diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5ff966f9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +root = true + +[*.cs] + +# CA1873: Avoid potentially expensive logging +# +# This rule fires on any non-literal argument to a logging method (including +# trivial field accesses like `_logger.LogInformation("X: {Path}", _path)`), +# on the theory that the argument might be expensive to evaluate when the +# level is disabled. In this codebase the cost is consistently negligible — +# the proper fix is migrating to LoggerMessage source generators, which is +# a larger separate effort. Until then, this rule is pure noise. +dotnet_diagnostic.CA1873.severity = none diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c5b35c9..a9dae595 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,7 +18,7 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Detect changed paths id: filter @@ -44,15 +44,24 @@ jobs: - 'Installer.Tests/**' - 'install/**' - 'upgrades/**' - - - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v4 + # 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 + # runs so the required 'build' check reports a result. + code: + - '**' + - '!**/*.md' + + - name: Setup .NET 10.0 + if: steps.filter.outputs.code != 'false' + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x cache: true cache-dependency-path: '**/packages.lock.json' - name: Restore dependencies + if: steps.filter.outputs.code != 'false' run: | dotnet restore Dashboard/Dashboard.csproj --locked-mode dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode @@ -61,12 +70,15 @@ jobs: dotnet restore Installer.Tests/Installer.Tests.csproj --locked-mode - name: Build Lite.Tests + if: steps.filter.outputs.code != 'false' run: dotnet build Lite.Tests/Lite.Tests.csproj -c Release --no-restore - name: Build Installer.Tests + if: steps.filter.outputs.code != 'false' run: dotnet build Installer.Tests/Installer.Tests.csproj -c Release --no-restore - name: Run Lite fast tests + if: steps.filter.outputs.code != 'false' 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 @@ -78,6 +90,7 @@ jobs: run: dotnet test Installer.Tests/Installer.Tests.csproj -c Release --no-build --verbosity normal --filter "FullyQualifiedName!~VersionDetectionTests&FullyQualifiedName!~IdempotencyTests&FullyQualifiedName!~AdversarialTests" - name: Get version + if: steps.filter.outputs.code != 'false' id: version shell: pwsh run: | @@ -85,6 +98,7 @@ jobs: echo "VERSION=$version" >> $env:GITHUB_OUTPUT - name: Publish Dashboard + if: steps.filter.outputs.code != 'false' run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard - name: Publish Dashboard (self-contained for Velopack) @@ -92,6 +106,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' run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite - name: Publish Lite (self-contained for Velopack) @@ -99,6 +114,7 @@ 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' run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release - name: Package release artifacts @@ -108,19 +124,21 @@ jobs: $version = "${{ steps.version.outputs.VERSION }}" New-Item -ItemType Directory -Force -Path releases - # Dashboard ZIP + # Dashboard ZIP — portable artifact for advanced/air-gapped users. + # The README points end users at Setup.exe (Velopack) because it sets up Start Menu + # shortcuts and Apps & Features registration; this ZIP is the explicit fallback. Compress-Archive -Path 'publish/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force - # Lite ZIP + # Lite ZIP — same rationale. Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - # Installer ZIP (CLI + SQL scripts) + # Installer ZIP (CLI + SQL scripts) — still shipped for server-side install $instDir = 'publish/Installer' New-Item -ItemType Directory -Force -Path $instDir New-Item -ItemType Directory -Force -Path "$instDir/install" New-Item -ItemType Directory -Force -Path "$instDir/upgrades" - Copy-Item 'Installer/bin/Release/net8.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir + Copy-Item 'Installer/bin/Release/net10.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir Copy-Item 'install/*.sql' "$instDir/install/" if (Test-Path 'upgrades') { Copy-Item 'upgrades/*' "$instDir/upgrades/" -Recurse -ErrorAction SilentlyContinue } if (Test-Path 'README.md') { Copy-Item 'README.md' $instDir } @@ -132,7 +150,7 @@ jobs: - name: Upload Dashboard for signing if: github.event_name == 'release' id: upload-dashboard - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Dashboard-unsigned path: publish/Dashboard/ @@ -140,7 +158,7 @@ jobs: - name: Upload Lite for signing if: github.event_name == 'release' id: upload-lite - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Lite-unsigned path: publish/Lite/ @@ -148,14 +166,14 @@ jobs: - name: Upload Installer for signing if: github.event_name == 'release' id: upload-installer - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Installer-unsigned path: publish/Installer/ - name: Sign Dashboard if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v1 + uses: signpath/github-action-submit-signing-request@v2 with: api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' @@ -168,7 +186,7 @@ jobs: - name: Sign Lite if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v1 + uses: signpath/github-action-submit-signing-request@v2 with: api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' @@ -181,7 +199,7 @@ jobs: - name: Sign Installer if: github.event_name == 'release' - uses: signpath/github-action-submit-signing-request@v1 + uses: signpath/github-action-submit-signing-request@v2 with: api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml index 19bca8d5..f556a7d0 100644 --- a/.github/workflows/check-version-bump.yml +++ b/.github/workflows/check-version-bump.yml @@ -1,48 +1,70 @@ -name: Check version bump -on: - pull_request: - branches: [main] - -jobs: - check-version: - if: github.head_ref == 'dev' - runs-on: ubuntu-latest - - steps: - - name: Checkout PR branch - uses: actions/checkout@v4 - - - name: Get PR version - id: pr - shell: pwsh - run: | - $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - Write-Host "PR version: $version" - - - name: Checkout main - uses: actions/checkout@v4 - with: - ref: main - path: main-branch - - - name: Get main version - id: main - shell: pwsh - run: | - $version = ([xml](Get-Content main-branch/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } - echo "VERSION=$version" >> $env:GITHUB_OUTPUT - Write-Host "Main version: $version" - - - name: Compare versions - env: - PR_VERSION: ${{ steps.pr.outputs.VERSION }} - MAIN_VERSION: ${{ steps.main.outputs.VERSION }} - run: | - echo "Main version: $MAIN_VERSION" - echo "PR version: $PR_VERSION" - if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then - echo "::error::Version in Dashboard.csproj ($PR_VERSION) has not changed from main. Bump the version before merging to main." - exit 1 - fi - echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION" +name: Check version bump +on: + pull_request: + branches: [main] + +jobs: + check-version: + if: github.head_ref == 'dev' + runs-on: ubuntu-latest + + steps: + - name: Checkout PR branch + uses: actions/checkout@v5 + + # Documentation-only PRs (e.g. a CHANGELOG correction) do not bump the + # version. The workflow still runs so the required check reports a result + # — using paths-ignore would skip the run entirely and leave the check + # stuck pending. Instead, the version comparison below is gated on whether + # any non-*.md file changed. + - name: Detect non-documentation changes + id: changes + uses: dorny/paths-filter@v3 + with: + filters: | + code: + - '**' + - '!**/*.md' + + - name: Get PR version + if: steps.changes.outputs.code == 'true' + id: pr + shell: pwsh + run: | + $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + Write-Host "PR version: $version" + + - name: Checkout main + if: steps.changes.outputs.code == 'true' + uses: actions/checkout@v5 + with: + ref: main + path: main-branch + + - name: Get main version + if: steps.changes.outputs.code == 'true' + id: main + shell: pwsh + run: | + $version = ([xml](Get-Content main-branch/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + echo "VERSION=$version" >> $env:GITHUB_OUTPUT + Write-Host "Main version: $version" + + - name: Compare versions + if: steps.changes.outputs.code == 'true' + env: + PR_VERSION: ${{ steps.pr.outputs.VERSION }} + MAIN_VERSION: ${{ steps.main.outputs.VERSION }} + run: | + echo "Main version: $MAIN_VERSION" + echo "PR version: $PR_VERSION" + if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then + echo "::error::Version in Dashboard.csproj ($PR_VERSION) has not changed from main. Bump the version before merging to main." + exit 1 + fi + echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION" + + - name: Skip notice + if: steps.changes.outputs.code != 'true' + run: echo "Only documentation (*.md) files changed — version bump check skipped." diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index ef0ef391..fd33e514 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -15,7 +15,7 @@ jobs: outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: dev fetch-depth: 0 @@ -38,14 +38,14 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: dev - - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v4 + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@v5 with: - dotnet-version: 8.0.x + dotnet-version: 10.0.x cache: true cache-dependency-path: '**/packages.lock.json' @@ -92,7 +92,7 @@ jobs: New-Item -ItemType Directory -Force -Path "$instDir/install" New-Item -ItemType Directory -Force -Path "$instDir/upgrades" - Copy-Item 'Installer/bin/Release/net8.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir + Copy-Item 'Installer/bin/Release/net10.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir Copy-Item 'install/*.sql' "$instDir/install/" if (Test-Path 'install/templates') { Copy-Item 'install/templates' "$instDir/install/templates" -Recurse -ErrorAction SilentlyContinue } if (Test-Path 'upgrades') { Copy-Item 'upgrades/*' "$instDir/upgrades/" -Recurse -ErrorAction SilentlyContinue } diff --git a/.github/workflows/sql-validation.yml b/.github/workflows/sql-validation.yml index e4f266d3..84c817e5 100644 --- a/.github/workflows/sql-validation.yml +++ b/.github/workflows/sql-validation.yml @@ -41,7 +41,7 @@ jobs: --health-retries 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install sqlcmd run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7def0f9c..08689041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.10.0] - TBD +## [2.11.0] - 2026-05-19 + +### Important + +- **.NET 10 upgrade** — Dashboard, Lite, Installer, and Installer.Core now target `net10.0` (Windows projects target `net10.0-windows`). Building from source now requires the .NET 10 SDK; CI is pinned to 10.0.204 via `global.json` for reproducible builds. End users running prebuilt Velopack installers do not need to install anything separately — runtime is bundled ([#958]) +- **Setup.exe is now the recommended install path** for Dashboard and Lite — the README steers users to the Velopack `Setup.exe`, which installs to `%LocalAppData%`, registers the apps under Apps & Features, creates Start Menu and Desktop shortcuts, and wires up auto-update. Portable ZIPs are still produced for both apps (CI release pipeline and local build scripts) as a fallback for advanced or air-gapped users. The Installer ZIP (CLI installer + SQL scripts) is unchanged +- **Shared `servers.json` location** — Dashboard and Lite now store `servers.json` under `%ProgramData%\PerformanceMonitor{Dashboard,Lite}\` so every Windows user on the same machine shares one server list. First run migrates an existing per-user `servers.json` to the new location and grants Authenticated Users Modify on the directory. SQL credentials remain per-user in Windows Credential Manager — each DBA re-enters SQL passwords on first connect; Windows Auth works with no re-entry + +### Added + +- **One-click snooze from the alert tray popup** in Lite — snooze an alert directly from the tray notification balloon without opening the main window ([#944]) +- **Snooze hint in email and Teams/Slack alert payloads** — alert messages now show the snooze duration / scheduled wake time when an alert is fired while a snooze is active ([#944]) +- **Process memory logging per collection cycle** in Lite — the collector now logs working set and private bytes at the end of each cycle, making it easier to track memory growth in long-running sessions + +### Changed + +- **Lite compaction memory tuning** ([#933]) — multiple changes to make parquet compaction robust on wide-row tables and large datasets: + - Cap the main collector connection's `memory_limit` and raise it transiently only for the `COPY` step + - Detect compaction `EXCLUDE` columns per merge step instead of once up front + - Raise the compaction `memory_limit` floor to 4 GB + - Set DuckDB `temp_directory` explicitly so spill files don't blow the OS temp drive + - Compact parquet in size-budgeted batches instead of one mega-batch +- **Trace collectors honor `config.collector_database_exclusions`** ([#887] follow-up) — the trace-file based collectors now filter against the exclusions table, matching the behavior of the eight DMV-based per-database collectors shipped in v2.9.0 +- **InstallerGui project directory removed** — the WPF InstallerGui was retired in v2.9.0 in favor of the Dashboard's integrated Add Server dialog. The project directory has now been deleted from the repo +- **Build warnings cleaned up** across Lite, Dashboard, and Installer ([#945]) +- **GitHub Actions runners bumped** to Node 24-compatible major versions to silence deprecation warnings + +### Fixed + +- **Re-run `installation_history` column widening** for servers that crossed v2.4.0 → v2.5.0 before PR #828's fix shipped in v2.7.0. Those servers ran the original widen script as a no-op against `master`, then advanced their installer_version past 2.5, so the now-fixed script never reapplied. Adds an idempotent ALTER under an `IF EXISTS` guard checking `max_length = 510` ([#828]) +- **Mute rules preserved across size-triggered DuckDB reset** in Lite — when the local DuckDB exceeded the configured size budget and was reset, mute rules were being lost. They now survive the reset ([#938]) +- **Chart tooltips break after tab switch** — root-cause fix for the popup-wedge issue first patched in v2.10.0. Both the Memory tab handlers and `CorrelatedCrosshairManager` are now resilient to tab churn ([#916], [#937]) +- **Stale `Monitor_LongQueries_*.trc` files cleaned up** by `config.data_retention` — the trace-file cleanup step previously left old `.trc` files behind on disk ([#951]) +- **Nullability guards** added to the remaining comparison overlay tasks that were producing CS86xx warnings + +[#828]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/828 +[#887]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/887 +[#916]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/916 +[#933]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/933 +[#937]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/937 +[#938]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/938 +[#944]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/944 +[#945]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/945 +[#951]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/951 +[#958]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/958 + +## [2.10.0] - 2026-05-04 ### Fixed @@ -17,7 +63,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Per-database grants for FinOps Index Analysis** documented in the README — sp_IndexCleanup-backed Index Analysis requires per-database `EXECUTE` grants on each user database you want to analyze ([#915]) [#915]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/915 -[#916]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/916 [#917]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/917 ## [2.9.0] - 2026-04-29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75a18e1c..c20d7167 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,7 +40,7 @@ This repository contains two editions of the SQL Server Performance Monitor: ### Prerequisites - **Windows 10/11** (required for WPF) -- **.NET 8.0 SDK** ([download](https://dotnet.microsoft.com/download/dotnet/8.0)) +- **.NET 10.0 SDK** ([download](https://dotnet.microsoft.com/download/dotnet/10.0)) - **Visual Studio 2022** or **VS Code** with C# extension - **SQL Server** (2016 or later) for testing - **Git** for version control @@ -67,11 +67,11 @@ dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release **Full Dashboard:** 1. Install the database on a SQL Server instance using the installer -2. Run `Dashboard/bin/Debug/net8.0-windows/Dashboard.exe` +2. Run `Dashboard/bin/Debug/net10.0-windows/Dashboard.exe` 3. Add your server connection and start monitoring **Lite Edition:** -1. Run `Lite/bin/Debug/net8.0-windows/PerformanceMonitorLite.exe` +1. Run `Lite/bin/Debug/net10.0-windows/PerformanceMonitorLite.exe` 2. Add a SQL Server connection (requires VIEW SERVER STATE permission) 3. Data collection begins automatically diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/Dashboard/Controls/MemoryContent.xaml.cs index 1ce108ca..691cbee6 100644 --- a/Dashboard/Controls/MemoryContent.xaml.cs +++ b/Dashboard/Controls/MemoryContent.xaml.cs @@ -106,11 +106,10 @@ public MemoryContent() SetupChartContextMenus(); Loaded += OnLoaded; Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => - { - Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; - DisposeChartHelpers(); - }; + /* WPF fires Unloaded on every TabControl tab switch, not just on destruction. + Tearing down chart hover helpers here unsubscribes their MouseMove handlers + and they are never re-registered when the user returns — this is the + root cause of #916. Final disposal happens via ServerTab.CleanupOnClose. */ // Apply dark theme immediately so charts don't flash white before data loads TabHelpers.ApplyThemeToChart(MemoryStatsOverviewChart); @@ -136,6 +135,7 @@ public void DisposeChartHelpers() _memoryClerksHover?.Dispose(); _planCacheHover?.Dispose(); _memoryPressureEventsHover?.Dispose(); + Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; } private void OnLoaded(object sender, RoutedEventArgs e) diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/Dashboard/Controls/QueryPerformanceContent.xaml.cs index 3d998fde..e500bf88 100644 --- a/Dashboard/Controls/QueryPerformanceContent.xaml.cs +++ b/Dashboard/Controls/QueryPerformanceContent.xaml.cs @@ -241,8 +241,10 @@ private void OnUnloaded(object sender, RoutedEventArgs e) _qsRegressionsUnfilteredData = null; _lrqPatternsUnfilteredData = null; - DisposeChartHelpers(); - Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; + /* WPF fires Unloaded on every TabControl tab switch, not just on destruction. + Tearing down chart hover helpers or unsubscribing ThemeManager here breaks + tooltips and theme refresh after a tab switch (#916). Final cleanup happens + via ServerTab.CleanupOnClose → DisposeChartHelpers. */ } public void DisposeChartHelpers() @@ -251,6 +253,7 @@ public void DisposeChartHelpers() _procDurationHover?.Dispose(); _qsDurationHover?.Dispose(); _execTrendsHover?.Dispose(); + Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; } private void OnThemeChanged(string _) diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/Dashboard/Controls/ResourceMetricsContent.xaml.cs index 3d4c3afa..45dc3ca0 100644 --- a/Dashboard/Controls/ResourceMetricsContent.xaml.cs +++ b/Dashboard/Controls/ResourceMetricsContent.xaml.cs @@ -130,11 +130,10 @@ public ResourceMetricsContent() SetupChartContextMenus(); Loaded += OnLoaded; Helpers.ThemeManager.ThemeChanged += OnThemeChanged; - Unloaded += (_, _) => - { - Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; - DisposeChartHelpers(); - }; + /* WPF fires Unloaded on every TabControl tab switch, not just on destruction. + Tearing down chart hover helpers here unsubscribes their MouseMove handlers + and they are never re-registered when the user returns — this is the + root cause of #916. Final disposal happens via ServerTab.CleanupOnClose. */ // Apply dark theme immediately so charts don't flash white before data loads TabHelpers.ApplyThemeToChart(LatchStatsChart); @@ -175,6 +174,7 @@ public void DisposeChartHelpers() _waitStatsHover?.Dispose(); _tempdbStatsHover?.Dispose(); _tempDbLatencyHover?.Dispose(); + Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; } private void OnLoaded(object sender, RoutedEventArgs e) diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/Dashboard/Controls/SystemEventsContent.xaml.cs index 726f3d2d..2ddb8af2 100644 --- a/Dashboard/Controls/SystemEventsContent.xaml.cs +++ b/Dashboard/Controls/SystemEventsContent.xaml.cs @@ -175,20 +175,40 @@ public SystemEventsContent() private void OnUnloaded(object sender, RoutedEventArgs e) { - /* Unsubscribe from filter popup events to prevent memory leaks */ + /* WPF fires Unloaded on every TabControl tab switch, not just on destruction. + Unsubscribing ThemeManager or filter-popup events here breaks them on + return to the tab (#916 family). Final cleanup happens via + ServerTab.CleanupOnClose → DisposeChartHelpers. */ + } + + public void DisposeChartHelpers() + { + _badPagesHover?.Dispose(); + _dumpRequestsHover?.Dispose(); + _accessViolationsHover?.Dispose(); + _writeAccessViolationsHover?.Dispose(); + _nonYieldingTasksHover?.Dispose(); + _latchWarningsHover?.Dispose(); + _sickSpinlocksHover?.Dispose(); + _cpuComparisonHover?.Dispose(); + _severeErrorsHover?.Dispose(); + _ioIssuesHover?.Dispose(); + _longestPendingIoHover?.Dispose(); + _schedulerIssuesHover?.Dispose(); + _memoryConditionsHover?.Dispose(); + _cpuTasksHover?.Dispose(); + _memoryBrokerHover?.Dispose(); + _memoryBrokerRatioHover?.Dispose(); + _memoryNodeOomHover?.Dispose(); + _memoryNodeOomUtilHover?.Dispose(); + _memoryNodeOomMemoryHover?.Dispose(); + if (_filterPopupContent != null) { _filterPopupContent.FilterApplied -= FilterPopup_FilterApplied; _filterPopupContent.FilterCleared -= FilterPopup_FilterCleared; } - /* Clear large data collections to free memory */ - _systemHealthUnfilteredData = null; - _severeErrorsUnfilteredData = null; - _ioIssuesUnfilteredData = null; - _memoryBrokerUnfilteredData = null; - _memoryNodeOOMUnfilteredData = null; - Helpers.ThemeManager.ThemeChanged -= OnThemeChanged; } diff --git a/Dashboard/Dashboard.csproj b/Dashboard/Dashboard.csproj index fc8a7b00..0f265bef 100644 --- a/Dashboard/Dashboard.csproj +++ b/Dashboard/Dashboard.csproj @@ -1,16 +1,16 @@ WinExe - net8.0-windows + net10.0-windows enable true PerformanceMonitorDashboard.Program PerformanceMonitorDashboard SQL Server Performance Monitor Dashboard - 2.10.0 - 2.10.0.0 - 2.10.0.0 - 2.10.0 + 2.11.0 + 2.11.0.0 + 2.11.0.0 + 2.11.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC EDD.ico @@ -24,7 +24,7 @@ CA1508: Dead code - false positives on nullable DateTime parameters CA2201: Generic exception - acceptable for global error handlers CS4014: Unawaited async - intentional fire-and-forget refresh calls --> - PerformanceMonitorInstaller SQL Server Performance Monitor Installer - 2.10.0 - 2.10.0.0 - 2.10.0.0 - 2.10.0 + 2.11.0 + 2.11.0.0 + 2.11.0.0 + 2.11.0 Darling Data, LLC Copyright © 2026 Darling Data, LLC Installation utility for SQL Server Performance Monitor - Supports SQL Server 2016-2025 diff --git a/Installer/README.md b/Installer/README.md index 3f852e21..36f416e7 100644 --- a/Installer/README.md +++ b/Installer/README.md @@ -2,6 +2,6 @@ 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. -No .NET installation required on the target machine — the executable includes the full .NET 8.0 runtime. +No .NET installation required on the target machine — the executable includes the full .NET 10.0 runtime. See the [root README](../README.md) for usage, command-line options, and troubleshooting. diff --git a/Installer/packages.lock.json b/Installer/packages.lock.json index 5cecd6b2..0bbf9101 100644 --- a/Installer/packages.lock.json +++ b/Installer/packages.lock.json @@ -1,30 +1,30 @@ { "version": 1, "dependencies": { - "net8.0": { + "net10.0": { "Microsoft.Data.SqlClient": { "type": "Direct", "requested": "[7.0.1, )", "resolved": "7.0.1", "contentHash": "9jZFXAJ2ThNYK7lhj2RhH7klXVNaWSvZpQncq3bPIOjmHBrdjwgeO4c8wucUVxQwFT8rAA13Z2F2jzoYR7ICDw==", "dependencies": { - "Microsoft.Bcl.Cryptography": "8.0.0", + "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": "8.0.1", + "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", - "System.Configuration.ConfigurationManager": "8.0.1", - "System.Security.Cryptography.Pkcs": "8.0.1" + "System.Configuration.ConfigurationManager": "9.0.13", + "System.Security.Cryptography.Pkcs": "9.0.13" } }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.26, )", - "resolved": "8.0.26", - "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA==" + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw==" }, "Azure.Core": { "type": "Transitive", @@ -55,8 +55,8 @@ }, "Microsoft.Bcl.Cryptography": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "Y3t/c7C5XHJGFDnohjf1/9SYF3ZOfEU1fkNQuKg/dGf9hN18yrQj2owHITGfNS3+lKJdW6J4vY98jYu57jCO8A==" + "resolved": "9.0.13", + "contentHash": "5T+bH3Lb1nEe8Hf/ixMxLmhlrx5wRi53wv7OhVwG2F1ZviW1ejFRS1NHur3uqPpJRGtkQwUchtY6zhVK2R+v+w==" }, "Microsoft.Data.SqlClient.Extensions.Abstractions": { "type": "Transitive", @@ -91,22 +91,22 @@ }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "resolved": "9.0.13", + "contentHash": "nTT90JYIpcXEy6fcU8LPVycONkO6wipROgP9pyC4uxBif4fazu2rDzlWSntqtzr5p8GbQL2EopsYuTZR3yoeag==", "dependencies": { - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.Primitives": "9.0.13" } }, "Microsoft.Extensions.Caching.Memory": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "HFDnhYLccngrzyGgHkjEDU5FMLn4MpOsr5ElgsBMC4yx6lJh4jeWO7fHS8+TXPq+dgxCmUa/Trl8svObmwW4QA==", + "resolved": "9.0.13", + "contentHash": "OdQmN8LYcUEu20Fxii9mk68nHJGL+JPXF3w0+hxenf0oDDdDBA+ZV/S92FmIgAWAElowIiFA/g0x+8YB1g80Hg==", "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "8.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", - "Microsoft.Extensions.Logging.Abstractions": "8.0.2", - "Microsoft.Extensions.Options": "8.0.2", - "Microsoft.Extensions.Primitives": "8.0.0" + "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": { @@ -128,8 +128,7 @@ "contentHash": "mQiTzAj7PIJ2A9YXR5QhgulS1fTWhmQc3ckd1Mrf3hKW07d03fBDqx8vVaFw+cRTebDOeB6pNqdWdnRxsi1hBA==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "Microsoft.Extensions.Options": "10.0.3", - "System.Diagnostics.DiagnosticSource": "10.0.3" + "Microsoft.Extensions.Options": "10.0.3" } }, "Microsoft.Extensions.FileProviders.Abstractions": { @@ -157,8 +156,7 @@ "resolved": "10.0.3", "contentHash": "lxl0WLk7ROgBFAsjcOYjQ8/DVK+VMszxGBzUhgtQmAsTNldLL5pk9NG/cWTsXHq0lUhUEAtZkEE7jOGOA8bGKQ==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3", - "System.Diagnostics.DiagnosticSource": "10.0.3" + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.3" } }, "Microsoft.Extensions.Options": { @@ -180,9 +178,7 @@ "resolved": "4.83.0", "contentHash": "eiirunq8tuQW1KKqi7BcD+jR6/Ge/wDt11HW6K9dTGdqS6TUuA81PtPIy1gijarEOaMBeHEPWuAiRyLUO4M87Q==", "dependencies": { - "Microsoft.IdentityModel.Abstractions": "8.14.0", - "System.Diagnostics.DiagnosticSource": "6.0.1", - "System.ValueTuple": "4.5.0" + "Microsoft.IdentityModel.Abstractions": "8.14.0" } }, "Microsoft.Identity.Client.Extensions.Msal": { @@ -237,7 +233,7 @@ "resolved": "8.16.0", "contentHash": "rtViGJcGsN7WcfUNErwNeQgjuU5cJNl6FDQsfi9TncwO+Epzn0FTfBsg3YuFW1Q0Ch/KPxaVdjLw3/+5Z5ceFQ==", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", "Microsoft.IdentityModel.Logging": "8.16.0" } }, @@ -259,22 +255,17 @@ }, "System.Configuration.ConfigurationManager": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "gPYFPDyohW2gXNhdQRSjtmeS6FymL2crg4Sral1wtvEJ7DUqFCDWDVbbLobASbzxfic8U1hQEdC7hmg9LHncMw==", + "resolved": "9.0.13", + "contentHash": "GbBrJq9S/gYpHzm7Pxx6Y5tDyfSfyxW6tlP5oiKJV38uf19Wp+GIIAnWfyL1zmNiz1+EjwVapw2WkBFvvqKQzg==", "dependencies": { - "System.Diagnostics.EventLog": "8.0.1", - "System.Security.Cryptography.ProtectedData": "8.0.0" + "System.Diagnostics.EventLog": "9.0.13", + "System.Security.Cryptography.ProtectedData": "9.0.13" } }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.3", - "contentHash": "IuZXyF3K5X+mCsBKIQ87Cn/V4Nyb39vyCbzfH/AkoneSWNV/ExGQ/I0m4CEaVAeFh9fW6kp2NVObkmevd1Ys7A==" - }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg==" + "resolved": "9.0.13", + "contentHash": "675Rk4RwaVrWo09wrR2rpDVKixtgtnhd5NhPrn6O21uj92JvE61KTGupn76M2N6Ff/xJjY3SHSfSg0MIanEzGw==" }, "System.IdentityModel.Tokens.Jwt": { "type": "Transitive", @@ -285,47 +276,20 @@ "Microsoft.IdentityModel.Tokens": "8.16.0" } }, - "System.IO.Pipelines": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "26LbFXHKd7PmRnWlkjnYgmjd5B6HYVG+1MpTO25BdxTJnx6D0O16JPAC/S4YBqjtt4YpfGj1QO/Ss6SPMGEGQw==" - }, "System.Memory.Data": { "type": "Transitive", "resolved": "10.0.1", - "contentHash": "BZC4mhdL569AXV56ep9YO6ShjhxFXGP7SwVX0Bc/e0dJPWnS6aBEXZJXqh64RVx8HquqWHkJUINBydLRQ1yq0g==", - "dependencies": { - "System.Text.Json": "10.0.1" - } + "contentHash": "BZC4mhdL569AXV56ep9YO6ShjhxFXGP7SwVX0Bc/e0dJPWnS6aBEXZJXqh64RVx8HquqWHkJUINBydLRQ1yq0g==" }, "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" + "resolved": "9.0.13", + "contentHash": "dxJhkuoaelvWy588wPXjShNks+ZMiSgXnN75/u+DPbER5PqKrLPDftE0BvGM7nDK/scQAVlD+gRXlCAAjWi58Q==" }, "System.Security.Cryptography.ProtectedData": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "+TUFINV2q2ifyXauQXRwy4CiBhqvDEDZeVJU7qfxya4aRYOKzVBpN+4acx25VcPB9ywUN6C0n8drWl110PhZEg==" - }, - "System.Text.Encodings.Web": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "cVAka0o1rJJ5/De0pjNs7jcaZk5hUGf1HGzUyVmE2MEB1Vf0h/8qsWxImk1zjitCbeD2Avaq2P2+usdvqgbeVQ==" - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "EsgwDgU1PFqhrFA9l5n+RBu76wFhNGCEwu8ITrBNhjPP3MxLyklroU5GIF8o6JYpYg6T4KD/VICfMdgPAvNp5g==", - "dependencies": { - "System.IO.Pipelines": "10.0.1", - "System.Text.Encodings.Web": "10.0.1" - } - }, - "System.ValueTuple": { - "type": "Transitive", - "resolved": "4.5.0", - "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" + "resolved": "9.0.13", + "contentHash": "t8S9IDpjJKsLpLkeBdW8cWtcPyYqrGu93Dej1RO6WwuL/lkFSqWlan3rMJfortqz1mRIh+sys2AFsSA6jWJ3Jg==" }, "installer.core": { "type": "Project", @@ -335,23 +299,23 @@ } } }, - "net8.0/win-x64": { + "net10.0/win-x64": { "Microsoft.Data.SqlClient": { "type": "Direct", "requested": "[7.0.1, )", "resolved": "7.0.1", "contentHash": "9jZFXAJ2ThNYK7lhj2RhH7klXVNaWSvZpQncq3bPIOjmHBrdjwgeO4c8wucUVxQwFT8rAA13Z2F2jzoYR7ICDw==", "dependencies": { - "Microsoft.Bcl.Cryptography": "8.0.0", + "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": "8.0.1", + "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", - "System.Configuration.ConfigurationManager": "8.0.1", - "System.Security.Cryptography.Pkcs": "8.0.1" + "System.Configuration.ConfigurationManager": "9.0.13", + "System.Security.Cryptography.Pkcs": "9.0.13" } }, "Microsoft.Data.SqlClient.SNI.runtime": { @@ -361,18 +325,13 @@ }, "System.Diagnostics.EventLog": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "n1ZP7NM2Gkn/MgD8+eOT5MulMj6wfeQMNS2Pizvq5GHCZfjlFMXV2irQlQmJhwA2VABC57M0auudO89Iu2uRLg==" + "resolved": "9.0.13", + "contentHash": "675Rk4RwaVrWo09wrR2rpDVKixtgtnhd5NhPrn6O21uj92JvE61KTGupn76M2N6Ff/xJjY3SHSfSg0MIanEzGw==" }, "System.Security.Cryptography.Pkcs": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "CoCRHFym33aUSf/NtWSVSZa99dkd0Hm7OCZUxORBjRB16LNhIEOf8THPqzIYlvKM0nNDAPTRBa1FxEECrgaxxA==" - }, - "System.Text.Encodings.Web": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "cVAka0o1rJJ5/De0pjNs7jcaZk5hUGf1HGzUyVmE2MEB1Vf0h/8qsWxImk1zjitCbeD2Avaq2P2+usdvqgbeVQ==" + "resolved": "9.0.13", + "contentHash": "dxJhkuoaelvWy588wPXjShNks+ZMiSgXnN75/u+DPbER5PqKrLPDftE0BvGM7nDK/scQAVlD+gRXlCAAjWi58Q==" } } } diff --git a/InstallerGui/App.xaml b/InstallerGui/App.xaml deleted file mode 100644 index f28634d7..00000000 --- a/InstallerGui/App.xaml +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/InstallerGui/App.xaml.cs b/InstallerGui/App.xaml.cs deleted file mode 100644 index 6ccc1a5a..00000000 --- a/InstallerGui/App.xaml.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. - * - * 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.Threading; -using PerformanceMonitorInstallerGui.Utilities; - -namespace PerformanceMonitorInstallerGui -{ - public partial class App : Application - { - protected override void OnStartup(StartupEventArgs e) - { - base.OnStartup(e); - - Logger.LogToFile("App.OnStartup", "Application starting..."); - - /* - Register global exception handlers - */ - AppDomain.CurrentDomain.UnhandledException += OnUnhandledException; - DispatcherUnhandledException += OnDispatcherUnhandledException; - TaskScheduler.UnobservedTaskException += OnUnobservedTaskException; - } - - private void OnUnhandledException(object sender, UnhandledExceptionEventArgs e) - { - var exception = e.ExceptionObject as Exception; - if (exception != null) - { - Logger.LogToFile("OnUnhandledException", exception); - } - - if (e.IsTerminating) - { - MessageBox.Show( - $"A fatal error occurred and the application must close.\n\n" + - $"Error: {exception?.Message}\n\n" + - $"Inner: {exception?.InnerException?.Message}\n\n" + - $"Log file: {Logger.LogFilePath}", - "Fatal Error", - MessageBoxButton.OK, - MessageBoxImage.Error); - } - } - - private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) - { - Logger.LogToFile("OnDispatcherUnhandledException", e.Exception); - - e.Handled = true; - - MessageBox.Show( - $"An error occurred: {e.Exception.Message}\n\n" + - $"Inner: {e.Exception.InnerException?.Message}\n\n" + - $"Log file: {Logger.LogFilePath}", - "Error", - MessageBoxButton.OK, - MessageBoxImage.Error); - } - - private void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) - { - if (e.Exception != null) - { - Logger.LogToFile("OnUnobservedTaskException", e.Exception); - } - - e.SetObserved(); - - Dispatcher.Invoke(() => - { - MessageBox.Show( - $"A background task error occurred: {e.Exception?.InnerException?.Message ?? e.Exception?.Message}\n\n" + - $"Log file: {Logger.LogFilePath}", - "Background Error", - MessageBoxButton.OK, - MessageBoxImage.Warning); - }); - } - } -} diff --git a/InstallerGui/EDD.ico b/InstallerGui/EDD.ico deleted file mode 100644 index a7f07aa3..00000000 Binary files a/InstallerGui/EDD.ico and /dev/null differ diff --git a/InstallerGui/InstallerGui.csproj b/InstallerGui/InstallerGui.csproj deleted file mode 100644 index 6e6a58ef..00000000 --- a/InstallerGui/InstallerGui.csproj +++ /dev/null @@ -1,44 +0,0 @@ - - - - WinExe - net8.0-windows - enable - true - PerformanceMonitorInstallerGui - PerformanceMonitorInstallerGui - SQL Server Performance Monitor Installer - 2.4.1 - 2.4.1.0 - 2.4.1.0 - 2.4.1 - Darling Data, LLC - Copyright © 2026 Darling Data, LLC - EDD.ico - win-x64 - true - true - true - false - true - true - latest-recommended - CA1849;CA2007;CA1508;CA1031;CA1001;CA1822;CA1305;CA2100;CA1002;CA1845;CA1861;CA2234;CA1062;CA1823 - - - - - - - - - - - - - - PreserveNewest - - - - diff --git a/InstallerGui/MainWindow.xaml b/InstallerGui/MainWindow.xaml deleted file mode 100644 index 1e428e38..00000000 --- a/InstallerGui/MainWindow.xaml +++ /dev/null @@ -1,282 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -