From 97c2f99724ccee8bc696accb4ded91c9b2338da1 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:53:45 -0400 Subject: [PATCH 001/296] Deprecate Full Dashboard + CLI Installer: move to deprecated/, strip from releases - Move Dashboard, Dashboard.Tests, Installer, Installer.Core, Installer.Tests -> deprecated/ (Lite/Darling never reference them). Fix .sln + moved-csproj relative paths (shared-lib refs +1 ../, install/upgrades globs +1 ../). Solution builds green; Installer.Tests 190 pass at the new path. - build.yml: remove Dashboard + CLI Installer from the RELEASE artifacts (publish/sign/upload/package/velopack). Keep Lite + Darling. Installer.Tests still built/tested (repathed). Version source -> Lite. Path-filter globs -> deprecated/. - nightly.yml / check-version-bump.yml: repath the moved projects to deprecated/. - README + issue/PR templates: reframe to Darling (flagship) / Lite; Full Dashboard + CLI Installer flagged deprecated. - install/ + upgrades/ stay at repo root (many path-dependent consumers). Co-Authored-By: Claude Opus 4.8 --- .github/ISSUE_TEMPLATE/bug_report.yml | 14 +- .github/ISSUE_TEMPLATE/feature_request.yml | 4 +- .github/ISSUE_TEMPLATE/question.yml | 5 +- .github/PULL_REQUEST_TEMPLATE.md | 8 +- .github/workflows/build.yml | 155 ++---------------- .github/workflows/check-version-bump.yml | 6 +- .github/workflows/nightly.yml | 12 +- PerformanceMonitor.sln | 10 +- README.md | 24 +-- .../AlertBadgeConditionTests.cs | 0 .../AlertContextBuildersTests.cs | 0 .../AlertDeliveryModeOverrideTests.cs | 0 .../AnalysisEnabledPreferenceTests.cs | 0 .../AnalysisNotificationTests.cs | 0 .../AnomalyIncidentReconcilerTests.cs | 0 .../AzureAuthConnectionStringTests.cs | 0 .../Dashboard.Tests}/BaselineBucketTests.cs | 0 .../BlockingChainReconstructorTests.cs | 0 .../BlockingPairRowMergeTests.cs | 0 .../ChartClickIsolateTests.cs | 0 .../ChartStyleYLimitsTests.cs | 0 .../Dashboard.Tests}/CoFiredSummaryTests.cs | 0 .../CollectionStoppedDetectionTests.cs | 0 .../Dashboard.Tests}/Dashboard.Tests.csproj | 0 .../DashboardAlertSettingsTests.cs | 0 .../Dashboard.Tests}/DataGridExportTests.cs | 0 .../DeadlockAlertClearPolicyTests.cs | 0 .../EmailTemplateBrandingTests.cs | 0 .../FactAdviceComposeTests.cs | 0 .../FactAdviceCorrectnessTests.cs | 0 .../Dashboard.Tests}/FactScorerTests.cs | 0 .../FailedJobWatermarkPreferenceTests.cs | 0 .../Dashboard.Tests}/FailedJobsQueryTests.cs | 0 .../FileAutogrowthHandlerTests.cs | 0 .../FinOpsHeatmapBuilderTests.cs | 0 .../FinOpsInventoryQueryTests.cs | 0 .../Dashboard.Tests}/IncidentIdTests.cs | 0 .../Dashboard.Tests}/InferenceEngineTests.cs | 0 .../JsonAlertHistoryStoreDedupKeyTests.cs | 0 .../JsonAlertHistoryStoreMutedFilterTests.cs | 0 .../Dashboard.Tests}/McpSchemaCompatTests.cs | 0 .../MissingIndexAdvisoryTests.cs | 0 .../PlanAdvisoryAggregatorTests.cs | 0 .../RecommendationDeduperTests.cs | 0 .../RecommendationsViewModelTests.cs | 0 .../RemediationApplyServiceTests.cs | 0 .../RemediationContractTests.cs | 0 .../RemediationGoldenSampleTests.cs | 0 .../Dashboard.Tests}/RemediationTests.cs | 0 .../ServerConfigHandlerTests.cs | 0 .../ServerConfigRecommendationTests.cs | 0 .../ServerManagerDetectionQueryTests.cs | 0 .../ServerPropertiesResilienceTests.cs | 0 .../SqlServerFindingStoreMappingTests.cs | 0 .../Dashboard.Tests}/ThemeParityTests.cs | 0 .../WebhookPayloadBrandingTests.cs | 0 .../Dashboard}/AboutWindow.xaml | 0 .../Dashboard}/AboutWindow.xaml.cs | 0 .../Dashboard}/AddServerDialog.xaml | 0 .../Dashboard}/AddServerDialog.xaml.cs | 0 .../Dashboard}/AlertDetailWindow.xaml | 0 .../Dashboard}/AlertDetailWindow.xaml.cs | 0 .../Dashboard}/Analysis/AnalysisService.cs | 0 .../Analysis/BlockingChainViewerProjection.cs | 0 .../Analysis/BlockingPairRowQuery.cs | 0 .../Analysis/SqlServerAnomalyDetector.cs | 0 .../Analysis/SqlServerBaselineProvider.cs | 0 .../SqlServerDrillDownCollector.Blocking.cs | 0 .../SqlServerDrillDownCollector.Config.cs | 0 .../SqlServerDrillDownCollector.Plans.cs | 0 .../SqlServerDrillDownCollector.Queries.cs | 0 .../SqlServerDrillDownCollector.Storage.cs | 0 .../Analysis/SqlServerDrillDownCollector.cs | 0 .../SqlServerFactCollector.Activity.cs | 0 .../Analysis/SqlServerFactCollector.Config.cs | 0 .../SqlServerFactCollector.QueryPerf.cs | 0 .../SqlServerFactCollector.Resources.cs | 0 .../SqlServerFactCollector.Storage.cs | 0 .../Analysis/SqlServerFactCollector.Waits.cs | 0 .../Analysis/SqlServerFactCollector.cs | 0 .../Analysis/SqlServerFindingStore.cs | 0 .../Analysis/SqlServerPlanFetcher.cs | 0 {Dashboard => deprecated/Dashboard}/App.xaml | 0 .../Dashboard}/App.xaml.cs | 0 .../Dashboard}/CollectionLogWindow.xaml | 0 .../Dashboard}/CollectionLogWindow.xaml.cs | 0 .../Dashboard}/CollectorScheduleWindow.xaml | 0 .../CollectorScheduleWindow.xaml.cs | 0 .../Dashboard}/ColumnFilterPopup.xaml | 0 .../Dashboard}/ColumnFilterPopup.xaml.cs | 0 .../Controls/AlertsHistoryContent.xaml | 0 .../Controls/AlertsHistoryContent.xaml.cs | 0 .../Controls/ConfigChangesContent.xaml | 0 .../Controls/ConfigChangesContent.xaml.cs | 0 .../CorrelatedTimelineLanesControl.xaml | 0 .../CorrelatedTimelineLanesControl.xaml.cs | 0 .../Controls/CriticalIssuesContent.xaml | 0 .../Controls/CriticalIssuesContent.xaml.cs | 0 .../Controls/CurrentConfigContent.xaml | 0 .../Controls/CurrentConfigContent.xaml.cs | 0 .../Controls/DailySummaryContent.xaml | 0 .../Controls/DailySummaryContent.xaml.cs | 0 .../Controls/DefaultTraceContent.xaml | 0 .../Controls/DefaultTraceContent.xaml.cs | 0 .../Controls/FinOpsContent.CopyExport.cs | 0 .../Controls/FinOpsContent.Filters.cs | 0 .../Controls/FinOpsContent.Loaders.cs | 0 .../Controls/FinOpsContent.Locking.cs | 0 .../Controls/FinOpsContent.ObjectHeatmap.cs | 0 .../Controls/FinOpsContent.Refresh.cs | 0 .../Dashboard}/Controls/FinOpsContent.xaml | 0 .../Dashboard}/Controls/FinOpsContent.xaml.cs | 0 .../Dashboard}/Controls/LandingPage.xaml | 0 .../Dashboard}/Controls/LandingPage.xaml.cs | 0 .../Dashboard}/Controls/LoadingOverlay.xaml | 0 .../Controls/LoadingOverlay.xaml.cs | 0 .../Controls/MemoryContent.CopyExport.cs | 0 .../Controls/MemoryContent.MemoryClerks.cs | 0 .../Controls/MemoryContent.MemoryGrants.cs | 0 .../Controls/MemoryContent.MemoryPressure.cs | 0 .../Controls/MemoryContent.MemoryStats.cs | 0 .../Controls/MemoryContent.PlanCache.cs | 0 .../Dashboard}/Controls/MemoryContent.xaml | 0 .../Dashboard}/Controls/MemoryContent.xaml.cs | 0 .../QueryPerformanceContent.BarCells.cs | 0 .../QueryPerformanceContent.Comparison.cs | 0 .../QueryPerformanceContent.CopyExport.cs | 0 .../QueryPerformanceContent.Filters.cs | 0 .../QueryPerformanceContent.Heatmap.cs | 0 .../Controls/QueryPerformanceContent.Plans.cs | 0 .../QueryPerformanceContent.Slicers.cs | 0 .../Controls/QueryPerformanceContent.xaml | 0 .../Controls/QueryPerformanceContent.xaml.cs | 0 .../Controls/RecommendationsContent.xaml | 0 .../Controls/RecommendationsContent.xaml.cs | 0 .../Controls/RecommendationsViewModel.cs | 0 .../ResourceMetricsContent.CopyExport.cs | 0 .../ResourceMetricsContent.FileIoLatency.cs | 0 .../ResourceMetricsContent.LatchStats.cs | 0 .../ResourceMetricsContent.PerfmonCounters.cs | 0 .../ResourceMetricsContent.SessionStats.cs | 0 .../ResourceMetricsContent.SpinlockStats.cs | 0 .../ResourceMetricsContent.TempdbStats.cs | 0 .../ResourceMetricsContent.WaitStatsDetail.cs | 0 .../Controls/ResourceMetricsContent.xaml | 0 .../Controls/ResourceMetricsContent.xaml.cs | 0 .../Dashboard}/Controls/ServerHealthCard.xaml | 0 .../Controls/ServerHealthCard.xaml.cs | 0 .../Dashboard}/Controls/SnoozeBalloon.xaml | 0 .../Dashboard}/Controls/SnoozeBalloon.xaml.cs | 0 .../Dashboard}/Controls/StyledBalloon.xaml | 0 .../Dashboard}/Controls/StyledBalloon.xaml.cs | 0 .../Controls/SystemEventsContent.CPUTasks.cs | 0 .../SystemEventsContent.CopyExport.cs | 0 .../SystemEventsContent.FilterPopup.cs | 0 .../Controls/SystemEventsContent.IOIssues.cs | 0 .../SystemEventsContent.MemoryBroker.cs | 0 .../SystemEventsContent.MemoryConditions.cs | 0 .../SystemEventsContent.MemoryNodeOOM.cs | 0 .../SystemEventsContent.SchedulerIssues.cs | 0 .../SystemEventsContent.SevereErrors.cs | 0 .../SystemEventsContent.SystemHealth.cs | 0 .../Controls/SystemEventsContent.xaml | 0 .../Controls/SystemEventsContent.xaml.cs | 0 .../Controls/TimeRangeSlicerControl.xaml | 0 .../Controls/TimeRangeSlicerControl.xaml.cs | 0 .../Converters/NullToBooleanConverter.cs | 0 .../Converters/QueryTextCleanupConverter.cs | 0 .../Converters/ValueToBrushConverter.cs | 0 .../Dashboard}/Dashboard.csproj | 12 +- {Dashboard => deprecated/Dashboard}/EDD.ico | Bin .../Dashboard}/ExcludedDatabasesDialog.xaml | 0 .../ExcludedDatabasesDialog.xaml.cs | 0 .../Dashboard}/GlobalUsings.cs | 0 .../Dashboard}/Helpers/DateFilterHelper.cs | 0 .../Dashboard}/Helpers/Logger.cs | 0 .../Dashboard}/Helpers/LoggerAdapter.cs | 0 .../Dashboard}/Helpers/MethodProfiler.cs | 0 .../Dashboard}/Helpers/NativeMethods.cs | 0 .../Dashboard}/Helpers/NumericFilterHelper.cs | 0 .../Dashboard}/Helpers/QueryLogger.cs | 0 .../Dashboard}/Helpers/ServerTimeConverter.cs | 0 .../Dashboard}/Helpers/ServerTimeHelper.cs | 0 .../Dashboard}/Helpers/TabHelpers.cs | 0 .../Interfaces/ICredentialService.cs | 0 .../Dashboard}/Interfaces/IServerManager.cs | 0 .../Interfaces/IUserPreferencesService.cs | 0 .../Dashboard}/MainWindow.AlertEngine.cs | 0 .../Dashboard}/MainWindow.PlanViewer.cs | 0 .../Dashboard}/MainWindow.xaml | 0 .../Dashboard}/MainWindow.xaml.cs | 0 .../Dashboard}/ManageMuteRulesWindow.xaml | 0 .../Dashboard}/ManageMuteRulesWindow.xaml.cs | 0 .../Dashboard}/ManageServersWindow.xaml | 0 .../Dashboard}/ManageServersWindow.xaml.cs | 0 .../Dashboard}/Mcp/DatabaseServiceRegistry.cs | 0 .../Dashboard}/Mcp/McpActiveQueryTools.cs | 0 .../Dashboard}/Mcp/McpAlertTools.cs | 0 .../Dashboard}/Mcp/McpAnalysisTools.cs | 0 .../Dashboard}/Mcp/McpBlockingTools.cs | 0 .../Dashboard}/Mcp/McpConfigHistoryTools.cs | 0 .../Dashboard}/Mcp/McpCpuTools.cs | 0 .../Dashboard}/Mcp/McpDiagnosticTools.cs | 0 .../Dashboard}/Mcp/McpDiscoveryTools.cs | 0 .../Dashboard}/Mcp/McpHealthParserTools.cs | 0 .../Dashboard}/Mcp/McpHealthTools.cs | 0 .../Dashboard}/Mcp/McpHostService.cs | 0 .../Dashboard}/Mcp/McpInstructions.cs | 0 .../Dashboard}/Mcp/McpIoTools.cs | 0 .../Dashboard}/Mcp/McpJobTools.cs | 0 .../Dashboard}/Mcp/McpLatchSpinlockTools.cs | 0 .../Dashboard}/Mcp/McpMemoryTools.cs | 0 .../Dashboard}/Mcp/McpObjectStatsTools.cs | 0 .../Dashboard}/Mcp/McpPerfmonTools.cs | 0 .../Dashboard}/Mcp/McpPlanTools.cs | 0 .../Dashboard}/Mcp/McpQueryTools.cs | 0 .../Dashboard}/Mcp/McpSchedulerTools.cs | 0 .../Dashboard}/Mcp/McpServerInventoryTools.cs | 0 .../Dashboard}/Mcp/McpSystemEventTools.cs | 0 .../Dashboard}/Mcp/McpTempDbTools.cs | 0 .../Dashboard}/Mcp/McpWaitTools.cs | 0 .../Dashboard}/Mcp/ServerResolver.cs | 0 .../Dashboard}/Models/AlertHealthResult.cs | 0 .../Models/BlockedSessionTrendItem.cs | 0 .../Models/BlockingDeadlockStatsItem.cs | 0 .../Dashboard}/Models/BlockingEventItem.cs | 0 .../Dashboard}/Models/CollectionHealthItem.cs | 0 .../Dashboard}/Models/CollectionLogEntry.cs | 0 .../Models/CollectorScheduleItem.cs | 0 .../Dashboard}/Models/CpuDataPoint.cs | 0 .../Dashboard}/Models/CpuPressureItem.cs | 0 .../Dashboard}/Models/CpuSpikeItem.cs | 0 .../Models/CpuUtilizationHistoryItem.cs | 0 .../Dashboard}/Models/CpuUtilizationItem.cs | 0 .../Dashboard}/Models/CriticalIssueItem.cs | 0 .../Models/CurrentServerConfigItem.cs | 0 .../Dashboard}/Models/DailySummaryItem.cs | 0 .../Models/DatabaseConfigChangeItem.cs | 0 .../Dashboard}/Models/DeadlockItem.cs | 0 .../Models/DefaultTraceEventItem.cs | 0 .../Dashboard}/Models/DurationTrendItem.cs | 0 .../Dashboard}/Models/ExecutionTrendItem.cs | 0 .../Dashboard}/Models/ExpensiveQueryItem.cs | 0 .../Dashboard}/Models/FileIoDataPoint.cs | 0 .../Dashboard}/Models/FileIoLatencyItem.cs | 0 .../Models/FileIoLatencyTimeSeriesItem.cs | 0 .../Models/HealthParserCPUTasksItem.cs | 0 .../Models/HealthParserIOIssueItem.cs | 0 .../Models/HealthParserMemoryBrokerItem.cs | 0 .../Models/HealthParserMemoryConditionItem.cs | 0 .../Models/HealthParserMemoryNodeOOMItem.cs | 0 .../Models/HealthParserSchedulerIssueItem.cs | 0 .../Models/HealthParserSevereErrorItem.cs | 0 .../Models/HealthParserSystemHealthItem.cs | 0 .../Dashboard}/Models/HeatmapModels.cs | 0 .../Dashboard}/Models/LatchStatsItem.cs | 0 .../Dashboard}/Models/LiveQueryItem.cs | 0 .../Dashboard}/Models/LockWaitStatsItem.cs | 0 .../Models/LongRunningQueryPatternItem.cs | 0 .../Dashboard}/Models/MemoryClerksItem.cs | 0 .../Dashboard}/Models/MemoryDataPoint.cs | 0 .../Dashboard}/Models/MemoryGrantStatsItem.cs | 0 .../Dashboard}/Models/MemoryHistoryItem.cs | 0 .../Models/MemoryPressureEventItem.cs | 0 .../Dashboard}/Models/MemoryPressureItem.cs | 0 .../Dashboard}/Models/MemoryStatsItem.cs | 0 .../Dashboard}/Models/PerfmonStatsItem.cs | 0 .../Dashboard}/Models/PlanCacheStatsItem.cs | 0 .../Models/ProcedureExecutionHistoryItem.cs | 0 .../Dashboard}/Models/ProcedureStatsItem.cs | 0 .../Models/QueryExecutionHistoryItem.cs | 0 .../Dashboard}/Models/QuerySnapshotItem.cs | 0 .../Models/QueryStatsHistoryItem.cs | 0 .../Dashboard}/Models/QueryStatsItem.cs | 0 .../Dashboard}/Models/QueryStoreItem.cs | 0 .../Models/QueryStoreRegressionItem.cs | 0 .../Dashboard}/Models/RunningJobItem.cs | 0 .../Dashboard}/Models/SelectableItem.cs | 0 .../Models/ServerConfigChangeItem.cs | 0 .../Dashboard}/Models/ServerConnection.cs | 0 .../Dashboard}/Models/ServerHealthStatus.cs | 0 .../Dashboard}/Models/ServerListItem.cs | 0 .../Dashboard}/Models/SessionStatsItem.cs | 0 .../Dashboard}/Models/SpinlockStatsItem.cs | 0 .../Dashboard}/Models/TempdbStatsItem.cs | 0 .../Dashboard}/Models/TraceAnalysisItem.cs | 0 .../Dashboard}/Models/TraceFlagChangeItem.cs | 0 .../Models/TracePatternDetailItem.cs | 0 .../Dashboard}/Models/UserPreferences.cs | 0 .../Dashboard}/Models/WaitStatItem.cs | 0 .../Dashboard}/Models/WaitStatsDataPoint.cs | 0 .../Dashboard}/Models/WaitingTaskTrendItem.cs | 0 .../Dashboard}/MuteRuleDialog.xaml | 0 .../Dashboard}/MuteRuleDialog.xaml.cs | 0 .../Dashboard}/PlanViewerWindow.xaml | 0 .../Dashboard}/PlanViewerWindow.xaml.cs | 0 .../Dashboard}/ProcedureHistoryWindow.xaml | 0 .../Dashboard}/ProcedureHistoryWindow.xaml.cs | 0 .../Dashboard}/Program.cs | 0 .../Dashboard}/PurgeNowDialog.xaml | 0 .../Dashboard}/PurgeNowDialog.xaml.cs | 0 .../QueryExecutionHistoryWindow.xaml | 0 .../QueryExecutionHistoryWindow.xaml.cs | 0 .../Dashboard}/QueryStatsHistoryWindow.xaml | 0 .../QueryStatsHistoryWindow.xaml.cs | 0 {Dashboard => deprecated/Dashboard}/README.md | 0 .../Dashboard}/RemediationConfirmWindow.xaml | 0 .../RemediationConfirmWindow.xaml.cs | 0 .../Dashboard}/RemoveServerDialog.xaml | 0 .../Dashboard}/RemoveServerDialog.xaml.cs | 0 .../Dashboard}/ServerTab.Alerts.cs | 0 .../Dashboard}/ServerTab.BlockChain.cs | 0 .../Dashboard}/ServerTab.Charts.cs | 0 .../Dashboard}/ServerTab.CopyExport.cs | 0 .../Dashboard}/ServerTab.Deadlock.cs | 0 .../Dashboard}/ServerTab.DrillDown.cs | 0 .../Dashboard}/ServerTab.Filters.cs | 0 .../Dashboard}/ServerTab.Plans.cs | 0 .../Dashboard}/ServerTab.Refresh.cs | 0 .../Dashboard}/ServerTab.Slicers.cs | 0 .../Dashboard}/ServerTab.TimeRange.cs | 0 .../Dashboard}/ServerTab.xaml | 0 .../Dashboard}/ServerTab.xaml.cs | 0 .../Dashboard}/Services/AlertStateService.cs | 0 .../Dashboard}/Services/AnalysisScheduler.cs | 0 .../Dashboard}/Services/CredentialService.cs | 0 .../Services/DashboardAlertCredentials.cs | 0 .../Services/DashboardAlertSettings.cs | 0 .../Services/DataGridFilterService.cs | 0 .../DatabaseService.FinOps.IndexAnalysis.cs | 0 .../DatabaseService.FinOps.IndexObjects.cs | 0 .../DatabaseService.FinOps.Inventory.cs | 0 .../Services/DatabaseService.FinOps.Models.cs | 0 .../DatabaseService.FinOps.Queries.cs | 0 .../DatabaseService.FinOps.Recommendations.cs | 0 .../DatabaseService.FinOps.Storage.cs | 0 .../DatabaseService.FinOps.Workload.cs | 0 .../Services/DatabaseService.HealthParser.cs | 0 .../Services/DatabaseService.Memory.cs | 0 .../Services/DatabaseService.NocHealth.cs | 0 .../Services/DatabaseService.Overview.cs | 0 ...tabaseService.QueryPerformance.Blocking.cs | 0 ...baseService.QueryPerformance.Comparison.cs | 0 ...atabaseService.QueryPerformance.Heatmap.cs | 0 ...atabaseService.QueryPerformance.History.cs | 0 .../DatabaseService.QueryPerformance.Mcp.cs | 0 ...atabaseService.QueryPerformance.PlanXml.cs | 0 ...abaseService.QueryPerformance.Snapshots.cs | 0 .../DatabaseService.QueryPerformance.Stats.cs | 0 ...DatabaseService.QueryPerformance.Trends.cs | 0 .../DatabaseService.QueryPerformance.cs | 0 .../Services/DatabaseService.Remediation.cs | 0 .../DatabaseService.ResourceMetrics.Cpu.cs | 0 .../DatabaseService.ResourceMetrics.FileIo.cs | 0 ...seService.ResourceMetrics.LatchSpinlock.cs | 0 .../DatabaseService.ResourceMetrics.Memory.cs | 0 ...DatabaseService.ResourceMetrics.Perfmon.cs | 0 .../DatabaseService.ResourceMetrics.Waits.cs | 0 .../DatabaseService.ResourceMetrics.cs | 0 .../Services/DatabaseService.SystemEvents.cs | 0 .../Dashboard}/Services/DatabaseService.cs | 0 .../Services/DeadlockAlertClearPolicy.cs | 0 .../Dashboard}/Services/EmailAlertService.cs | 0 .../Services/JsonAlertHistoryStore.cs | 0 .../Dashboard}/Services/JsonMuteRuleStore.cs | 0 .../Services/NotificationService.cs | 0 .../Recommendations/RecommendationDeduper.cs | 0 .../Recommendations/RecommendationItem.cs | 0 .../Recommendations/RecommendationsReader.cs | 0 .../Remediation/AuditWritabilityProbe.cs | 0 .../Services/Remediation/ClearPlanHandler.cs | 0 .../DatabaseServiceRemediationExecutor.cs | 0 .../Services/Remediation/DbConfigHandler.cs | 0 .../Remediation/FileAutogrowthHandler.cs | 0 .../Services/Remediation/ForcePlanHandler.cs | 0 .../Remediation/IRemediationExecutor.cs | 0 .../Remediation/IRemediationHandler.cs | 0 .../Services/Remediation/RcsiHandler.cs | 0 .../Remediation/RemediationApplyModels.cs | 0 .../Remediation/RemediationApplyService.cs | 0 .../Remediation/RemediationAuditHelpers.cs | 0 .../Remediation/RemediationAuditWriter.cs | 0 .../Remediation/RemediationHandlerRegistry.cs | 0 .../Remediation/RemediationIdentity.cs | 0 .../Remediation/RemediationResults.cs | 0 .../Remediation/ServerConfigHandler.cs | 0 .../Dashboard}/Services/ServerManager.cs | 0 .../Services/UserPreferencesAlertSettings.cs | 0 .../Services/UserPreferencesService.cs | 0 .../Dashboard}/SettingsWindow.xaml | 0 .../Dashboard}/SettingsWindow.xaml.cs | 0 .../Dashboard}/Themes/CoolBreezeTheme.xaml | 0 .../Dashboard}/Themes/DarkTheme.xaml | 0 .../Dashboard}/Themes/LightTheme.xaml | 0 .../Dashboard}/TracePatternHistoryWindow.xaml | 0 .../TracePatternHistoryWindow.xaml.cs | 0 .../Dashboard}/WaitDrillDownWindow.xaml | 0 .../Dashboard}/WaitDrillDownWindow.xaml.cs | 0 .../Dashboard}/app.manifest | 0 .../Dashboard}/packages.lock.json | 0 .../Dashboard}/schema/tables.json | 0 .../Installer.Core}/DependencyInstaller.cs | 0 .../Installer.Core}/InstallGuard.cs | 0 .../Installer.Core}/InstallationService.cs | 0 .../InstalledVersionClassifier.cs | 0 .../Installer.Core}/Installer.Core.csproj | 4 +- .../Models/InstallationProgress.cs | 0 .../Models/InstallationResult.cs | 0 .../Models/InstallationResultCode.cs | 0 .../Installer.Core}/Models/ServerInfo.cs | 0 .../Installer.Core}/Models/UpgradeInfo.cs | 0 .../Installer.Core}/PathValidation.cs | 0 .../Installer.Core}/Patterns.cs | 0 .../Installer.Core}/RepairOutcome.cs | 0 .../Installer.Core}/ScriptProvider.cs | 0 .../Installer.Core}/ServerSetupPolicy.cs | 0 .../Installer.Core}/ServerSetupState.cs | 0 .../Installer.Core}/packages.lock.json | 0 .../Installer.Tests}/AdversarialTests.cs | 0 .../BuildConnectionStringTests.cs | 0 .../CleanInstallCancellationTests.cs | 0 .../Installer.Tests}/FileFilteringTests.cs | 0 .../Installer.Tests}/GlobalUsings.cs | 0 .../Helpers/TempDirectoryBuilder.cs | 0 .../Helpers/TestDatabaseHelper.cs | 0 .../Installer.Tests}/IdempotencyTests.cs | 0 .../Installer.Tests}/InstallGuardTests.cs | 0 .../InstalledVersionClassifierTests.cs | 0 .../Installer.Tests}/Installer.Tests.csproj | 0 .../Installer.Tests}/PathValidationTests.cs | 0 .../Installer.Tests}/RepairOutcomeTests.cs | 0 .../ServerSetupPolicyTests.cs | 0 .../SummaryReportPathTests.cs | 0 .../Installer.Tests}/UpgradeOrderingTests.cs | 0 .../Installer.Tests}/VersionDetectionTests.cs | 0 .../Installer.Tests}/packages.lock.json | 0 .../Installer}/.gitignore | 0 {Installer => deprecated/Installer}/EDD.ico | Bin .../PerformanceMonitorInstaller.csproj | 0 .../Installer}/Program.cs | 0 {Installer => deprecated/Installer}/README.md | 0 .../Installer}/packages.lock.json | 0 442 files changed, 62 insertions(+), 192 deletions(-) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/AlertBadgeConditionTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/AlertContextBuildersTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/AlertDeliveryModeOverrideTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/AnalysisEnabledPreferenceTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/AnalysisNotificationTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/AnomalyIncidentReconcilerTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/AzureAuthConnectionStringTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/BaselineBucketTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/BlockingChainReconstructorTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/BlockingPairRowMergeTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/ChartClickIsolateTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/ChartStyleYLimitsTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/CoFiredSummaryTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/CollectionStoppedDetectionTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/Dashboard.Tests.csproj (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/DashboardAlertSettingsTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/DataGridExportTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/DeadlockAlertClearPolicyTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/EmailTemplateBrandingTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FactAdviceComposeTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FactAdviceCorrectnessTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FactScorerTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FailedJobWatermarkPreferenceTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FailedJobsQueryTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FileAutogrowthHandlerTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FinOpsHeatmapBuilderTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/FinOpsInventoryQueryTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/IncidentIdTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/InferenceEngineTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/JsonAlertHistoryStoreDedupKeyTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/JsonAlertHistoryStoreMutedFilterTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/McpSchemaCompatTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/MissingIndexAdvisoryTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/PlanAdvisoryAggregatorTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/RecommendationDeduperTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/RecommendationsViewModelTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/RemediationApplyServiceTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/RemediationContractTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/RemediationGoldenSampleTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/RemediationTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/ServerConfigHandlerTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/ServerConfigRecommendationTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/ServerManagerDetectionQueryTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/ServerPropertiesResilienceTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/SqlServerFindingStoreMappingTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/ThemeParityTests.cs (100%) rename {Dashboard.Tests => deprecated/Dashboard.Tests}/WebhookPayloadBrandingTests.cs (100%) rename {Dashboard => deprecated/Dashboard}/AboutWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/AboutWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/AddServerDialog.xaml (100%) rename {Dashboard => deprecated/Dashboard}/AddServerDialog.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/AlertDetailWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/AlertDetailWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/AnalysisService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/BlockingChainViewerProjection.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/BlockingPairRowQuery.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerAnomalyDetector.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerBaselineProvider.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerDrillDownCollector.Blocking.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerDrillDownCollector.Config.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerDrillDownCollector.Plans.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerDrillDownCollector.Queries.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerDrillDownCollector.Storage.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerDrillDownCollector.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFactCollector.Activity.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFactCollector.Config.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFactCollector.QueryPerf.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFactCollector.Resources.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFactCollector.Storage.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFactCollector.Waits.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFactCollector.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerFindingStore.cs (100%) rename {Dashboard => deprecated/Dashboard}/Analysis/SqlServerPlanFetcher.cs (100%) rename {Dashboard => deprecated/Dashboard}/App.xaml (100%) rename {Dashboard => deprecated/Dashboard}/App.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/CollectionLogWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/CollectionLogWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/CollectorScheduleWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/CollectorScheduleWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/ColumnFilterPopup.xaml (100%) rename {Dashboard => deprecated/Dashboard}/ColumnFilterPopup.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/AlertsHistoryContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/AlertsHistoryContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ConfigChangesContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ConfigChangesContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/CorrelatedTimelineLanesControl.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/CorrelatedTimelineLanesControl.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/CriticalIssuesContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/CriticalIssuesContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/CurrentConfigContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/CurrentConfigContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/DailySummaryContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/DailySummaryContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/DefaultTraceContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/DefaultTraceContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.CopyExport.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.Filters.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.Loaders.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.Locking.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.ObjectHeatmap.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.Refresh.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/FinOpsContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/LandingPage.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/LandingPage.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/LoadingOverlay.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/LoadingOverlay.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.CopyExport.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.MemoryClerks.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.MemoryGrants.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.MemoryPressure.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.MemoryStats.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.PlanCache.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/MemoryContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.BarCells.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.Comparison.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.CopyExport.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.Filters.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.Heatmap.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.Plans.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.Slicers.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/QueryPerformanceContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/RecommendationsContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/RecommendationsContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/RecommendationsViewModel.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.CopyExport.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.FileIoLatency.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.LatchStats.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.PerfmonCounters.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.SessionStats.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.SpinlockStats.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.TempdbStats.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.WaitStatsDetail.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ResourceMetricsContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ServerHealthCard.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/ServerHealthCard.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SnoozeBalloon.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SnoozeBalloon.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/StyledBalloon.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/StyledBalloon.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.CPUTasks.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.CopyExport.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.FilterPopup.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.IOIssues.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.MemoryBroker.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.MemoryConditions.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.MemoryNodeOOM.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.SchedulerIssues.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.SevereErrors.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.SystemHealth.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/SystemEventsContent.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Controls/TimeRangeSlicerControl.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Controls/TimeRangeSlicerControl.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Converters/NullToBooleanConverter.cs (100%) rename {Dashboard => deprecated/Dashboard}/Converters/QueryTextCleanupConverter.cs (100%) rename {Dashboard => deprecated/Dashboard}/Converters/ValueToBrushConverter.cs (100%) rename {Dashboard => deprecated/Dashboard}/Dashboard.csproj (84%) rename {Dashboard => deprecated/Dashboard}/EDD.ico (100%) rename {Dashboard => deprecated/Dashboard}/ExcludedDatabasesDialog.xaml (100%) rename {Dashboard => deprecated/Dashboard}/ExcludedDatabasesDialog.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/GlobalUsings.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/DateFilterHelper.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/Logger.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/LoggerAdapter.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/MethodProfiler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/NativeMethods.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/NumericFilterHelper.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/QueryLogger.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/ServerTimeConverter.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/ServerTimeHelper.cs (100%) rename {Dashboard => deprecated/Dashboard}/Helpers/TabHelpers.cs (100%) rename {Dashboard => deprecated/Dashboard}/Interfaces/ICredentialService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Interfaces/IServerManager.cs (100%) rename {Dashboard => deprecated/Dashboard}/Interfaces/IUserPreferencesService.cs (100%) rename {Dashboard => deprecated/Dashboard}/MainWindow.AlertEngine.cs (100%) rename {Dashboard => deprecated/Dashboard}/MainWindow.PlanViewer.cs (100%) rename {Dashboard => deprecated/Dashboard}/MainWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/MainWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/ManageMuteRulesWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/ManageMuteRulesWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/ManageServersWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/ManageServersWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/DatabaseServiceRegistry.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpActiveQueryTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpAlertTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpAnalysisTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpBlockingTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpConfigHistoryTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpCpuTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpDiagnosticTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpDiscoveryTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpHealthParserTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpHealthTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpHostService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpInstructions.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpIoTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpJobTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpLatchSpinlockTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpMemoryTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpObjectStatsTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpPerfmonTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpPlanTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpQueryTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpSchedulerTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpServerInventoryTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpSystemEventTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpTempDbTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/McpWaitTools.cs (100%) rename {Dashboard => deprecated/Dashboard}/Mcp/ServerResolver.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/AlertHealthResult.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/BlockedSessionTrendItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/BlockingDeadlockStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/BlockingEventItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CollectionHealthItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CollectionLogEntry.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CollectorScheduleItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CpuDataPoint.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CpuPressureItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CpuSpikeItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CpuUtilizationHistoryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CpuUtilizationItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CriticalIssueItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/CurrentServerConfigItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/DailySummaryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/DatabaseConfigChangeItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/DeadlockItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/DefaultTraceEventItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/DurationTrendItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ExecutionTrendItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ExpensiveQueryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/FileIoDataPoint.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/FileIoLatencyItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/FileIoLatencyTimeSeriesItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserCPUTasksItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserIOIssueItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserMemoryBrokerItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserMemoryConditionItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserMemoryNodeOOMItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserSchedulerIssueItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserSevereErrorItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HealthParserSystemHealthItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/HeatmapModels.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/LatchStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/LiveQueryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/LockWaitStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/LongRunningQueryPatternItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/MemoryClerksItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/MemoryDataPoint.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/MemoryGrantStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/MemoryHistoryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/MemoryPressureEventItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/MemoryPressureItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/MemoryStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/PerfmonStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/PlanCacheStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ProcedureExecutionHistoryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ProcedureStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/QueryExecutionHistoryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/QuerySnapshotItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/QueryStatsHistoryItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/QueryStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/QueryStoreItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/QueryStoreRegressionItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/RunningJobItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/SelectableItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ServerConfigChangeItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ServerConnection.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ServerHealthStatus.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/ServerListItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/SessionStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/SpinlockStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/TempdbStatsItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/TraceAnalysisItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/TraceFlagChangeItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/TracePatternDetailItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/UserPreferences.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/WaitStatItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/WaitStatsDataPoint.cs (100%) rename {Dashboard => deprecated/Dashboard}/Models/WaitingTaskTrendItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/MuteRuleDialog.xaml (100%) rename {Dashboard => deprecated/Dashboard}/MuteRuleDialog.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/PlanViewerWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/PlanViewerWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/ProcedureHistoryWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/ProcedureHistoryWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Program.cs (100%) rename {Dashboard => deprecated/Dashboard}/PurgeNowDialog.xaml (100%) rename {Dashboard => deprecated/Dashboard}/PurgeNowDialog.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/QueryExecutionHistoryWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/QueryExecutionHistoryWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/QueryStatsHistoryWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/QueryStatsHistoryWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/README.md (100%) rename {Dashboard => deprecated/Dashboard}/RemediationConfirmWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/RemediationConfirmWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/RemoveServerDialog.xaml (100%) rename {Dashboard => deprecated/Dashboard}/RemoveServerDialog.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.Alerts.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.BlockChain.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.Charts.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.CopyExport.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.Deadlock.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.DrillDown.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.Filters.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.Plans.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.Refresh.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.Slicers.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.TimeRange.cs (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.xaml (100%) rename {Dashboard => deprecated/Dashboard}/ServerTab.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/AlertStateService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/AnalysisScheduler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/CredentialService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DashboardAlertCredentials.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DashboardAlertSettings.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DataGridFilterService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.IndexAnalysis.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.IndexObjects.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.Inventory.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.Models.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.Queries.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.Recommendations.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.Storage.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.FinOps.Workload.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.HealthParser.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.Memory.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.NocHealth.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.Overview.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.Blocking.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.Comparison.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.Heatmap.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.History.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.Mcp.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.PlanXml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.Snapshots.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.Stats.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.Trends.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.QueryPerformance.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.Remediation.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.ResourceMetrics.Cpu.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.ResourceMetrics.FileIo.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.ResourceMetrics.LatchSpinlock.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.ResourceMetrics.Memory.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.ResourceMetrics.Perfmon.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.ResourceMetrics.Waits.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.ResourceMetrics.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.SystemEvents.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DatabaseService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/DeadlockAlertClearPolicy.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/EmailAlertService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/JsonAlertHistoryStore.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/JsonMuteRuleStore.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/NotificationService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Recommendations/RecommendationDeduper.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Recommendations/RecommendationItem.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Recommendations/RecommendationsReader.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/AuditWritabilityProbe.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/ClearPlanHandler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/DatabaseServiceRemediationExecutor.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/DbConfigHandler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/FileAutogrowthHandler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/ForcePlanHandler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/IRemediationExecutor.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/IRemediationHandler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RcsiHandler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RemediationApplyModels.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RemediationApplyService.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RemediationAuditHelpers.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RemediationAuditWriter.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RemediationHandlerRegistry.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RemediationIdentity.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/RemediationResults.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/Remediation/ServerConfigHandler.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/ServerManager.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/UserPreferencesAlertSettings.cs (100%) rename {Dashboard => deprecated/Dashboard}/Services/UserPreferencesService.cs (100%) rename {Dashboard => deprecated/Dashboard}/SettingsWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/SettingsWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/Themes/CoolBreezeTheme.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Themes/DarkTheme.xaml (100%) rename {Dashboard => deprecated/Dashboard}/Themes/LightTheme.xaml (100%) rename {Dashboard => deprecated/Dashboard}/TracePatternHistoryWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/TracePatternHistoryWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/WaitDrillDownWindow.xaml (100%) rename {Dashboard => deprecated/Dashboard}/WaitDrillDownWindow.xaml.cs (100%) rename {Dashboard => deprecated/Dashboard}/app.manifest (100%) rename {Dashboard => deprecated/Dashboard}/packages.lock.json (100%) rename {Dashboard => deprecated/Dashboard}/schema/tables.json (100%) rename {Installer.Core => deprecated/Installer.Core}/DependencyInstaller.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/InstallGuard.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/InstallationService.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/InstalledVersionClassifier.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/Installer.Core.csproj (86%) rename {Installer.Core => deprecated/Installer.Core}/Models/InstallationProgress.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/Models/InstallationResult.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/Models/InstallationResultCode.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/Models/ServerInfo.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/Models/UpgradeInfo.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/PathValidation.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/Patterns.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/RepairOutcome.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/ScriptProvider.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/ServerSetupPolicy.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/ServerSetupState.cs (100%) rename {Installer.Core => deprecated/Installer.Core}/packages.lock.json (100%) rename {Installer.Tests => deprecated/Installer.Tests}/AdversarialTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/BuildConnectionStringTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/CleanInstallCancellationTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/FileFilteringTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/GlobalUsings.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/Helpers/TempDirectoryBuilder.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/Helpers/TestDatabaseHelper.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/IdempotencyTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/InstallGuardTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/InstalledVersionClassifierTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/Installer.Tests.csproj (100%) rename {Installer.Tests => deprecated/Installer.Tests}/PathValidationTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/RepairOutcomeTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/ServerSetupPolicyTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/SummaryReportPathTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/UpgradeOrderingTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/VersionDetectionTests.cs (100%) rename {Installer.Tests => deprecated/Installer.Tests}/packages.lock.json (100%) rename {Installer => deprecated/Installer}/.gitignore (100%) rename {Installer => deprecated/Installer}/EDD.ico (100%) rename {Installer => deprecated/Installer}/PerformanceMonitorInstaller.csproj (100%) rename {Installer => deprecated/Installer}/Program.cs (100%) rename {Installer => deprecated/Installer}/README.md (100%) rename {Installer => deprecated/Installer}/packages.lock.json (100%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 4343fd4a8..d6254da86 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -10,10 +10,10 @@ body: label: Component description: Which part of Performance Monitor is affected? options: - - Full Dashboard - Lite - - Installer (CLI) - - Installer (GUI) + - Darling + - Full Dashboard (deprecated) + - Installer (deprecated) - SQL collection scripts validations: required: true @@ -40,7 +40,7 @@ body: id: windows-version attributes: label: Windows Version - description: The OS where the Dashboard or Lite app is running. + description: The OS where the Lite, Darling, or Dashboard app is running. placeholder: "e.g., Windows 11 23H2, Windows Server 2022" validations: required: true @@ -59,7 +59,7 @@ body: label: Steps to Reproduce description: How can we reproduce this? placeholder: | - 1. Open the Dashboard + 1. Open the app 2. Click on... 3. See error validations: @@ -85,7 +85,7 @@ body: id: errors attributes: label: Error Messages / Log Output - description: Paste any error messages or relevant log entries. Dashboard logs are in %LOCALAPPDATA%\PerformanceMonitor\Logs\. + description: Paste any error messages or relevant log entries. Dashboard logs are in %LOCALAPPDATA%\PerformanceMonitor\Logs\; Lite logs are in %LOCALAPPDATA%\PerformanceMonitorLite\Logs\. render: text validations: required: false @@ -105,7 +105,7 @@ body: description: | Anything else that might help: - Did this work before? If so, what changed? - - Is SQL Server Agent running? (Full Edition only) + - Is SQL Server Agent running? (deprecated Full Dashboard edition only) - Are you monitoring Azure SQL DB, Azure MI, or AWS RDS? validations: required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index b330d3df2..18e944f48 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -9,10 +9,8 @@ body: attributes: label: Which component(s) does this affect? options: - - label: Full Dashboard - label: Lite - - label: SQL collection scripts - - label: Installer + - label: Darling - label: Documentation validations: required: true diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml index 3c4973109..4129a77a2 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -9,10 +9,9 @@ body: attributes: label: Which component is your question about? options: - - Full Dashboard - Lite - - SQL collection scripts - - Installer + - Darling + - Full Dashboard (deprecated) - General / setup validations: required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 311320e9d..0663b1113 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,12 +4,14 @@ A clear description of the change and why it's being made. ## Which component(s) does this affect? -- [ ] Full Dashboard -- [ ] Lite Dashboard +- [ ] Lite +- [ ] Darling - [ ] Lite Tests +- [ ] Darling Tests - [ ] SQL collection scripts -- [ ] CLI Installer - [ ] Documentation +- [ ] Full Dashboard (deprecated) +- [ ] CLI Installer (deprecated) ## How was this tested? diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a83b57a8..c68491618 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -54,11 +54,11 @@ jobs: # 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/**' + - 'deprecated/Installer.Core/**' - '!**/*.md' dashboard: - - 'Dashboard/**' - - 'Dashboard.Tests/**' + - 'deprecated/Dashboard/**' + - 'deprecated/Dashboard.Tests/**' - '!**/*.md' lite: - 'Lite/**' @@ -75,8 +75,8 @@ jobs: - 'Lite/PerformanceMonitorLite.csproj' - '!**/*.md' installer: - - 'Installer/**' - - 'Installer.Tests/**' + - 'deprecated/Installer/**' + - 'deprecated/Installer.Tests/**' - 'install/**' - 'upgrades/**' - '!**/*.md' @@ -102,11 +102,9 @@ jobs: - name: Restore dependencies if: steps.filter.outputs.code != 'false' run: | - dotnet restore Dashboard/Dashboard.csproj --locked-mode 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 Installer.Tests/Installer.Tests.csproj --locked-mode + dotnet restore deprecated/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 @@ -116,7 +114,7 @@ jobs: - name: Build Installer.Tests 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 + run: dotnet build deprecated/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' @@ -134,7 +132,7 @@ jobs: - name: Run Installer tests 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" + run: dotnet test deprecated/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' @@ -145,17 +143,9 @@ jobs: id: version shell: pwsh run: | - $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + $version = ([xml](Get-Content Lite/PerformanceMonitorLite.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } echo "VERSION=$version" >> $env:GITHUB_OUTPUT - - name: Publish Dashboard - 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) - if: github.event_name == 'release' - run: dotnet publish Dashboard/Dashboard.csproj -c Release -r win-x64 --self-contained -o publish/Dashboard-velopack - - name: Publish Lite 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 @@ -164,15 +154,6 @@ jobs: if: github.event_name == 'release' 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.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 @@ -211,37 +192,10 @@ jobs: $version = "${{ steps.version.outputs.VERSION }}" New-Item -ItemType Directory -Force -Path releases - # 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 — same rationale. + # Lite ZIP - portable artifact for advanced/air-gapped users. The README points end + # users at Setup.exe (Velopack); this ZIP is the explicit fallback. Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - # 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/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 } - if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' $instDir } - if (Test-Path 'THIRD_PARTY_NOTICES.md') { Copy-Item 'THIRD_PARTY_NOTICES.md' $instDir } - - Compress-Archive -Path 'publish/Installer/*' -DestinationPath "releases/PerformanceMonitorInstaller-$version.zip" -Force - - - name: Upload Dashboard for signing - if: github.event_name == 'release' - id: upload-dashboard - uses: actions/upload-artifact@v6 - with: - name: Dashboard-unsigned - path: publish/Dashboard/ - - name: Upload Lite for signing if: github.event_name == 'release' id: upload-lite @@ -250,19 +204,6 @@ jobs: name: Lite-unsigned path: publish/Lite/ - - name: Upload Installer for signing - if: github.event_name == 'release' - id: upload-installer - uses: actions/upload-artifact@v6 - with: - 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 @@ -281,19 +222,6 @@ jobs: name: Darling-unsigned path: publish/Darling-signing/ - - name: Sign Dashboard - 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.outputs.artifact-id }}' - wait-for-completion: true - output-artifact-directory: 'signed/Dashboard' - - name: Sign Lite if: github.event_name == 'release' uses: signpath/github-action-submit-signing-request@v2 @@ -307,24 +235,6 @@ jobs: wait-for-completion: true output-artifact-directory: 'signed/Lite' - - name: Sign Installer - 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: 'Installers' - github-artifact-id: '${{ steps.upload-installer.outputs.artifact-id }}' - 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 @@ -344,12 +254,8 @@ jobs: run: | $version = "${{ steps.version.outputs.VERSION }}" # Re-zip signed files into release archives - Remove-Item "releases/PerformanceMonitorDashboard-$version.zip" -ErrorAction SilentlyContinue - Compress-Archive -Path 'signed/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force Remove-Item "releases/PerformanceMonitorLite-$version.zip" -ErrorAction SilentlyContinue Compress-Archive -Path 'signed/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - 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' @@ -375,14 +281,6 @@ jobs: # 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 @@ -399,19 +297,6 @@ jobs: 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 @@ -443,7 +328,7 @@ jobs: wait-for-completion: true output-artifact-directory: 'signed/DarlingViewer-Velopack' - - name: Create Velopack release (Dashboard) + - name: Create Velopack releases (Lite + Darling Viewer) if: github.event_name == 'release' shell: pwsh env: @@ -451,16 +336,11 @@ jobs: VERSION: ${{ steps.version.outputs.VERSION }} run: | # Pin vpk to the Velopack library version (keep in sync with the Velopack - # PackageReference in Dashboard.csproj / PerformanceMonitorLite.csproj). + # PackageReference in PerformanceMonitorLite.csproj). 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 (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 signed/Dashboard-Velopack -e PerformanceMonitorDashboard.exe -o releases/velopack-dashboard --channel dashboard - # 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 signed/Lite-Velopack -e PerformanceMonitorLite.exe -o releases/velopack-lite --channel lite @@ -490,15 +370,6 @@ jobs: run: | gh release upload ${{ github.event.release.tag_name }} releases/*.zip releases/SHA256SUMS.txt --clobber - - name: Upload Dashboard 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 dashboard -o releases/velopack-dashboard --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN - - name: Upload Lite Velopack artifacts if: github.event_name == 'release' shell: pwsh diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml index f556a7d02..bca145ad6 100644 --- a/.github/workflows/check-version-bump.yml +++ b/.github/workflows/check-version-bump.yml @@ -31,7 +31,7 @@ jobs: id: pr shell: pwsh run: | - $version = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + $version = ([xml](Get-Content deprecated/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } echo "VERSION=$version" >> $env:GITHUB_OUTPUT Write-Host "PR version: $version" @@ -47,7 +47,7 @@ jobs: id: main shell: pwsh run: | - $version = ([xml](Get-Content main-branch/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + $version = ([xml](Get-Content main-branch/deprecated/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } echo "VERSION=$version" >> $env:GITHUB_OUTPUT Write-Host "Main version: $version" @@ -60,7 +60,7 @@ jobs: 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." + echo "::error::Version in deprecated/Dashboard/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" diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3f526b037..2621bb0bd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -53,7 +53,7 @@ jobs: id: version shell: pwsh run: | - $base = ([xml](Get-Content Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + $base = ([xml](Get-Content deprecated/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } $date = Get-Date -Format "yyyyMMdd" $nightly = "$base-nightly.$date" echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT @@ -61,9 +61,9 @@ jobs: - name: Restore dependencies run: | - dotnet restore Dashboard/Dashboard.csproj --locked-mode + dotnet restore deprecated/Dashboard/Dashboard.csproj --locked-mode dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode - dotnet restore Installer/PerformanceMonitorInstaller.csproj --locked-mode + dotnet restore deprecated/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 @@ -71,13 +71,13 @@ jobs: run: dotnet test Lite.Tests/Lite.Tests.csproj -c Release --verbosity normal - name: Publish Dashboard - run: dotnet publish Dashboard/Dashboard.csproj -c Release -o publish/Dashboard + run: dotnet publish deprecated/Dashboard/Dashboard.csproj -c Release -o publish/Dashboard - name: Publish Lite run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite - name: Publish CLI Installer - run: dotnet publish Installer/PerformanceMonitorInstaller.csproj -c Release + run: dotnet publish deprecated/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 @@ -121,7 +121,7 @@ jobs: New-Item -ItemType Directory -Force -Path "$instDir/install" New-Item -ItemType Directory -Force -Path "$instDir/upgrades" - Copy-Item 'Installer/bin/Release/net10.0/win-x64/publish/PerformanceMonitorInstaller.exe' $instDir + Copy-Item 'deprecated/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/PerformanceMonitor.sln b/PerformanceMonitor.sln index a76c9df69..704c1e7f8 100644 --- a/PerformanceMonitor.sln +++ b/PerformanceMonitor.sln @@ -3,19 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36930.0 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dashboard", "Dashboard\Dashboard.csproj", "{15117113-EE40-8DCA-BC4D-4D4D68604E97}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dashboard", "deprecated\Dashboard\Dashboard.csproj", "{15117113-EE40-8DCA-BC4D-4D4D68604E97}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitorLite", "Lite\PerformanceMonitorLite.csproj", "{48718A1C-3679-AB80-1D5F-A9C20CC7EF9A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lite.Tests", "Lite.Tests\Lite.Tests.csproj", "{C4E76DA8-A115-7291-3AD0-12C648E24FF2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitorInstaller", "Installer\PerformanceMonitorInstaller.csproj", "{0F1EFD0D-61B6-D475-3A2E-ED7FD83BCE6E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitorInstaller", "deprecated\Installer\PerformanceMonitorInstaller.csproj", "{0F1EFD0D-61B6-D475-3A2E-ED7FD83BCE6E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Tests", "Installer.Tests\Installer.Tests.csproj", "{9B2800D2-8F32-450E-A169-86B381EA5560}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Tests", "deprecated\Installer.Tests\Installer.Tests.csproj", "{9B2800D2-8F32-450E-A169-86B381EA5560}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Core", "Installer.Core\Installer.Core.csproj", "{AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Installer.Core", "deprecated\Installer.Core\Installer.Core.csproj", "{AFA0BA1D-42D6-4E01-9D6C-B7E327E1B7D3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dashboard.Tests", "Dashboard.Tests\Dashboard.Tests.csproj", "{769BF5E2-21F2-4D14-8C45-DC46FB9E3D86}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dashboard.Tests", "deprecated\Dashboard.Tests\Dashboard.Tests.csproj", "{769BF5E2-21F2-4D14-8C45-DC46FB9E3D86}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceMonitor.Analysis", "PerformanceMonitor.Analysis\PerformanceMonitor.Analysis.csproj", "{2F8A8C93-4B0D-4B5E-A898-3B1FCD882B5B}" EndProject diff --git a/README.md b/README.md index 8aae08cf5..32b2c3b79 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,15 @@ 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. -| | **[Lite](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** — flagship | **[Darling](Darling/README.md)** — headless | **[Dashboard](Dashboard/README.md)** — *deprecated* | +| | **[Lite](https://github.com/erikdarlingdata/PerformanceMonitor/releases/latest)** — flagship | **[Darling](Darling/README.md)** — headless | **[Dashboard](deprecated/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)) | +| **Requires** | `VIEW SERVER STATE` ([permissions](#permissions)) | `VIEW SERVER STATE` + a place to run the service | SQL Agent ([Dashboard docs](deprecated/Dashboard/README.md)) | -> **⚠️ 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. +> **⚠️ 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: **[deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)** (the app, tabs, permissions) and **[deprecated/Installer/README.md](deprecated/Installer/README.md)** (the CLI database installer). The Dashboard installer remains in the release assets. **👉 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. @@ -223,7 +223,7 @@ The **Lite** app and the **Darling** viewer share the same tab layout (the viewe | **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 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). +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 [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md). --- @@ -318,7 +318,7 @@ claude mcp add --transport http --scope user sql-monitor http://localhost:5151/ ### Available Tools -**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. +**Lite** exposes 74 tools; **Darling** exposes the analysis + data-read surface on request; the deprecated **Dashboard** exposes 66 (see [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md)). Core tools are shared. | Category | Tools | |---|---| @@ -384,7 +384,7 @@ Common issues: 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`** — 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). +**Darling** troubleshooting (service logs, store connectivity, permissions) is in the [Darling operator guide](Darling/README.md). **Dashboard** (Full edition) troubleshooting is in [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md). --- @@ -427,7 +427,7 @@ 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). +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 [deprecated/Dashboard/README.md](deprecated/Dashboard/README.md). ### FinOps Index Analysis (per-database grants) @@ -536,9 +536,9 @@ PerformanceMonitor/ │ 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) +├── deprecated/Installer/ # CLI installer for the Full edition database (see deprecated/Installer/README.md) +├── deprecated/Installer.Core/ # Shared installation library (CLI + Dashboard) +├── deprecated/Dashboard/ # Full edition dashboard app (see deprecated/Dashboard/README.md) │ └── README.md # This file ``` @@ -558,8 +558,8 @@ dotnet build Darling/PerformanceMonitor.Darling.Service/PerformanceMonitor.Darli 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 +dotnet build deprecated/Dashboard/Dashboard.csproj +dotnet publish deprecated/Installer/PerformanceMonitorInstaller.csproj -c Release ``` --- diff --git a/Dashboard.Tests/AlertBadgeConditionTests.cs b/deprecated/Dashboard.Tests/AlertBadgeConditionTests.cs similarity index 100% rename from Dashboard.Tests/AlertBadgeConditionTests.cs rename to deprecated/Dashboard.Tests/AlertBadgeConditionTests.cs diff --git a/Dashboard.Tests/AlertContextBuildersTests.cs b/deprecated/Dashboard.Tests/AlertContextBuildersTests.cs similarity index 100% rename from Dashboard.Tests/AlertContextBuildersTests.cs rename to deprecated/Dashboard.Tests/AlertContextBuildersTests.cs diff --git a/Dashboard.Tests/AlertDeliveryModeOverrideTests.cs b/deprecated/Dashboard.Tests/AlertDeliveryModeOverrideTests.cs similarity index 100% rename from Dashboard.Tests/AlertDeliveryModeOverrideTests.cs rename to deprecated/Dashboard.Tests/AlertDeliveryModeOverrideTests.cs diff --git a/Dashboard.Tests/AnalysisEnabledPreferenceTests.cs b/deprecated/Dashboard.Tests/AnalysisEnabledPreferenceTests.cs similarity index 100% rename from Dashboard.Tests/AnalysisEnabledPreferenceTests.cs rename to deprecated/Dashboard.Tests/AnalysisEnabledPreferenceTests.cs diff --git a/Dashboard.Tests/AnalysisNotificationTests.cs b/deprecated/Dashboard.Tests/AnalysisNotificationTests.cs similarity index 100% rename from Dashboard.Tests/AnalysisNotificationTests.cs rename to deprecated/Dashboard.Tests/AnalysisNotificationTests.cs diff --git a/Dashboard.Tests/AnomalyIncidentReconcilerTests.cs b/deprecated/Dashboard.Tests/AnomalyIncidentReconcilerTests.cs similarity index 100% rename from Dashboard.Tests/AnomalyIncidentReconcilerTests.cs rename to deprecated/Dashboard.Tests/AnomalyIncidentReconcilerTests.cs diff --git a/Dashboard.Tests/AzureAuthConnectionStringTests.cs b/deprecated/Dashboard.Tests/AzureAuthConnectionStringTests.cs similarity index 100% rename from Dashboard.Tests/AzureAuthConnectionStringTests.cs rename to deprecated/Dashboard.Tests/AzureAuthConnectionStringTests.cs diff --git a/Dashboard.Tests/BaselineBucketTests.cs b/deprecated/Dashboard.Tests/BaselineBucketTests.cs similarity index 100% rename from Dashboard.Tests/BaselineBucketTests.cs rename to deprecated/Dashboard.Tests/BaselineBucketTests.cs diff --git a/Dashboard.Tests/BlockingChainReconstructorTests.cs b/deprecated/Dashboard.Tests/BlockingChainReconstructorTests.cs similarity index 100% rename from Dashboard.Tests/BlockingChainReconstructorTests.cs rename to deprecated/Dashboard.Tests/BlockingChainReconstructorTests.cs diff --git a/Dashboard.Tests/BlockingPairRowMergeTests.cs b/deprecated/Dashboard.Tests/BlockingPairRowMergeTests.cs similarity index 100% rename from Dashboard.Tests/BlockingPairRowMergeTests.cs rename to deprecated/Dashboard.Tests/BlockingPairRowMergeTests.cs diff --git a/Dashboard.Tests/ChartClickIsolateTests.cs b/deprecated/Dashboard.Tests/ChartClickIsolateTests.cs similarity index 100% rename from Dashboard.Tests/ChartClickIsolateTests.cs rename to deprecated/Dashboard.Tests/ChartClickIsolateTests.cs diff --git a/Dashboard.Tests/ChartStyleYLimitsTests.cs b/deprecated/Dashboard.Tests/ChartStyleYLimitsTests.cs similarity index 100% rename from Dashboard.Tests/ChartStyleYLimitsTests.cs rename to deprecated/Dashboard.Tests/ChartStyleYLimitsTests.cs diff --git a/Dashboard.Tests/CoFiredSummaryTests.cs b/deprecated/Dashboard.Tests/CoFiredSummaryTests.cs similarity index 100% rename from Dashboard.Tests/CoFiredSummaryTests.cs rename to deprecated/Dashboard.Tests/CoFiredSummaryTests.cs diff --git a/Dashboard.Tests/CollectionStoppedDetectionTests.cs b/deprecated/Dashboard.Tests/CollectionStoppedDetectionTests.cs similarity index 100% rename from Dashboard.Tests/CollectionStoppedDetectionTests.cs rename to deprecated/Dashboard.Tests/CollectionStoppedDetectionTests.cs diff --git a/Dashboard.Tests/Dashboard.Tests.csproj b/deprecated/Dashboard.Tests/Dashboard.Tests.csproj similarity index 100% rename from Dashboard.Tests/Dashboard.Tests.csproj rename to deprecated/Dashboard.Tests/Dashboard.Tests.csproj diff --git a/Dashboard.Tests/DashboardAlertSettingsTests.cs b/deprecated/Dashboard.Tests/DashboardAlertSettingsTests.cs similarity index 100% rename from Dashboard.Tests/DashboardAlertSettingsTests.cs rename to deprecated/Dashboard.Tests/DashboardAlertSettingsTests.cs diff --git a/Dashboard.Tests/DataGridExportTests.cs b/deprecated/Dashboard.Tests/DataGridExportTests.cs similarity index 100% rename from Dashboard.Tests/DataGridExportTests.cs rename to deprecated/Dashboard.Tests/DataGridExportTests.cs diff --git a/Dashboard.Tests/DeadlockAlertClearPolicyTests.cs b/deprecated/Dashboard.Tests/DeadlockAlertClearPolicyTests.cs similarity index 100% rename from Dashboard.Tests/DeadlockAlertClearPolicyTests.cs rename to deprecated/Dashboard.Tests/DeadlockAlertClearPolicyTests.cs diff --git a/Dashboard.Tests/EmailTemplateBrandingTests.cs b/deprecated/Dashboard.Tests/EmailTemplateBrandingTests.cs similarity index 100% rename from Dashboard.Tests/EmailTemplateBrandingTests.cs rename to deprecated/Dashboard.Tests/EmailTemplateBrandingTests.cs diff --git a/Dashboard.Tests/FactAdviceComposeTests.cs b/deprecated/Dashboard.Tests/FactAdviceComposeTests.cs similarity index 100% rename from Dashboard.Tests/FactAdviceComposeTests.cs rename to deprecated/Dashboard.Tests/FactAdviceComposeTests.cs diff --git a/Dashboard.Tests/FactAdviceCorrectnessTests.cs b/deprecated/Dashboard.Tests/FactAdviceCorrectnessTests.cs similarity index 100% rename from Dashboard.Tests/FactAdviceCorrectnessTests.cs rename to deprecated/Dashboard.Tests/FactAdviceCorrectnessTests.cs diff --git a/Dashboard.Tests/FactScorerTests.cs b/deprecated/Dashboard.Tests/FactScorerTests.cs similarity index 100% rename from Dashboard.Tests/FactScorerTests.cs rename to deprecated/Dashboard.Tests/FactScorerTests.cs diff --git a/Dashboard.Tests/FailedJobWatermarkPreferenceTests.cs b/deprecated/Dashboard.Tests/FailedJobWatermarkPreferenceTests.cs similarity index 100% rename from Dashboard.Tests/FailedJobWatermarkPreferenceTests.cs rename to deprecated/Dashboard.Tests/FailedJobWatermarkPreferenceTests.cs diff --git a/Dashboard.Tests/FailedJobsQueryTests.cs b/deprecated/Dashboard.Tests/FailedJobsQueryTests.cs similarity index 100% rename from Dashboard.Tests/FailedJobsQueryTests.cs rename to deprecated/Dashboard.Tests/FailedJobsQueryTests.cs diff --git a/Dashboard.Tests/FileAutogrowthHandlerTests.cs b/deprecated/Dashboard.Tests/FileAutogrowthHandlerTests.cs similarity index 100% rename from Dashboard.Tests/FileAutogrowthHandlerTests.cs rename to deprecated/Dashboard.Tests/FileAutogrowthHandlerTests.cs diff --git a/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs b/deprecated/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs similarity index 100% rename from Dashboard.Tests/FinOpsHeatmapBuilderTests.cs rename to deprecated/Dashboard.Tests/FinOpsHeatmapBuilderTests.cs diff --git a/Dashboard.Tests/FinOpsInventoryQueryTests.cs b/deprecated/Dashboard.Tests/FinOpsInventoryQueryTests.cs similarity index 100% rename from Dashboard.Tests/FinOpsInventoryQueryTests.cs rename to deprecated/Dashboard.Tests/FinOpsInventoryQueryTests.cs diff --git a/Dashboard.Tests/IncidentIdTests.cs b/deprecated/Dashboard.Tests/IncidentIdTests.cs similarity index 100% rename from Dashboard.Tests/IncidentIdTests.cs rename to deprecated/Dashboard.Tests/IncidentIdTests.cs diff --git a/Dashboard.Tests/InferenceEngineTests.cs b/deprecated/Dashboard.Tests/InferenceEngineTests.cs similarity index 100% rename from Dashboard.Tests/InferenceEngineTests.cs rename to deprecated/Dashboard.Tests/InferenceEngineTests.cs diff --git a/Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs b/deprecated/Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs similarity index 100% rename from Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs rename to deprecated/Dashboard.Tests/JsonAlertHistoryStoreDedupKeyTests.cs diff --git a/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs b/deprecated/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs similarity index 100% rename from Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs rename to deprecated/Dashboard.Tests/JsonAlertHistoryStoreMutedFilterTests.cs diff --git a/Dashboard.Tests/McpSchemaCompatTests.cs b/deprecated/Dashboard.Tests/McpSchemaCompatTests.cs similarity index 100% rename from Dashboard.Tests/McpSchemaCompatTests.cs rename to deprecated/Dashboard.Tests/McpSchemaCompatTests.cs diff --git a/Dashboard.Tests/MissingIndexAdvisoryTests.cs b/deprecated/Dashboard.Tests/MissingIndexAdvisoryTests.cs similarity index 100% rename from Dashboard.Tests/MissingIndexAdvisoryTests.cs rename to deprecated/Dashboard.Tests/MissingIndexAdvisoryTests.cs diff --git a/Dashboard.Tests/PlanAdvisoryAggregatorTests.cs b/deprecated/Dashboard.Tests/PlanAdvisoryAggregatorTests.cs similarity index 100% rename from Dashboard.Tests/PlanAdvisoryAggregatorTests.cs rename to deprecated/Dashboard.Tests/PlanAdvisoryAggregatorTests.cs diff --git a/Dashboard.Tests/RecommendationDeduperTests.cs b/deprecated/Dashboard.Tests/RecommendationDeduperTests.cs similarity index 100% rename from Dashboard.Tests/RecommendationDeduperTests.cs rename to deprecated/Dashboard.Tests/RecommendationDeduperTests.cs diff --git a/Dashboard.Tests/RecommendationsViewModelTests.cs b/deprecated/Dashboard.Tests/RecommendationsViewModelTests.cs similarity index 100% rename from Dashboard.Tests/RecommendationsViewModelTests.cs rename to deprecated/Dashboard.Tests/RecommendationsViewModelTests.cs diff --git a/Dashboard.Tests/RemediationApplyServiceTests.cs b/deprecated/Dashboard.Tests/RemediationApplyServiceTests.cs similarity index 100% rename from Dashboard.Tests/RemediationApplyServiceTests.cs rename to deprecated/Dashboard.Tests/RemediationApplyServiceTests.cs diff --git a/Dashboard.Tests/RemediationContractTests.cs b/deprecated/Dashboard.Tests/RemediationContractTests.cs similarity index 100% rename from Dashboard.Tests/RemediationContractTests.cs rename to deprecated/Dashboard.Tests/RemediationContractTests.cs diff --git a/Dashboard.Tests/RemediationGoldenSampleTests.cs b/deprecated/Dashboard.Tests/RemediationGoldenSampleTests.cs similarity index 100% rename from Dashboard.Tests/RemediationGoldenSampleTests.cs rename to deprecated/Dashboard.Tests/RemediationGoldenSampleTests.cs diff --git a/Dashboard.Tests/RemediationTests.cs b/deprecated/Dashboard.Tests/RemediationTests.cs similarity index 100% rename from Dashboard.Tests/RemediationTests.cs rename to deprecated/Dashboard.Tests/RemediationTests.cs diff --git a/Dashboard.Tests/ServerConfigHandlerTests.cs b/deprecated/Dashboard.Tests/ServerConfigHandlerTests.cs similarity index 100% rename from Dashboard.Tests/ServerConfigHandlerTests.cs rename to deprecated/Dashboard.Tests/ServerConfigHandlerTests.cs diff --git a/Dashboard.Tests/ServerConfigRecommendationTests.cs b/deprecated/Dashboard.Tests/ServerConfigRecommendationTests.cs similarity index 100% rename from Dashboard.Tests/ServerConfigRecommendationTests.cs rename to deprecated/Dashboard.Tests/ServerConfigRecommendationTests.cs diff --git a/Dashboard.Tests/ServerManagerDetectionQueryTests.cs b/deprecated/Dashboard.Tests/ServerManagerDetectionQueryTests.cs similarity index 100% rename from Dashboard.Tests/ServerManagerDetectionQueryTests.cs rename to deprecated/Dashboard.Tests/ServerManagerDetectionQueryTests.cs diff --git a/Dashboard.Tests/ServerPropertiesResilienceTests.cs b/deprecated/Dashboard.Tests/ServerPropertiesResilienceTests.cs similarity index 100% rename from Dashboard.Tests/ServerPropertiesResilienceTests.cs rename to deprecated/Dashboard.Tests/ServerPropertiesResilienceTests.cs diff --git a/Dashboard.Tests/SqlServerFindingStoreMappingTests.cs b/deprecated/Dashboard.Tests/SqlServerFindingStoreMappingTests.cs similarity index 100% rename from Dashboard.Tests/SqlServerFindingStoreMappingTests.cs rename to deprecated/Dashboard.Tests/SqlServerFindingStoreMappingTests.cs diff --git a/Dashboard.Tests/ThemeParityTests.cs b/deprecated/Dashboard.Tests/ThemeParityTests.cs similarity index 100% rename from Dashboard.Tests/ThemeParityTests.cs rename to deprecated/Dashboard.Tests/ThemeParityTests.cs diff --git a/Dashboard.Tests/WebhookPayloadBrandingTests.cs b/deprecated/Dashboard.Tests/WebhookPayloadBrandingTests.cs similarity index 100% rename from Dashboard.Tests/WebhookPayloadBrandingTests.cs rename to deprecated/Dashboard.Tests/WebhookPayloadBrandingTests.cs diff --git a/Dashboard/AboutWindow.xaml b/deprecated/Dashboard/AboutWindow.xaml similarity index 100% rename from Dashboard/AboutWindow.xaml rename to deprecated/Dashboard/AboutWindow.xaml diff --git a/Dashboard/AboutWindow.xaml.cs b/deprecated/Dashboard/AboutWindow.xaml.cs similarity index 100% rename from Dashboard/AboutWindow.xaml.cs rename to deprecated/Dashboard/AboutWindow.xaml.cs diff --git a/Dashboard/AddServerDialog.xaml b/deprecated/Dashboard/AddServerDialog.xaml similarity index 100% rename from Dashboard/AddServerDialog.xaml rename to deprecated/Dashboard/AddServerDialog.xaml diff --git a/Dashboard/AddServerDialog.xaml.cs b/deprecated/Dashboard/AddServerDialog.xaml.cs similarity index 100% rename from Dashboard/AddServerDialog.xaml.cs rename to deprecated/Dashboard/AddServerDialog.xaml.cs diff --git a/Dashboard/AlertDetailWindow.xaml b/deprecated/Dashboard/AlertDetailWindow.xaml similarity index 100% rename from Dashboard/AlertDetailWindow.xaml rename to deprecated/Dashboard/AlertDetailWindow.xaml diff --git a/Dashboard/AlertDetailWindow.xaml.cs b/deprecated/Dashboard/AlertDetailWindow.xaml.cs similarity index 100% rename from Dashboard/AlertDetailWindow.xaml.cs rename to deprecated/Dashboard/AlertDetailWindow.xaml.cs diff --git a/Dashboard/Analysis/AnalysisService.cs b/deprecated/Dashboard/Analysis/AnalysisService.cs similarity index 100% rename from Dashboard/Analysis/AnalysisService.cs rename to deprecated/Dashboard/Analysis/AnalysisService.cs diff --git a/Dashboard/Analysis/BlockingChainViewerProjection.cs b/deprecated/Dashboard/Analysis/BlockingChainViewerProjection.cs similarity index 100% rename from Dashboard/Analysis/BlockingChainViewerProjection.cs rename to deprecated/Dashboard/Analysis/BlockingChainViewerProjection.cs diff --git a/Dashboard/Analysis/BlockingPairRowQuery.cs b/deprecated/Dashboard/Analysis/BlockingPairRowQuery.cs similarity index 100% rename from Dashboard/Analysis/BlockingPairRowQuery.cs rename to deprecated/Dashboard/Analysis/BlockingPairRowQuery.cs diff --git a/Dashboard/Analysis/SqlServerAnomalyDetector.cs b/deprecated/Dashboard/Analysis/SqlServerAnomalyDetector.cs similarity index 100% rename from Dashboard/Analysis/SqlServerAnomalyDetector.cs rename to deprecated/Dashboard/Analysis/SqlServerAnomalyDetector.cs diff --git a/Dashboard/Analysis/SqlServerBaselineProvider.cs b/deprecated/Dashboard/Analysis/SqlServerBaselineProvider.cs similarity index 100% rename from Dashboard/Analysis/SqlServerBaselineProvider.cs rename to deprecated/Dashboard/Analysis/SqlServerBaselineProvider.cs diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.Blocking.cs b/deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Blocking.cs similarity index 100% rename from Dashboard/Analysis/SqlServerDrillDownCollector.Blocking.cs rename to deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Blocking.cs diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.Config.cs b/deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Config.cs similarity index 100% rename from Dashboard/Analysis/SqlServerDrillDownCollector.Config.cs rename to deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Config.cs diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.Plans.cs b/deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Plans.cs similarity index 100% rename from Dashboard/Analysis/SqlServerDrillDownCollector.Plans.cs rename to deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Plans.cs diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.Queries.cs b/deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Queries.cs similarity index 100% rename from Dashboard/Analysis/SqlServerDrillDownCollector.Queries.cs rename to deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Queries.cs diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.Storage.cs b/deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Storage.cs similarity index 100% rename from Dashboard/Analysis/SqlServerDrillDownCollector.Storage.cs rename to deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.Storage.cs diff --git a/Dashboard/Analysis/SqlServerDrillDownCollector.cs b/deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.cs similarity index 100% rename from Dashboard/Analysis/SqlServerDrillDownCollector.cs rename to deprecated/Dashboard/Analysis/SqlServerDrillDownCollector.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.Activity.cs b/deprecated/Dashboard/Analysis/SqlServerFactCollector.Activity.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFactCollector.Activity.cs rename to deprecated/Dashboard/Analysis/SqlServerFactCollector.Activity.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.Config.cs b/deprecated/Dashboard/Analysis/SqlServerFactCollector.Config.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFactCollector.Config.cs rename to deprecated/Dashboard/Analysis/SqlServerFactCollector.Config.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.QueryPerf.cs b/deprecated/Dashboard/Analysis/SqlServerFactCollector.QueryPerf.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFactCollector.QueryPerf.cs rename to deprecated/Dashboard/Analysis/SqlServerFactCollector.QueryPerf.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.Resources.cs b/deprecated/Dashboard/Analysis/SqlServerFactCollector.Resources.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFactCollector.Resources.cs rename to deprecated/Dashboard/Analysis/SqlServerFactCollector.Resources.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.Storage.cs b/deprecated/Dashboard/Analysis/SqlServerFactCollector.Storage.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFactCollector.Storage.cs rename to deprecated/Dashboard/Analysis/SqlServerFactCollector.Storage.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.Waits.cs b/deprecated/Dashboard/Analysis/SqlServerFactCollector.Waits.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFactCollector.Waits.cs rename to deprecated/Dashboard/Analysis/SqlServerFactCollector.Waits.cs diff --git a/Dashboard/Analysis/SqlServerFactCollector.cs b/deprecated/Dashboard/Analysis/SqlServerFactCollector.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFactCollector.cs rename to deprecated/Dashboard/Analysis/SqlServerFactCollector.cs diff --git a/Dashboard/Analysis/SqlServerFindingStore.cs b/deprecated/Dashboard/Analysis/SqlServerFindingStore.cs similarity index 100% rename from Dashboard/Analysis/SqlServerFindingStore.cs rename to deprecated/Dashboard/Analysis/SqlServerFindingStore.cs diff --git a/Dashboard/Analysis/SqlServerPlanFetcher.cs b/deprecated/Dashboard/Analysis/SqlServerPlanFetcher.cs similarity index 100% rename from Dashboard/Analysis/SqlServerPlanFetcher.cs rename to deprecated/Dashboard/Analysis/SqlServerPlanFetcher.cs diff --git a/Dashboard/App.xaml b/deprecated/Dashboard/App.xaml similarity index 100% rename from Dashboard/App.xaml rename to deprecated/Dashboard/App.xaml diff --git a/Dashboard/App.xaml.cs b/deprecated/Dashboard/App.xaml.cs similarity index 100% rename from Dashboard/App.xaml.cs rename to deprecated/Dashboard/App.xaml.cs diff --git a/Dashboard/CollectionLogWindow.xaml b/deprecated/Dashboard/CollectionLogWindow.xaml similarity index 100% rename from Dashboard/CollectionLogWindow.xaml rename to deprecated/Dashboard/CollectionLogWindow.xaml diff --git a/Dashboard/CollectionLogWindow.xaml.cs b/deprecated/Dashboard/CollectionLogWindow.xaml.cs similarity index 100% rename from Dashboard/CollectionLogWindow.xaml.cs rename to deprecated/Dashboard/CollectionLogWindow.xaml.cs diff --git a/Dashboard/CollectorScheduleWindow.xaml b/deprecated/Dashboard/CollectorScheduleWindow.xaml similarity index 100% rename from Dashboard/CollectorScheduleWindow.xaml rename to deprecated/Dashboard/CollectorScheduleWindow.xaml diff --git a/Dashboard/CollectorScheduleWindow.xaml.cs b/deprecated/Dashboard/CollectorScheduleWindow.xaml.cs similarity index 100% rename from Dashboard/CollectorScheduleWindow.xaml.cs rename to deprecated/Dashboard/CollectorScheduleWindow.xaml.cs diff --git a/Dashboard/ColumnFilterPopup.xaml b/deprecated/Dashboard/ColumnFilterPopup.xaml similarity index 100% rename from Dashboard/ColumnFilterPopup.xaml rename to deprecated/Dashboard/ColumnFilterPopup.xaml diff --git a/Dashboard/ColumnFilterPopup.xaml.cs b/deprecated/Dashboard/ColumnFilterPopup.xaml.cs similarity index 100% rename from Dashboard/ColumnFilterPopup.xaml.cs rename to deprecated/Dashboard/ColumnFilterPopup.xaml.cs diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml b/deprecated/Dashboard/Controls/AlertsHistoryContent.xaml similarity index 100% rename from Dashboard/Controls/AlertsHistoryContent.xaml rename to deprecated/Dashboard/Controls/AlertsHistoryContent.xaml diff --git a/Dashboard/Controls/AlertsHistoryContent.xaml.cs b/deprecated/Dashboard/Controls/AlertsHistoryContent.xaml.cs similarity index 100% rename from Dashboard/Controls/AlertsHistoryContent.xaml.cs rename to deprecated/Dashboard/Controls/AlertsHistoryContent.xaml.cs diff --git a/Dashboard/Controls/ConfigChangesContent.xaml b/deprecated/Dashboard/Controls/ConfigChangesContent.xaml similarity index 100% rename from Dashboard/Controls/ConfigChangesContent.xaml rename to deprecated/Dashboard/Controls/ConfigChangesContent.xaml diff --git a/Dashboard/Controls/ConfigChangesContent.xaml.cs b/deprecated/Dashboard/Controls/ConfigChangesContent.xaml.cs similarity index 100% rename from Dashboard/Controls/ConfigChangesContent.xaml.cs rename to deprecated/Dashboard/Controls/ConfigChangesContent.xaml.cs diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml b/deprecated/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml similarity index 100% rename from Dashboard/Controls/CorrelatedTimelineLanesControl.xaml rename to deprecated/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml diff --git a/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs b/deprecated/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs similarity index 100% rename from Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs rename to deprecated/Dashboard/Controls/CorrelatedTimelineLanesControl.xaml.cs diff --git a/Dashboard/Controls/CriticalIssuesContent.xaml b/deprecated/Dashboard/Controls/CriticalIssuesContent.xaml similarity index 100% rename from Dashboard/Controls/CriticalIssuesContent.xaml rename to deprecated/Dashboard/Controls/CriticalIssuesContent.xaml diff --git a/Dashboard/Controls/CriticalIssuesContent.xaml.cs b/deprecated/Dashboard/Controls/CriticalIssuesContent.xaml.cs similarity index 100% rename from Dashboard/Controls/CriticalIssuesContent.xaml.cs rename to deprecated/Dashboard/Controls/CriticalIssuesContent.xaml.cs diff --git a/Dashboard/Controls/CurrentConfigContent.xaml b/deprecated/Dashboard/Controls/CurrentConfigContent.xaml similarity index 100% rename from Dashboard/Controls/CurrentConfigContent.xaml rename to deprecated/Dashboard/Controls/CurrentConfigContent.xaml diff --git a/Dashboard/Controls/CurrentConfigContent.xaml.cs b/deprecated/Dashboard/Controls/CurrentConfigContent.xaml.cs similarity index 100% rename from Dashboard/Controls/CurrentConfigContent.xaml.cs rename to deprecated/Dashboard/Controls/CurrentConfigContent.xaml.cs diff --git a/Dashboard/Controls/DailySummaryContent.xaml b/deprecated/Dashboard/Controls/DailySummaryContent.xaml similarity index 100% rename from Dashboard/Controls/DailySummaryContent.xaml rename to deprecated/Dashboard/Controls/DailySummaryContent.xaml diff --git a/Dashboard/Controls/DailySummaryContent.xaml.cs b/deprecated/Dashboard/Controls/DailySummaryContent.xaml.cs similarity index 100% rename from Dashboard/Controls/DailySummaryContent.xaml.cs rename to deprecated/Dashboard/Controls/DailySummaryContent.xaml.cs diff --git a/Dashboard/Controls/DefaultTraceContent.xaml b/deprecated/Dashboard/Controls/DefaultTraceContent.xaml similarity index 100% rename from Dashboard/Controls/DefaultTraceContent.xaml rename to deprecated/Dashboard/Controls/DefaultTraceContent.xaml diff --git a/Dashboard/Controls/DefaultTraceContent.xaml.cs b/deprecated/Dashboard/Controls/DefaultTraceContent.xaml.cs similarity index 100% rename from Dashboard/Controls/DefaultTraceContent.xaml.cs rename to deprecated/Dashboard/Controls/DefaultTraceContent.xaml.cs diff --git a/Dashboard/Controls/FinOpsContent.CopyExport.cs b/deprecated/Dashboard/Controls/FinOpsContent.CopyExport.cs similarity index 100% rename from Dashboard/Controls/FinOpsContent.CopyExport.cs rename to deprecated/Dashboard/Controls/FinOpsContent.CopyExport.cs diff --git a/Dashboard/Controls/FinOpsContent.Filters.cs b/deprecated/Dashboard/Controls/FinOpsContent.Filters.cs similarity index 100% rename from Dashboard/Controls/FinOpsContent.Filters.cs rename to deprecated/Dashboard/Controls/FinOpsContent.Filters.cs diff --git a/Dashboard/Controls/FinOpsContent.Loaders.cs b/deprecated/Dashboard/Controls/FinOpsContent.Loaders.cs similarity index 100% rename from Dashboard/Controls/FinOpsContent.Loaders.cs rename to deprecated/Dashboard/Controls/FinOpsContent.Loaders.cs diff --git a/Dashboard/Controls/FinOpsContent.Locking.cs b/deprecated/Dashboard/Controls/FinOpsContent.Locking.cs similarity index 100% rename from Dashboard/Controls/FinOpsContent.Locking.cs rename to deprecated/Dashboard/Controls/FinOpsContent.Locking.cs diff --git a/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs b/deprecated/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs similarity index 100% rename from Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs rename to deprecated/Dashboard/Controls/FinOpsContent.ObjectHeatmap.cs diff --git a/Dashboard/Controls/FinOpsContent.Refresh.cs b/deprecated/Dashboard/Controls/FinOpsContent.Refresh.cs similarity index 100% rename from Dashboard/Controls/FinOpsContent.Refresh.cs rename to deprecated/Dashboard/Controls/FinOpsContent.Refresh.cs diff --git a/Dashboard/Controls/FinOpsContent.xaml b/deprecated/Dashboard/Controls/FinOpsContent.xaml similarity index 100% rename from Dashboard/Controls/FinOpsContent.xaml rename to deprecated/Dashboard/Controls/FinOpsContent.xaml diff --git a/Dashboard/Controls/FinOpsContent.xaml.cs b/deprecated/Dashboard/Controls/FinOpsContent.xaml.cs similarity index 100% rename from Dashboard/Controls/FinOpsContent.xaml.cs rename to deprecated/Dashboard/Controls/FinOpsContent.xaml.cs diff --git a/Dashboard/Controls/LandingPage.xaml b/deprecated/Dashboard/Controls/LandingPage.xaml similarity index 100% rename from Dashboard/Controls/LandingPage.xaml rename to deprecated/Dashboard/Controls/LandingPage.xaml diff --git a/Dashboard/Controls/LandingPage.xaml.cs b/deprecated/Dashboard/Controls/LandingPage.xaml.cs similarity index 100% rename from Dashboard/Controls/LandingPage.xaml.cs rename to deprecated/Dashboard/Controls/LandingPage.xaml.cs diff --git a/Dashboard/Controls/LoadingOverlay.xaml b/deprecated/Dashboard/Controls/LoadingOverlay.xaml similarity index 100% rename from Dashboard/Controls/LoadingOverlay.xaml rename to deprecated/Dashboard/Controls/LoadingOverlay.xaml diff --git a/Dashboard/Controls/LoadingOverlay.xaml.cs b/deprecated/Dashboard/Controls/LoadingOverlay.xaml.cs similarity index 100% rename from Dashboard/Controls/LoadingOverlay.xaml.cs rename to deprecated/Dashboard/Controls/LoadingOverlay.xaml.cs diff --git a/Dashboard/Controls/MemoryContent.CopyExport.cs b/deprecated/Dashboard/Controls/MemoryContent.CopyExport.cs similarity index 100% rename from Dashboard/Controls/MemoryContent.CopyExport.cs rename to deprecated/Dashboard/Controls/MemoryContent.CopyExport.cs diff --git a/Dashboard/Controls/MemoryContent.MemoryClerks.cs b/deprecated/Dashboard/Controls/MemoryContent.MemoryClerks.cs similarity index 100% rename from Dashboard/Controls/MemoryContent.MemoryClerks.cs rename to deprecated/Dashboard/Controls/MemoryContent.MemoryClerks.cs diff --git a/Dashboard/Controls/MemoryContent.MemoryGrants.cs b/deprecated/Dashboard/Controls/MemoryContent.MemoryGrants.cs similarity index 100% rename from Dashboard/Controls/MemoryContent.MemoryGrants.cs rename to deprecated/Dashboard/Controls/MemoryContent.MemoryGrants.cs diff --git a/Dashboard/Controls/MemoryContent.MemoryPressure.cs b/deprecated/Dashboard/Controls/MemoryContent.MemoryPressure.cs similarity index 100% rename from Dashboard/Controls/MemoryContent.MemoryPressure.cs rename to deprecated/Dashboard/Controls/MemoryContent.MemoryPressure.cs diff --git a/Dashboard/Controls/MemoryContent.MemoryStats.cs b/deprecated/Dashboard/Controls/MemoryContent.MemoryStats.cs similarity index 100% rename from Dashboard/Controls/MemoryContent.MemoryStats.cs rename to deprecated/Dashboard/Controls/MemoryContent.MemoryStats.cs diff --git a/Dashboard/Controls/MemoryContent.PlanCache.cs b/deprecated/Dashboard/Controls/MemoryContent.PlanCache.cs similarity index 100% rename from Dashboard/Controls/MemoryContent.PlanCache.cs rename to deprecated/Dashboard/Controls/MemoryContent.PlanCache.cs diff --git a/Dashboard/Controls/MemoryContent.xaml b/deprecated/Dashboard/Controls/MemoryContent.xaml similarity index 100% rename from Dashboard/Controls/MemoryContent.xaml rename to deprecated/Dashboard/Controls/MemoryContent.xaml diff --git a/Dashboard/Controls/MemoryContent.xaml.cs b/deprecated/Dashboard/Controls/MemoryContent.xaml.cs similarity index 100% rename from Dashboard/Controls/MemoryContent.xaml.cs rename to deprecated/Dashboard/Controls/MemoryContent.xaml.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.BarCells.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.BarCells.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.BarCells.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.BarCells.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.Comparison.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.Comparison.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.Comparison.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.Comparison.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.CopyExport.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.CopyExport.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.Filters.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.Filters.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.Filters.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.Filters.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.Heatmap.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.Heatmap.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.Heatmap.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.Heatmap.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.Plans.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.Plans.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.Plans.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.Plans.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.Slicers.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.Slicers.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.Slicers.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.Slicers.cs diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml b/deprecated/Dashboard/Controls/QueryPerformanceContent.xaml similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.xaml rename to deprecated/Dashboard/Controls/QueryPerformanceContent.xaml diff --git a/Dashboard/Controls/QueryPerformanceContent.xaml.cs b/deprecated/Dashboard/Controls/QueryPerformanceContent.xaml.cs similarity index 100% rename from Dashboard/Controls/QueryPerformanceContent.xaml.cs rename to deprecated/Dashboard/Controls/QueryPerformanceContent.xaml.cs diff --git a/Dashboard/Controls/RecommendationsContent.xaml b/deprecated/Dashboard/Controls/RecommendationsContent.xaml similarity index 100% rename from Dashboard/Controls/RecommendationsContent.xaml rename to deprecated/Dashboard/Controls/RecommendationsContent.xaml diff --git a/Dashboard/Controls/RecommendationsContent.xaml.cs b/deprecated/Dashboard/Controls/RecommendationsContent.xaml.cs similarity index 100% rename from Dashboard/Controls/RecommendationsContent.xaml.cs rename to deprecated/Dashboard/Controls/RecommendationsContent.xaml.cs diff --git a/Dashboard/Controls/RecommendationsViewModel.cs b/deprecated/Dashboard/Controls/RecommendationsViewModel.cs similarity index 100% rename from Dashboard/Controls/RecommendationsViewModel.cs rename to deprecated/Dashboard/Controls/RecommendationsViewModel.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.CopyExport.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.CopyExport.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.FileIoLatency.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.FileIoLatency.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.FileIoLatency.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.FileIoLatency.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.LatchStats.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.LatchStats.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.LatchStats.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.LatchStats.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.PerfmonCounters.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.PerfmonCounters.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.PerfmonCounters.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.PerfmonCounters.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.SessionStats.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.SessionStats.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.SessionStats.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.SessionStats.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.SpinlockStats.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.SpinlockStats.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.SpinlockStats.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.SpinlockStats.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.TempdbStats.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.TempdbStats.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.TempdbStats.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.TempdbStats.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.WaitStatsDetail.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.WaitStatsDetail.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.WaitStatsDetail.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.WaitStatsDetail.cs diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml b/deprecated/Dashboard/Controls/ResourceMetricsContent.xaml similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.xaml rename to deprecated/Dashboard/Controls/ResourceMetricsContent.xaml diff --git a/Dashboard/Controls/ResourceMetricsContent.xaml.cs b/deprecated/Dashboard/Controls/ResourceMetricsContent.xaml.cs similarity index 100% rename from Dashboard/Controls/ResourceMetricsContent.xaml.cs rename to deprecated/Dashboard/Controls/ResourceMetricsContent.xaml.cs diff --git a/Dashboard/Controls/ServerHealthCard.xaml b/deprecated/Dashboard/Controls/ServerHealthCard.xaml similarity index 100% rename from Dashboard/Controls/ServerHealthCard.xaml rename to deprecated/Dashboard/Controls/ServerHealthCard.xaml diff --git a/Dashboard/Controls/ServerHealthCard.xaml.cs b/deprecated/Dashboard/Controls/ServerHealthCard.xaml.cs similarity index 100% rename from Dashboard/Controls/ServerHealthCard.xaml.cs rename to deprecated/Dashboard/Controls/ServerHealthCard.xaml.cs diff --git a/Dashboard/Controls/SnoozeBalloon.xaml b/deprecated/Dashboard/Controls/SnoozeBalloon.xaml similarity index 100% rename from Dashboard/Controls/SnoozeBalloon.xaml rename to deprecated/Dashboard/Controls/SnoozeBalloon.xaml diff --git a/Dashboard/Controls/SnoozeBalloon.xaml.cs b/deprecated/Dashboard/Controls/SnoozeBalloon.xaml.cs similarity index 100% rename from Dashboard/Controls/SnoozeBalloon.xaml.cs rename to deprecated/Dashboard/Controls/SnoozeBalloon.xaml.cs diff --git a/Dashboard/Controls/StyledBalloon.xaml b/deprecated/Dashboard/Controls/StyledBalloon.xaml similarity index 100% rename from Dashboard/Controls/StyledBalloon.xaml rename to deprecated/Dashboard/Controls/StyledBalloon.xaml diff --git a/Dashboard/Controls/StyledBalloon.xaml.cs b/deprecated/Dashboard/Controls/StyledBalloon.xaml.cs similarity index 100% rename from Dashboard/Controls/StyledBalloon.xaml.cs rename to deprecated/Dashboard/Controls/StyledBalloon.xaml.cs diff --git a/Dashboard/Controls/SystemEventsContent.CPUTasks.cs b/deprecated/Dashboard/Controls/SystemEventsContent.CPUTasks.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.CPUTasks.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.CPUTasks.cs diff --git a/Dashboard/Controls/SystemEventsContent.CopyExport.cs b/deprecated/Dashboard/Controls/SystemEventsContent.CopyExport.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.CopyExport.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.CopyExport.cs diff --git a/Dashboard/Controls/SystemEventsContent.FilterPopup.cs b/deprecated/Dashboard/Controls/SystemEventsContent.FilterPopup.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.FilterPopup.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.FilterPopup.cs diff --git a/Dashboard/Controls/SystemEventsContent.IOIssues.cs b/deprecated/Dashboard/Controls/SystemEventsContent.IOIssues.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.IOIssues.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.IOIssues.cs diff --git a/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs b/deprecated/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.MemoryBroker.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.MemoryBroker.cs diff --git a/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs b/deprecated/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.MemoryConditions.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.MemoryConditions.cs diff --git a/Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs b/deprecated/Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.MemoryNodeOOM.cs diff --git a/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs b/deprecated/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.SchedulerIssues.cs diff --git a/Dashboard/Controls/SystemEventsContent.SevereErrors.cs b/deprecated/Dashboard/Controls/SystemEventsContent.SevereErrors.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.SevereErrors.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.SevereErrors.cs diff --git a/Dashboard/Controls/SystemEventsContent.SystemHealth.cs b/deprecated/Dashboard/Controls/SystemEventsContent.SystemHealth.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.SystemHealth.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.SystemHealth.cs diff --git a/Dashboard/Controls/SystemEventsContent.xaml b/deprecated/Dashboard/Controls/SystemEventsContent.xaml similarity index 100% rename from Dashboard/Controls/SystemEventsContent.xaml rename to deprecated/Dashboard/Controls/SystemEventsContent.xaml diff --git a/Dashboard/Controls/SystemEventsContent.xaml.cs b/deprecated/Dashboard/Controls/SystemEventsContent.xaml.cs similarity index 100% rename from Dashboard/Controls/SystemEventsContent.xaml.cs rename to deprecated/Dashboard/Controls/SystemEventsContent.xaml.cs diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml b/deprecated/Dashboard/Controls/TimeRangeSlicerControl.xaml similarity index 100% rename from Dashboard/Controls/TimeRangeSlicerControl.xaml rename to deprecated/Dashboard/Controls/TimeRangeSlicerControl.xaml diff --git a/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs b/deprecated/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs similarity index 100% rename from Dashboard/Controls/TimeRangeSlicerControl.xaml.cs rename to deprecated/Dashboard/Controls/TimeRangeSlicerControl.xaml.cs diff --git a/Dashboard/Converters/NullToBooleanConverter.cs b/deprecated/Dashboard/Converters/NullToBooleanConverter.cs similarity index 100% rename from Dashboard/Converters/NullToBooleanConverter.cs rename to deprecated/Dashboard/Converters/NullToBooleanConverter.cs diff --git a/Dashboard/Converters/QueryTextCleanupConverter.cs b/deprecated/Dashboard/Converters/QueryTextCleanupConverter.cs similarity index 100% rename from Dashboard/Converters/QueryTextCleanupConverter.cs rename to deprecated/Dashboard/Converters/QueryTextCleanupConverter.cs diff --git a/Dashboard/Converters/ValueToBrushConverter.cs b/deprecated/Dashboard/Converters/ValueToBrushConverter.cs similarity index 100% rename from Dashboard/Converters/ValueToBrushConverter.cs rename to deprecated/Dashboard/Converters/ValueToBrushConverter.cs diff --git a/Dashboard/Dashboard.csproj b/deprecated/Dashboard/Dashboard.csproj similarity index 84% rename from Dashboard/Dashboard.csproj rename to deprecated/Dashboard/Dashboard.csproj index 3602c00df..701faea5f 100644 --- a/Dashboard/Dashboard.csproj +++ b/deprecated/Dashboard/Dashboard.csproj @@ -55,12 +55,12 @@ - - - - - - + + + + + + diff --git a/Dashboard/EDD.ico b/deprecated/Dashboard/EDD.ico similarity index 100% rename from Dashboard/EDD.ico rename to deprecated/Dashboard/EDD.ico diff --git a/Dashboard/ExcludedDatabasesDialog.xaml b/deprecated/Dashboard/ExcludedDatabasesDialog.xaml similarity index 100% rename from Dashboard/ExcludedDatabasesDialog.xaml rename to deprecated/Dashboard/ExcludedDatabasesDialog.xaml diff --git a/Dashboard/ExcludedDatabasesDialog.xaml.cs b/deprecated/Dashboard/ExcludedDatabasesDialog.xaml.cs similarity index 100% rename from Dashboard/ExcludedDatabasesDialog.xaml.cs rename to deprecated/Dashboard/ExcludedDatabasesDialog.xaml.cs diff --git a/Dashboard/GlobalUsings.cs b/deprecated/Dashboard/GlobalUsings.cs similarity index 100% rename from Dashboard/GlobalUsings.cs rename to deprecated/Dashboard/GlobalUsings.cs diff --git a/Dashboard/Helpers/DateFilterHelper.cs b/deprecated/Dashboard/Helpers/DateFilterHelper.cs similarity index 100% rename from Dashboard/Helpers/DateFilterHelper.cs rename to deprecated/Dashboard/Helpers/DateFilterHelper.cs diff --git a/Dashboard/Helpers/Logger.cs b/deprecated/Dashboard/Helpers/Logger.cs similarity index 100% rename from Dashboard/Helpers/Logger.cs rename to deprecated/Dashboard/Helpers/Logger.cs diff --git a/Dashboard/Helpers/LoggerAdapter.cs b/deprecated/Dashboard/Helpers/LoggerAdapter.cs similarity index 100% rename from Dashboard/Helpers/LoggerAdapter.cs rename to deprecated/Dashboard/Helpers/LoggerAdapter.cs diff --git a/Dashboard/Helpers/MethodProfiler.cs b/deprecated/Dashboard/Helpers/MethodProfiler.cs similarity index 100% rename from Dashboard/Helpers/MethodProfiler.cs rename to deprecated/Dashboard/Helpers/MethodProfiler.cs diff --git a/Dashboard/Helpers/NativeMethods.cs b/deprecated/Dashboard/Helpers/NativeMethods.cs similarity index 100% rename from Dashboard/Helpers/NativeMethods.cs rename to deprecated/Dashboard/Helpers/NativeMethods.cs diff --git a/Dashboard/Helpers/NumericFilterHelper.cs b/deprecated/Dashboard/Helpers/NumericFilterHelper.cs similarity index 100% rename from Dashboard/Helpers/NumericFilterHelper.cs rename to deprecated/Dashboard/Helpers/NumericFilterHelper.cs diff --git a/Dashboard/Helpers/QueryLogger.cs b/deprecated/Dashboard/Helpers/QueryLogger.cs similarity index 100% rename from Dashboard/Helpers/QueryLogger.cs rename to deprecated/Dashboard/Helpers/QueryLogger.cs diff --git a/Dashboard/Helpers/ServerTimeConverter.cs b/deprecated/Dashboard/Helpers/ServerTimeConverter.cs similarity index 100% rename from Dashboard/Helpers/ServerTimeConverter.cs rename to deprecated/Dashboard/Helpers/ServerTimeConverter.cs diff --git a/Dashboard/Helpers/ServerTimeHelper.cs b/deprecated/Dashboard/Helpers/ServerTimeHelper.cs similarity index 100% rename from Dashboard/Helpers/ServerTimeHelper.cs rename to deprecated/Dashboard/Helpers/ServerTimeHelper.cs diff --git a/Dashboard/Helpers/TabHelpers.cs b/deprecated/Dashboard/Helpers/TabHelpers.cs similarity index 100% rename from Dashboard/Helpers/TabHelpers.cs rename to deprecated/Dashboard/Helpers/TabHelpers.cs diff --git a/Dashboard/Interfaces/ICredentialService.cs b/deprecated/Dashboard/Interfaces/ICredentialService.cs similarity index 100% rename from Dashboard/Interfaces/ICredentialService.cs rename to deprecated/Dashboard/Interfaces/ICredentialService.cs diff --git a/Dashboard/Interfaces/IServerManager.cs b/deprecated/Dashboard/Interfaces/IServerManager.cs similarity index 100% rename from Dashboard/Interfaces/IServerManager.cs rename to deprecated/Dashboard/Interfaces/IServerManager.cs diff --git a/Dashboard/Interfaces/IUserPreferencesService.cs b/deprecated/Dashboard/Interfaces/IUserPreferencesService.cs similarity index 100% rename from Dashboard/Interfaces/IUserPreferencesService.cs rename to deprecated/Dashboard/Interfaces/IUserPreferencesService.cs diff --git a/Dashboard/MainWindow.AlertEngine.cs b/deprecated/Dashboard/MainWindow.AlertEngine.cs similarity index 100% rename from Dashboard/MainWindow.AlertEngine.cs rename to deprecated/Dashboard/MainWindow.AlertEngine.cs diff --git a/Dashboard/MainWindow.PlanViewer.cs b/deprecated/Dashboard/MainWindow.PlanViewer.cs similarity index 100% rename from Dashboard/MainWindow.PlanViewer.cs rename to deprecated/Dashboard/MainWindow.PlanViewer.cs diff --git a/Dashboard/MainWindow.xaml b/deprecated/Dashboard/MainWindow.xaml similarity index 100% rename from Dashboard/MainWindow.xaml rename to deprecated/Dashboard/MainWindow.xaml diff --git a/Dashboard/MainWindow.xaml.cs b/deprecated/Dashboard/MainWindow.xaml.cs similarity index 100% rename from Dashboard/MainWindow.xaml.cs rename to deprecated/Dashboard/MainWindow.xaml.cs diff --git a/Dashboard/ManageMuteRulesWindow.xaml b/deprecated/Dashboard/ManageMuteRulesWindow.xaml similarity index 100% rename from Dashboard/ManageMuteRulesWindow.xaml rename to deprecated/Dashboard/ManageMuteRulesWindow.xaml diff --git a/Dashboard/ManageMuteRulesWindow.xaml.cs b/deprecated/Dashboard/ManageMuteRulesWindow.xaml.cs similarity index 100% rename from Dashboard/ManageMuteRulesWindow.xaml.cs rename to deprecated/Dashboard/ManageMuteRulesWindow.xaml.cs diff --git a/Dashboard/ManageServersWindow.xaml b/deprecated/Dashboard/ManageServersWindow.xaml similarity index 100% rename from Dashboard/ManageServersWindow.xaml rename to deprecated/Dashboard/ManageServersWindow.xaml diff --git a/Dashboard/ManageServersWindow.xaml.cs b/deprecated/Dashboard/ManageServersWindow.xaml.cs similarity index 100% rename from Dashboard/ManageServersWindow.xaml.cs rename to deprecated/Dashboard/ManageServersWindow.xaml.cs diff --git a/Dashboard/Mcp/DatabaseServiceRegistry.cs b/deprecated/Dashboard/Mcp/DatabaseServiceRegistry.cs similarity index 100% rename from Dashboard/Mcp/DatabaseServiceRegistry.cs rename to deprecated/Dashboard/Mcp/DatabaseServiceRegistry.cs diff --git a/Dashboard/Mcp/McpActiveQueryTools.cs b/deprecated/Dashboard/Mcp/McpActiveQueryTools.cs similarity index 100% rename from Dashboard/Mcp/McpActiveQueryTools.cs rename to deprecated/Dashboard/Mcp/McpActiveQueryTools.cs diff --git a/Dashboard/Mcp/McpAlertTools.cs b/deprecated/Dashboard/Mcp/McpAlertTools.cs similarity index 100% rename from Dashboard/Mcp/McpAlertTools.cs rename to deprecated/Dashboard/Mcp/McpAlertTools.cs diff --git a/Dashboard/Mcp/McpAnalysisTools.cs b/deprecated/Dashboard/Mcp/McpAnalysisTools.cs similarity index 100% rename from Dashboard/Mcp/McpAnalysisTools.cs rename to deprecated/Dashboard/Mcp/McpAnalysisTools.cs diff --git a/Dashboard/Mcp/McpBlockingTools.cs b/deprecated/Dashboard/Mcp/McpBlockingTools.cs similarity index 100% rename from Dashboard/Mcp/McpBlockingTools.cs rename to deprecated/Dashboard/Mcp/McpBlockingTools.cs diff --git a/Dashboard/Mcp/McpConfigHistoryTools.cs b/deprecated/Dashboard/Mcp/McpConfigHistoryTools.cs similarity index 100% rename from Dashboard/Mcp/McpConfigHistoryTools.cs rename to deprecated/Dashboard/Mcp/McpConfigHistoryTools.cs diff --git a/Dashboard/Mcp/McpCpuTools.cs b/deprecated/Dashboard/Mcp/McpCpuTools.cs similarity index 100% rename from Dashboard/Mcp/McpCpuTools.cs rename to deprecated/Dashboard/Mcp/McpCpuTools.cs diff --git a/Dashboard/Mcp/McpDiagnosticTools.cs b/deprecated/Dashboard/Mcp/McpDiagnosticTools.cs similarity index 100% rename from Dashboard/Mcp/McpDiagnosticTools.cs rename to deprecated/Dashboard/Mcp/McpDiagnosticTools.cs diff --git a/Dashboard/Mcp/McpDiscoveryTools.cs b/deprecated/Dashboard/Mcp/McpDiscoveryTools.cs similarity index 100% rename from Dashboard/Mcp/McpDiscoveryTools.cs rename to deprecated/Dashboard/Mcp/McpDiscoveryTools.cs diff --git a/Dashboard/Mcp/McpHealthParserTools.cs b/deprecated/Dashboard/Mcp/McpHealthParserTools.cs similarity index 100% rename from Dashboard/Mcp/McpHealthParserTools.cs rename to deprecated/Dashboard/Mcp/McpHealthParserTools.cs diff --git a/Dashboard/Mcp/McpHealthTools.cs b/deprecated/Dashboard/Mcp/McpHealthTools.cs similarity index 100% rename from Dashboard/Mcp/McpHealthTools.cs rename to deprecated/Dashboard/Mcp/McpHealthTools.cs diff --git a/Dashboard/Mcp/McpHostService.cs b/deprecated/Dashboard/Mcp/McpHostService.cs similarity index 100% rename from Dashboard/Mcp/McpHostService.cs rename to deprecated/Dashboard/Mcp/McpHostService.cs diff --git a/Dashboard/Mcp/McpInstructions.cs b/deprecated/Dashboard/Mcp/McpInstructions.cs similarity index 100% rename from Dashboard/Mcp/McpInstructions.cs rename to deprecated/Dashboard/Mcp/McpInstructions.cs diff --git a/Dashboard/Mcp/McpIoTools.cs b/deprecated/Dashboard/Mcp/McpIoTools.cs similarity index 100% rename from Dashboard/Mcp/McpIoTools.cs rename to deprecated/Dashboard/Mcp/McpIoTools.cs diff --git a/Dashboard/Mcp/McpJobTools.cs b/deprecated/Dashboard/Mcp/McpJobTools.cs similarity index 100% rename from Dashboard/Mcp/McpJobTools.cs rename to deprecated/Dashboard/Mcp/McpJobTools.cs diff --git a/Dashboard/Mcp/McpLatchSpinlockTools.cs b/deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs similarity index 100% rename from Dashboard/Mcp/McpLatchSpinlockTools.cs rename to deprecated/Dashboard/Mcp/McpLatchSpinlockTools.cs diff --git a/Dashboard/Mcp/McpMemoryTools.cs b/deprecated/Dashboard/Mcp/McpMemoryTools.cs similarity index 100% rename from Dashboard/Mcp/McpMemoryTools.cs rename to deprecated/Dashboard/Mcp/McpMemoryTools.cs diff --git a/Dashboard/Mcp/McpObjectStatsTools.cs b/deprecated/Dashboard/Mcp/McpObjectStatsTools.cs similarity index 100% rename from Dashboard/Mcp/McpObjectStatsTools.cs rename to deprecated/Dashboard/Mcp/McpObjectStatsTools.cs diff --git a/Dashboard/Mcp/McpPerfmonTools.cs b/deprecated/Dashboard/Mcp/McpPerfmonTools.cs similarity index 100% rename from Dashboard/Mcp/McpPerfmonTools.cs rename to deprecated/Dashboard/Mcp/McpPerfmonTools.cs diff --git a/Dashboard/Mcp/McpPlanTools.cs b/deprecated/Dashboard/Mcp/McpPlanTools.cs similarity index 100% rename from Dashboard/Mcp/McpPlanTools.cs rename to deprecated/Dashboard/Mcp/McpPlanTools.cs diff --git a/Dashboard/Mcp/McpQueryTools.cs b/deprecated/Dashboard/Mcp/McpQueryTools.cs similarity index 100% rename from Dashboard/Mcp/McpQueryTools.cs rename to deprecated/Dashboard/Mcp/McpQueryTools.cs diff --git a/Dashboard/Mcp/McpSchedulerTools.cs b/deprecated/Dashboard/Mcp/McpSchedulerTools.cs similarity index 100% rename from Dashboard/Mcp/McpSchedulerTools.cs rename to deprecated/Dashboard/Mcp/McpSchedulerTools.cs diff --git a/Dashboard/Mcp/McpServerInventoryTools.cs b/deprecated/Dashboard/Mcp/McpServerInventoryTools.cs similarity index 100% rename from Dashboard/Mcp/McpServerInventoryTools.cs rename to deprecated/Dashboard/Mcp/McpServerInventoryTools.cs diff --git a/Dashboard/Mcp/McpSystemEventTools.cs b/deprecated/Dashboard/Mcp/McpSystemEventTools.cs similarity index 100% rename from Dashboard/Mcp/McpSystemEventTools.cs rename to deprecated/Dashboard/Mcp/McpSystemEventTools.cs diff --git a/Dashboard/Mcp/McpTempDbTools.cs b/deprecated/Dashboard/Mcp/McpTempDbTools.cs similarity index 100% rename from Dashboard/Mcp/McpTempDbTools.cs rename to deprecated/Dashboard/Mcp/McpTempDbTools.cs diff --git a/Dashboard/Mcp/McpWaitTools.cs b/deprecated/Dashboard/Mcp/McpWaitTools.cs similarity index 100% rename from Dashboard/Mcp/McpWaitTools.cs rename to deprecated/Dashboard/Mcp/McpWaitTools.cs diff --git a/Dashboard/Mcp/ServerResolver.cs b/deprecated/Dashboard/Mcp/ServerResolver.cs similarity index 100% rename from Dashboard/Mcp/ServerResolver.cs rename to deprecated/Dashboard/Mcp/ServerResolver.cs diff --git a/Dashboard/Models/AlertHealthResult.cs b/deprecated/Dashboard/Models/AlertHealthResult.cs similarity index 100% rename from Dashboard/Models/AlertHealthResult.cs rename to deprecated/Dashboard/Models/AlertHealthResult.cs diff --git a/Dashboard/Models/BlockedSessionTrendItem.cs b/deprecated/Dashboard/Models/BlockedSessionTrendItem.cs similarity index 100% rename from Dashboard/Models/BlockedSessionTrendItem.cs rename to deprecated/Dashboard/Models/BlockedSessionTrendItem.cs diff --git a/Dashboard/Models/BlockingDeadlockStatsItem.cs b/deprecated/Dashboard/Models/BlockingDeadlockStatsItem.cs similarity index 100% rename from Dashboard/Models/BlockingDeadlockStatsItem.cs rename to deprecated/Dashboard/Models/BlockingDeadlockStatsItem.cs diff --git a/Dashboard/Models/BlockingEventItem.cs b/deprecated/Dashboard/Models/BlockingEventItem.cs similarity index 100% rename from Dashboard/Models/BlockingEventItem.cs rename to deprecated/Dashboard/Models/BlockingEventItem.cs diff --git a/Dashboard/Models/CollectionHealthItem.cs b/deprecated/Dashboard/Models/CollectionHealthItem.cs similarity index 100% rename from Dashboard/Models/CollectionHealthItem.cs rename to deprecated/Dashboard/Models/CollectionHealthItem.cs diff --git a/Dashboard/Models/CollectionLogEntry.cs b/deprecated/Dashboard/Models/CollectionLogEntry.cs similarity index 100% rename from Dashboard/Models/CollectionLogEntry.cs rename to deprecated/Dashboard/Models/CollectionLogEntry.cs diff --git a/Dashboard/Models/CollectorScheduleItem.cs b/deprecated/Dashboard/Models/CollectorScheduleItem.cs similarity index 100% rename from Dashboard/Models/CollectorScheduleItem.cs rename to deprecated/Dashboard/Models/CollectorScheduleItem.cs diff --git a/Dashboard/Models/CpuDataPoint.cs b/deprecated/Dashboard/Models/CpuDataPoint.cs similarity index 100% rename from Dashboard/Models/CpuDataPoint.cs rename to deprecated/Dashboard/Models/CpuDataPoint.cs diff --git a/Dashboard/Models/CpuPressureItem.cs b/deprecated/Dashboard/Models/CpuPressureItem.cs similarity index 100% rename from Dashboard/Models/CpuPressureItem.cs rename to deprecated/Dashboard/Models/CpuPressureItem.cs diff --git a/Dashboard/Models/CpuSpikeItem.cs b/deprecated/Dashboard/Models/CpuSpikeItem.cs similarity index 100% rename from Dashboard/Models/CpuSpikeItem.cs rename to deprecated/Dashboard/Models/CpuSpikeItem.cs diff --git a/Dashboard/Models/CpuUtilizationHistoryItem.cs b/deprecated/Dashboard/Models/CpuUtilizationHistoryItem.cs similarity index 100% rename from Dashboard/Models/CpuUtilizationHistoryItem.cs rename to deprecated/Dashboard/Models/CpuUtilizationHistoryItem.cs diff --git a/Dashboard/Models/CpuUtilizationItem.cs b/deprecated/Dashboard/Models/CpuUtilizationItem.cs similarity index 100% rename from Dashboard/Models/CpuUtilizationItem.cs rename to deprecated/Dashboard/Models/CpuUtilizationItem.cs diff --git a/Dashboard/Models/CriticalIssueItem.cs b/deprecated/Dashboard/Models/CriticalIssueItem.cs similarity index 100% rename from Dashboard/Models/CriticalIssueItem.cs rename to deprecated/Dashboard/Models/CriticalIssueItem.cs diff --git a/Dashboard/Models/CurrentServerConfigItem.cs b/deprecated/Dashboard/Models/CurrentServerConfigItem.cs similarity index 100% rename from Dashboard/Models/CurrentServerConfigItem.cs rename to deprecated/Dashboard/Models/CurrentServerConfigItem.cs diff --git a/Dashboard/Models/DailySummaryItem.cs b/deprecated/Dashboard/Models/DailySummaryItem.cs similarity index 100% rename from Dashboard/Models/DailySummaryItem.cs rename to deprecated/Dashboard/Models/DailySummaryItem.cs diff --git a/Dashboard/Models/DatabaseConfigChangeItem.cs b/deprecated/Dashboard/Models/DatabaseConfigChangeItem.cs similarity index 100% rename from Dashboard/Models/DatabaseConfigChangeItem.cs rename to deprecated/Dashboard/Models/DatabaseConfigChangeItem.cs diff --git a/Dashboard/Models/DeadlockItem.cs b/deprecated/Dashboard/Models/DeadlockItem.cs similarity index 100% rename from Dashboard/Models/DeadlockItem.cs rename to deprecated/Dashboard/Models/DeadlockItem.cs diff --git a/Dashboard/Models/DefaultTraceEventItem.cs b/deprecated/Dashboard/Models/DefaultTraceEventItem.cs similarity index 100% rename from Dashboard/Models/DefaultTraceEventItem.cs rename to deprecated/Dashboard/Models/DefaultTraceEventItem.cs diff --git a/Dashboard/Models/DurationTrendItem.cs b/deprecated/Dashboard/Models/DurationTrendItem.cs similarity index 100% rename from Dashboard/Models/DurationTrendItem.cs rename to deprecated/Dashboard/Models/DurationTrendItem.cs diff --git a/Dashboard/Models/ExecutionTrendItem.cs b/deprecated/Dashboard/Models/ExecutionTrendItem.cs similarity index 100% rename from Dashboard/Models/ExecutionTrendItem.cs rename to deprecated/Dashboard/Models/ExecutionTrendItem.cs diff --git a/Dashboard/Models/ExpensiveQueryItem.cs b/deprecated/Dashboard/Models/ExpensiveQueryItem.cs similarity index 100% rename from Dashboard/Models/ExpensiveQueryItem.cs rename to deprecated/Dashboard/Models/ExpensiveQueryItem.cs diff --git a/Dashboard/Models/FileIoDataPoint.cs b/deprecated/Dashboard/Models/FileIoDataPoint.cs similarity index 100% rename from Dashboard/Models/FileIoDataPoint.cs rename to deprecated/Dashboard/Models/FileIoDataPoint.cs diff --git a/Dashboard/Models/FileIoLatencyItem.cs b/deprecated/Dashboard/Models/FileIoLatencyItem.cs similarity index 100% rename from Dashboard/Models/FileIoLatencyItem.cs rename to deprecated/Dashboard/Models/FileIoLatencyItem.cs diff --git a/Dashboard/Models/FileIoLatencyTimeSeriesItem.cs b/deprecated/Dashboard/Models/FileIoLatencyTimeSeriesItem.cs similarity index 100% rename from Dashboard/Models/FileIoLatencyTimeSeriesItem.cs rename to deprecated/Dashboard/Models/FileIoLatencyTimeSeriesItem.cs diff --git a/Dashboard/Models/HealthParserCPUTasksItem.cs b/deprecated/Dashboard/Models/HealthParserCPUTasksItem.cs similarity index 100% rename from Dashboard/Models/HealthParserCPUTasksItem.cs rename to deprecated/Dashboard/Models/HealthParserCPUTasksItem.cs diff --git a/Dashboard/Models/HealthParserIOIssueItem.cs b/deprecated/Dashboard/Models/HealthParserIOIssueItem.cs similarity index 100% rename from Dashboard/Models/HealthParserIOIssueItem.cs rename to deprecated/Dashboard/Models/HealthParserIOIssueItem.cs diff --git a/Dashboard/Models/HealthParserMemoryBrokerItem.cs b/deprecated/Dashboard/Models/HealthParserMemoryBrokerItem.cs similarity index 100% rename from Dashboard/Models/HealthParserMemoryBrokerItem.cs rename to deprecated/Dashboard/Models/HealthParserMemoryBrokerItem.cs diff --git a/Dashboard/Models/HealthParserMemoryConditionItem.cs b/deprecated/Dashboard/Models/HealthParserMemoryConditionItem.cs similarity index 100% rename from Dashboard/Models/HealthParserMemoryConditionItem.cs rename to deprecated/Dashboard/Models/HealthParserMemoryConditionItem.cs diff --git a/Dashboard/Models/HealthParserMemoryNodeOOMItem.cs b/deprecated/Dashboard/Models/HealthParserMemoryNodeOOMItem.cs similarity index 100% rename from Dashboard/Models/HealthParserMemoryNodeOOMItem.cs rename to deprecated/Dashboard/Models/HealthParserMemoryNodeOOMItem.cs diff --git a/Dashboard/Models/HealthParserSchedulerIssueItem.cs b/deprecated/Dashboard/Models/HealthParserSchedulerIssueItem.cs similarity index 100% rename from Dashboard/Models/HealthParserSchedulerIssueItem.cs rename to deprecated/Dashboard/Models/HealthParserSchedulerIssueItem.cs diff --git a/Dashboard/Models/HealthParserSevereErrorItem.cs b/deprecated/Dashboard/Models/HealthParserSevereErrorItem.cs similarity index 100% rename from Dashboard/Models/HealthParserSevereErrorItem.cs rename to deprecated/Dashboard/Models/HealthParserSevereErrorItem.cs diff --git a/Dashboard/Models/HealthParserSystemHealthItem.cs b/deprecated/Dashboard/Models/HealthParserSystemHealthItem.cs similarity index 100% rename from Dashboard/Models/HealthParserSystemHealthItem.cs rename to deprecated/Dashboard/Models/HealthParserSystemHealthItem.cs diff --git a/Dashboard/Models/HeatmapModels.cs b/deprecated/Dashboard/Models/HeatmapModels.cs similarity index 100% rename from Dashboard/Models/HeatmapModels.cs rename to deprecated/Dashboard/Models/HeatmapModels.cs diff --git a/Dashboard/Models/LatchStatsItem.cs b/deprecated/Dashboard/Models/LatchStatsItem.cs similarity index 100% rename from Dashboard/Models/LatchStatsItem.cs rename to deprecated/Dashboard/Models/LatchStatsItem.cs diff --git a/Dashboard/Models/LiveQueryItem.cs b/deprecated/Dashboard/Models/LiveQueryItem.cs similarity index 100% rename from Dashboard/Models/LiveQueryItem.cs rename to deprecated/Dashboard/Models/LiveQueryItem.cs diff --git a/Dashboard/Models/LockWaitStatsItem.cs b/deprecated/Dashboard/Models/LockWaitStatsItem.cs similarity index 100% rename from Dashboard/Models/LockWaitStatsItem.cs rename to deprecated/Dashboard/Models/LockWaitStatsItem.cs diff --git a/Dashboard/Models/LongRunningQueryPatternItem.cs b/deprecated/Dashboard/Models/LongRunningQueryPatternItem.cs similarity index 100% rename from Dashboard/Models/LongRunningQueryPatternItem.cs rename to deprecated/Dashboard/Models/LongRunningQueryPatternItem.cs diff --git a/Dashboard/Models/MemoryClerksItem.cs b/deprecated/Dashboard/Models/MemoryClerksItem.cs similarity index 100% rename from Dashboard/Models/MemoryClerksItem.cs rename to deprecated/Dashboard/Models/MemoryClerksItem.cs diff --git a/Dashboard/Models/MemoryDataPoint.cs b/deprecated/Dashboard/Models/MemoryDataPoint.cs similarity index 100% rename from Dashboard/Models/MemoryDataPoint.cs rename to deprecated/Dashboard/Models/MemoryDataPoint.cs diff --git a/Dashboard/Models/MemoryGrantStatsItem.cs b/deprecated/Dashboard/Models/MemoryGrantStatsItem.cs similarity index 100% rename from Dashboard/Models/MemoryGrantStatsItem.cs rename to deprecated/Dashboard/Models/MemoryGrantStatsItem.cs diff --git a/Dashboard/Models/MemoryHistoryItem.cs b/deprecated/Dashboard/Models/MemoryHistoryItem.cs similarity index 100% rename from Dashboard/Models/MemoryHistoryItem.cs rename to deprecated/Dashboard/Models/MemoryHistoryItem.cs diff --git a/Dashboard/Models/MemoryPressureEventItem.cs b/deprecated/Dashboard/Models/MemoryPressureEventItem.cs similarity index 100% rename from Dashboard/Models/MemoryPressureEventItem.cs rename to deprecated/Dashboard/Models/MemoryPressureEventItem.cs diff --git a/Dashboard/Models/MemoryPressureItem.cs b/deprecated/Dashboard/Models/MemoryPressureItem.cs similarity index 100% rename from Dashboard/Models/MemoryPressureItem.cs rename to deprecated/Dashboard/Models/MemoryPressureItem.cs diff --git a/Dashboard/Models/MemoryStatsItem.cs b/deprecated/Dashboard/Models/MemoryStatsItem.cs similarity index 100% rename from Dashboard/Models/MemoryStatsItem.cs rename to deprecated/Dashboard/Models/MemoryStatsItem.cs diff --git a/Dashboard/Models/PerfmonStatsItem.cs b/deprecated/Dashboard/Models/PerfmonStatsItem.cs similarity index 100% rename from Dashboard/Models/PerfmonStatsItem.cs rename to deprecated/Dashboard/Models/PerfmonStatsItem.cs diff --git a/Dashboard/Models/PlanCacheStatsItem.cs b/deprecated/Dashboard/Models/PlanCacheStatsItem.cs similarity index 100% rename from Dashboard/Models/PlanCacheStatsItem.cs rename to deprecated/Dashboard/Models/PlanCacheStatsItem.cs diff --git a/Dashboard/Models/ProcedureExecutionHistoryItem.cs b/deprecated/Dashboard/Models/ProcedureExecutionHistoryItem.cs similarity index 100% rename from Dashboard/Models/ProcedureExecutionHistoryItem.cs rename to deprecated/Dashboard/Models/ProcedureExecutionHistoryItem.cs diff --git a/Dashboard/Models/ProcedureStatsItem.cs b/deprecated/Dashboard/Models/ProcedureStatsItem.cs similarity index 100% rename from Dashboard/Models/ProcedureStatsItem.cs rename to deprecated/Dashboard/Models/ProcedureStatsItem.cs diff --git a/Dashboard/Models/QueryExecutionHistoryItem.cs b/deprecated/Dashboard/Models/QueryExecutionHistoryItem.cs similarity index 100% rename from Dashboard/Models/QueryExecutionHistoryItem.cs rename to deprecated/Dashboard/Models/QueryExecutionHistoryItem.cs diff --git a/Dashboard/Models/QuerySnapshotItem.cs b/deprecated/Dashboard/Models/QuerySnapshotItem.cs similarity index 100% rename from Dashboard/Models/QuerySnapshotItem.cs rename to deprecated/Dashboard/Models/QuerySnapshotItem.cs diff --git a/Dashboard/Models/QueryStatsHistoryItem.cs b/deprecated/Dashboard/Models/QueryStatsHistoryItem.cs similarity index 100% rename from Dashboard/Models/QueryStatsHistoryItem.cs rename to deprecated/Dashboard/Models/QueryStatsHistoryItem.cs diff --git a/Dashboard/Models/QueryStatsItem.cs b/deprecated/Dashboard/Models/QueryStatsItem.cs similarity index 100% rename from Dashboard/Models/QueryStatsItem.cs rename to deprecated/Dashboard/Models/QueryStatsItem.cs diff --git a/Dashboard/Models/QueryStoreItem.cs b/deprecated/Dashboard/Models/QueryStoreItem.cs similarity index 100% rename from Dashboard/Models/QueryStoreItem.cs rename to deprecated/Dashboard/Models/QueryStoreItem.cs diff --git a/Dashboard/Models/QueryStoreRegressionItem.cs b/deprecated/Dashboard/Models/QueryStoreRegressionItem.cs similarity index 100% rename from Dashboard/Models/QueryStoreRegressionItem.cs rename to deprecated/Dashboard/Models/QueryStoreRegressionItem.cs diff --git a/Dashboard/Models/RunningJobItem.cs b/deprecated/Dashboard/Models/RunningJobItem.cs similarity index 100% rename from Dashboard/Models/RunningJobItem.cs rename to deprecated/Dashboard/Models/RunningJobItem.cs diff --git a/Dashboard/Models/SelectableItem.cs b/deprecated/Dashboard/Models/SelectableItem.cs similarity index 100% rename from Dashboard/Models/SelectableItem.cs rename to deprecated/Dashboard/Models/SelectableItem.cs diff --git a/Dashboard/Models/ServerConfigChangeItem.cs b/deprecated/Dashboard/Models/ServerConfigChangeItem.cs similarity index 100% rename from Dashboard/Models/ServerConfigChangeItem.cs rename to deprecated/Dashboard/Models/ServerConfigChangeItem.cs diff --git a/Dashboard/Models/ServerConnection.cs b/deprecated/Dashboard/Models/ServerConnection.cs similarity index 100% rename from Dashboard/Models/ServerConnection.cs rename to deprecated/Dashboard/Models/ServerConnection.cs diff --git a/Dashboard/Models/ServerHealthStatus.cs b/deprecated/Dashboard/Models/ServerHealthStatus.cs similarity index 100% rename from Dashboard/Models/ServerHealthStatus.cs rename to deprecated/Dashboard/Models/ServerHealthStatus.cs diff --git a/Dashboard/Models/ServerListItem.cs b/deprecated/Dashboard/Models/ServerListItem.cs similarity index 100% rename from Dashboard/Models/ServerListItem.cs rename to deprecated/Dashboard/Models/ServerListItem.cs diff --git a/Dashboard/Models/SessionStatsItem.cs b/deprecated/Dashboard/Models/SessionStatsItem.cs similarity index 100% rename from Dashboard/Models/SessionStatsItem.cs rename to deprecated/Dashboard/Models/SessionStatsItem.cs diff --git a/Dashboard/Models/SpinlockStatsItem.cs b/deprecated/Dashboard/Models/SpinlockStatsItem.cs similarity index 100% rename from Dashboard/Models/SpinlockStatsItem.cs rename to deprecated/Dashboard/Models/SpinlockStatsItem.cs diff --git a/Dashboard/Models/TempdbStatsItem.cs b/deprecated/Dashboard/Models/TempdbStatsItem.cs similarity index 100% rename from Dashboard/Models/TempdbStatsItem.cs rename to deprecated/Dashboard/Models/TempdbStatsItem.cs diff --git a/Dashboard/Models/TraceAnalysisItem.cs b/deprecated/Dashboard/Models/TraceAnalysisItem.cs similarity index 100% rename from Dashboard/Models/TraceAnalysisItem.cs rename to deprecated/Dashboard/Models/TraceAnalysisItem.cs diff --git a/Dashboard/Models/TraceFlagChangeItem.cs b/deprecated/Dashboard/Models/TraceFlagChangeItem.cs similarity index 100% rename from Dashboard/Models/TraceFlagChangeItem.cs rename to deprecated/Dashboard/Models/TraceFlagChangeItem.cs diff --git a/Dashboard/Models/TracePatternDetailItem.cs b/deprecated/Dashboard/Models/TracePatternDetailItem.cs similarity index 100% rename from Dashboard/Models/TracePatternDetailItem.cs rename to deprecated/Dashboard/Models/TracePatternDetailItem.cs diff --git a/Dashboard/Models/UserPreferences.cs b/deprecated/Dashboard/Models/UserPreferences.cs similarity index 100% rename from Dashboard/Models/UserPreferences.cs rename to deprecated/Dashboard/Models/UserPreferences.cs diff --git a/Dashboard/Models/WaitStatItem.cs b/deprecated/Dashboard/Models/WaitStatItem.cs similarity index 100% rename from Dashboard/Models/WaitStatItem.cs rename to deprecated/Dashboard/Models/WaitStatItem.cs diff --git a/Dashboard/Models/WaitStatsDataPoint.cs b/deprecated/Dashboard/Models/WaitStatsDataPoint.cs similarity index 100% rename from Dashboard/Models/WaitStatsDataPoint.cs rename to deprecated/Dashboard/Models/WaitStatsDataPoint.cs diff --git a/Dashboard/Models/WaitingTaskTrendItem.cs b/deprecated/Dashboard/Models/WaitingTaskTrendItem.cs similarity index 100% rename from Dashboard/Models/WaitingTaskTrendItem.cs rename to deprecated/Dashboard/Models/WaitingTaskTrendItem.cs diff --git a/Dashboard/MuteRuleDialog.xaml b/deprecated/Dashboard/MuteRuleDialog.xaml similarity index 100% rename from Dashboard/MuteRuleDialog.xaml rename to deprecated/Dashboard/MuteRuleDialog.xaml diff --git a/Dashboard/MuteRuleDialog.xaml.cs b/deprecated/Dashboard/MuteRuleDialog.xaml.cs similarity index 100% rename from Dashboard/MuteRuleDialog.xaml.cs rename to deprecated/Dashboard/MuteRuleDialog.xaml.cs diff --git a/Dashboard/PlanViewerWindow.xaml b/deprecated/Dashboard/PlanViewerWindow.xaml similarity index 100% rename from Dashboard/PlanViewerWindow.xaml rename to deprecated/Dashboard/PlanViewerWindow.xaml diff --git a/Dashboard/PlanViewerWindow.xaml.cs b/deprecated/Dashboard/PlanViewerWindow.xaml.cs similarity index 100% rename from Dashboard/PlanViewerWindow.xaml.cs rename to deprecated/Dashboard/PlanViewerWindow.xaml.cs diff --git a/Dashboard/ProcedureHistoryWindow.xaml b/deprecated/Dashboard/ProcedureHistoryWindow.xaml similarity index 100% rename from Dashboard/ProcedureHistoryWindow.xaml rename to deprecated/Dashboard/ProcedureHistoryWindow.xaml diff --git a/Dashboard/ProcedureHistoryWindow.xaml.cs b/deprecated/Dashboard/ProcedureHistoryWindow.xaml.cs similarity index 100% rename from Dashboard/ProcedureHistoryWindow.xaml.cs rename to deprecated/Dashboard/ProcedureHistoryWindow.xaml.cs diff --git a/Dashboard/Program.cs b/deprecated/Dashboard/Program.cs similarity index 100% rename from Dashboard/Program.cs rename to deprecated/Dashboard/Program.cs diff --git a/Dashboard/PurgeNowDialog.xaml b/deprecated/Dashboard/PurgeNowDialog.xaml similarity index 100% rename from Dashboard/PurgeNowDialog.xaml rename to deprecated/Dashboard/PurgeNowDialog.xaml diff --git a/Dashboard/PurgeNowDialog.xaml.cs b/deprecated/Dashboard/PurgeNowDialog.xaml.cs similarity index 100% rename from Dashboard/PurgeNowDialog.xaml.cs rename to deprecated/Dashboard/PurgeNowDialog.xaml.cs diff --git a/Dashboard/QueryExecutionHistoryWindow.xaml b/deprecated/Dashboard/QueryExecutionHistoryWindow.xaml similarity index 100% rename from Dashboard/QueryExecutionHistoryWindow.xaml rename to deprecated/Dashboard/QueryExecutionHistoryWindow.xaml diff --git a/Dashboard/QueryExecutionHistoryWindow.xaml.cs b/deprecated/Dashboard/QueryExecutionHistoryWindow.xaml.cs similarity index 100% rename from Dashboard/QueryExecutionHistoryWindow.xaml.cs rename to deprecated/Dashboard/QueryExecutionHistoryWindow.xaml.cs diff --git a/Dashboard/QueryStatsHistoryWindow.xaml b/deprecated/Dashboard/QueryStatsHistoryWindow.xaml similarity index 100% rename from Dashboard/QueryStatsHistoryWindow.xaml rename to deprecated/Dashboard/QueryStatsHistoryWindow.xaml diff --git a/Dashboard/QueryStatsHistoryWindow.xaml.cs b/deprecated/Dashboard/QueryStatsHistoryWindow.xaml.cs similarity index 100% rename from Dashboard/QueryStatsHistoryWindow.xaml.cs rename to deprecated/Dashboard/QueryStatsHistoryWindow.xaml.cs diff --git a/Dashboard/README.md b/deprecated/Dashboard/README.md similarity index 100% rename from Dashboard/README.md rename to deprecated/Dashboard/README.md diff --git a/Dashboard/RemediationConfirmWindow.xaml b/deprecated/Dashboard/RemediationConfirmWindow.xaml similarity index 100% rename from Dashboard/RemediationConfirmWindow.xaml rename to deprecated/Dashboard/RemediationConfirmWindow.xaml diff --git a/Dashboard/RemediationConfirmWindow.xaml.cs b/deprecated/Dashboard/RemediationConfirmWindow.xaml.cs similarity index 100% rename from Dashboard/RemediationConfirmWindow.xaml.cs rename to deprecated/Dashboard/RemediationConfirmWindow.xaml.cs diff --git a/Dashboard/RemoveServerDialog.xaml b/deprecated/Dashboard/RemoveServerDialog.xaml similarity index 100% rename from Dashboard/RemoveServerDialog.xaml rename to deprecated/Dashboard/RemoveServerDialog.xaml diff --git a/Dashboard/RemoveServerDialog.xaml.cs b/deprecated/Dashboard/RemoveServerDialog.xaml.cs similarity index 100% rename from Dashboard/RemoveServerDialog.xaml.cs rename to deprecated/Dashboard/RemoveServerDialog.xaml.cs diff --git a/Dashboard/ServerTab.Alerts.cs b/deprecated/Dashboard/ServerTab.Alerts.cs similarity index 100% rename from Dashboard/ServerTab.Alerts.cs rename to deprecated/Dashboard/ServerTab.Alerts.cs diff --git a/Dashboard/ServerTab.BlockChain.cs b/deprecated/Dashboard/ServerTab.BlockChain.cs similarity index 100% rename from Dashboard/ServerTab.BlockChain.cs rename to deprecated/Dashboard/ServerTab.BlockChain.cs diff --git a/Dashboard/ServerTab.Charts.cs b/deprecated/Dashboard/ServerTab.Charts.cs similarity index 100% rename from Dashboard/ServerTab.Charts.cs rename to deprecated/Dashboard/ServerTab.Charts.cs diff --git a/Dashboard/ServerTab.CopyExport.cs b/deprecated/Dashboard/ServerTab.CopyExport.cs similarity index 100% rename from Dashboard/ServerTab.CopyExport.cs rename to deprecated/Dashboard/ServerTab.CopyExport.cs diff --git a/Dashboard/ServerTab.Deadlock.cs b/deprecated/Dashboard/ServerTab.Deadlock.cs similarity index 100% rename from Dashboard/ServerTab.Deadlock.cs rename to deprecated/Dashboard/ServerTab.Deadlock.cs diff --git a/Dashboard/ServerTab.DrillDown.cs b/deprecated/Dashboard/ServerTab.DrillDown.cs similarity index 100% rename from Dashboard/ServerTab.DrillDown.cs rename to deprecated/Dashboard/ServerTab.DrillDown.cs diff --git a/Dashboard/ServerTab.Filters.cs b/deprecated/Dashboard/ServerTab.Filters.cs similarity index 100% rename from Dashboard/ServerTab.Filters.cs rename to deprecated/Dashboard/ServerTab.Filters.cs diff --git a/Dashboard/ServerTab.Plans.cs b/deprecated/Dashboard/ServerTab.Plans.cs similarity index 100% rename from Dashboard/ServerTab.Plans.cs rename to deprecated/Dashboard/ServerTab.Plans.cs diff --git a/Dashboard/ServerTab.Refresh.cs b/deprecated/Dashboard/ServerTab.Refresh.cs similarity index 100% rename from Dashboard/ServerTab.Refresh.cs rename to deprecated/Dashboard/ServerTab.Refresh.cs diff --git a/Dashboard/ServerTab.Slicers.cs b/deprecated/Dashboard/ServerTab.Slicers.cs similarity index 100% rename from Dashboard/ServerTab.Slicers.cs rename to deprecated/Dashboard/ServerTab.Slicers.cs diff --git a/Dashboard/ServerTab.TimeRange.cs b/deprecated/Dashboard/ServerTab.TimeRange.cs similarity index 100% rename from Dashboard/ServerTab.TimeRange.cs rename to deprecated/Dashboard/ServerTab.TimeRange.cs diff --git a/Dashboard/ServerTab.xaml b/deprecated/Dashboard/ServerTab.xaml similarity index 100% rename from Dashboard/ServerTab.xaml rename to deprecated/Dashboard/ServerTab.xaml diff --git a/Dashboard/ServerTab.xaml.cs b/deprecated/Dashboard/ServerTab.xaml.cs similarity index 100% rename from Dashboard/ServerTab.xaml.cs rename to deprecated/Dashboard/ServerTab.xaml.cs diff --git a/Dashboard/Services/AlertStateService.cs b/deprecated/Dashboard/Services/AlertStateService.cs similarity index 100% rename from Dashboard/Services/AlertStateService.cs rename to deprecated/Dashboard/Services/AlertStateService.cs diff --git a/Dashboard/Services/AnalysisScheduler.cs b/deprecated/Dashboard/Services/AnalysisScheduler.cs similarity index 100% rename from Dashboard/Services/AnalysisScheduler.cs rename to deprecated/Dashboard/Services/AnalysisScheduler.cs diff --git a/Dashboard/Services/CredentialService.cs b/deprecated/Dashboard/Services/CredentialService.cs similarity index 100% rename from Dashboard/Services/CredentialService.cs rename to deprecated/Dashboard/Services/CredentialService.cs diff --git a/Dashboard/Services/DashboardAlertCredentials.cs b/deprecated/Dashboard/Services/DashboardAlertCredentials.cs similarity index 100% rename from Dashboard/Services/DashboardAlertCredentials.cs rename to deprecated/Dashboard/Services/DashboardAlertCredentials.cs diff --git a/Dashboard/Services/DashboardAlertSettings.cs b/deprecated/Dashboard/Services/DashboardAlertSettings.cs similarity index 100% rename from Dashboard/Services/DashboardAlertSettings.cs rename to deprecated/Dashboard/Services/DashboardAlertSettings.cs diff --git a/Dashboard/Services/DataGridFilterService.cs b/deprecated/Dashboard/Services/DataGridFilterService.cs similarity index 100% rename from Dashboard/Services/DataGridFilterService.cs rename to deprecated/Dashboard/Services/DataGridFilterService.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.IndexAnalysis.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.IndexAnalysis.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.IndexAnalysis.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.IndexAnalysis.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.IndexObjects.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.Inventory.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.Inventory.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.Inventory.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.Inventory.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.Models.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.Models.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.Models.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.Models.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.Queries.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.Queries.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.Queries.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.Queries.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.Recommendations.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.Recommendations.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.Recommendations.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.Recommendations.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.Storage.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.Storage.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.Storage.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.Storage.cs diff --git a/Dashboard/Services/DatabaseService.FinOps.Workload.cs b/deprecated/Dashboard/Services/DatabaseService.FinOps.Workload.cs similarity index 100% rename from Dashboard/Services/DatabaseService.FinOps.Workload.cs rename to deprecated/Dashboard/Services/DatabaseService.FinOps.Workload.cs diff --git a/Dashboard/Services/DatabaseService.HealthParser.cs b/deprecated/Dashboard/Services/DatabaseService.HealthParser.cs similarity index 100% rename from Dashboard/Services/DatabaseService.HealthParser.cs rename to deprecated/Dashboard/Services/DatabaseService.HealthParser.cs diff --git a/Dashboard/Services/DatabaseService.Memory.cs b/deprecated/Dashboard/Services/DatabaseService.Memory.cs similarity index 100% rename from Dashboard/Services/DatabaseService.Memory.cs rename to deprecated/Dashboard/Services/DatabaseService.Memory.cs diff --git a/Dashboard/Services/DatabaseService.NocHealth.cs b/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs similarity index 100% rename from Dashboard/Services/DatabaseService.NocHealth.cs rename to deprecated/Dashboard/Services/DatabaseService.NocHealth.cs diff --git a/Dashboard/Services/DatabaseService.Overview.cs b/deprecated/Dashboard/Services/DatabaseService.Overview.cs similarity index 100% rename from Dashboard/Services/DatabaseService.Overview.cs rename to deprecated/Dashboard/Services/DatabaseService.Overview.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Blocking.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Blocking.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.Blocking.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Blocking.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Comparison.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Comparison.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.Comparison.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Comparison.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Heatmap.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Heatmap.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.Heatmap.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Heatmap.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.History.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.History.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.History.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.History.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Mcp.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Mcp.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.Mcp.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Mcp.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.PlanXml.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.PlanXml.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.PlanXml.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.PlanXml.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Snapshots.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Stats.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.Trends.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Trends.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.Trends.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.Trends.cs diff --git a/Dashboard/Services/DatabaseService.QueryPerformance.cs b/deprecated/Dashboard/Services/DatabaseService.QueryPerformance.cs similarity index 100% rename from Dashboard/Services/DatabaseService.QueryPerformance.cs rename to deprecated/Dashboard/Services/DatabaseService.QueryPerformance.cs diff --git a/Dashboard/Services/DatabaseService.Remediation.cs b/deprecated/Dashboard/Services/DatabaseService.Remediation.cs similarity index 100% rename from Dashboard/Services/DatabaseService.Remediation.cs rename to deprecated/Dashboard/Services/DatabaseService.Remediation.cs diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.Cpu.cs b/deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Cpu.cs similarity index 100% rename from Dashboard/Services/DatabaseService.ResourceMetrics.Cpu.cs rename to deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Cpu.cs diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.FileIo.cs b/deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.FileIo.cs similarity index 100% rename from Dashboard/Services/DatabaseService.ResourceMetrics.FileIo.cs rename to deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.FileIo.cs diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.LatchSpinlock.cs b/deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.LatchSpinlock.cs similarity index 100% rename from Dashboard/Services/DatabaseService.ResourceMetrics.LatchSpinlock.cs rename to deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.LatchSpinlock.cs diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.Memory.cs b/deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Memory.cs similarity index 100% rename from Dashboard/Services/DatabaseService.ResourceMetrics.Memory.cs rename to deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Memory.cs diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.Perfmon.cs b/deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Perfmon.cs similarity index 100% rename from Dashboard/Services/DatabaseService.ResourceMetrics.Perfmon.cs rename to deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Perfmon.cs diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.Waits.cs b/deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Waits.cs similarity index 100% rename from Dashboard/Services/DatabaseService.ResourceMetrics.Waits.cs rename to deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.Waits.cs diff --git a/Dashboard/Services/DatabaseService.ResourceMetrics.cs b/deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.cs similarity index 100% rename from Dashboard/Services/DatabaseService.ResourceMetrics.cs rename to deprecated/Dashboard/Services/DatabaseService.ResourceMetrics.cs diff --git a/Dashboard/Services/DatabaseService.SystemEvents.cs b/deprecated/Dashboard/Services/DatabaseService.SystemEvents.cs similarity index 100% rename from Dashboard/Services/DatabaseService.SystemEvents.cs rename to deprecated/Dashboard/Services/DatabaseService.SystemEvents.cs diff --git a/Dashboard/Services/DatabaseService.cs b/deprecated/Dashboard/Services/DatabaseService.cs similarity index 100% rename from Dashboard/Services/DatabaseService.cs rename to deprecated/Dashboard/Services/DatabaseService.cs diff --git a/Dashboard/Services/DeadlockAlertClearPolicy.cs b/deprecated/Dashboard/Services/DeadlockAlertClearPolicy.cs similarity index 100% rename from Dashboard/Services/DeadlockAlertClearPolicy.cs rename to deprecated/Dashboard/Services/DeadlockAlertClearPolicy.cs diff --git a/Dashboard/Services/EmailAlertService.cs b/deprecated/Dashboard/Services/EmailAlertService.cs similarity index 100% rename from Dashboard/Services/EmailAlertService.cs rename to deprecated/Dashboard/Services/EmailAlertService.cs diff --git a/Dashboard/Services/JsonAlertHistoryStore.cs b/deprecated/Dashboard/Services/JsonAlertHistoryStore.cs similarity index 100% rename from Dashboard/Services/JsonAlertHistoryStore.cs rename to deprecated/Dashboard/Services/JsonAlertHistoryStore.cs diff --git a/Dashboard/Services/JsonMuteRuleStore.cs b/deprecated/Dashboard/Services/JsonMuteRuleStore.cs similarity index 100% rename from Dashboard/Services/JsonMuteRuleStore.cs rename to deprecated/Dashboard/Services/JsonMuteRuleStore.cs diff --git a/Dashboard/Services/NotificationService.cs b/deprecated/Dashboard/Services/NotificationService.cs similarity index 100% rename from Dashboard/Services/NotificationService.cs rename to deprecated/Dashboard/Services/NotificationService.cs diff --git a/Dashboard/Services/Recommendations/RecommendationDeduper.cs b/deprecated/Dashboard/Services/Recommendations/RecommendationDeduper.cs similarity index 100% rename from Dashboard/Services/Recommendations/RecommendationDeduper.cs rename to deprecated/Dashboard/Services/Recommendations/RecommendationDeduper.cs diff --git a/Dashboard/Services/Recommendations/RecommendationItem.cs b/deprecated/Dashboard/Services/Recommendations/RecommendationItem.cs similarity index 100% rename from Dashboard/Services/Recommendations/RecommendationItem.cs rename to deprecated/Dashboard/Services/Recommendations/RecommendationItem.cs diff --git a/Dashboard/Services/Recommendations/RecommendationsReader.cs b/deprecated/Dashboard/Services/Recommendations/RecommendationsReader.cs similarity index 100% rename from Dashboard/Services/Recommendations/RecommendationsReader.cs rename to deprecated/Dashboard/Services/Recommendations/RecommendationsReader.cs diff --git a/Dashboard/Services/Remediation/AuditWritabilityProbe.cs b/deprecated/Dashboard/Services/Remediation/AuditWritabilityProbe.cs similarity index 100% rename from Dashboard/Services/Remediation/AuditWritabilityProbe.cs rename to deprecated/Dashboard/Services/Remediation/AuditWritabilityProbe.cs diff --git a/Dashboard/Services/Remediation/ClearPlanHandler.cs b/deprecated/Dashboard/Services/Remediation/ClearPlanHandler.cs similarity index 100% rename from Dashboard/Services/Remediation/ClearPlanHandler.cs rename to deprecated/Dashboard/Services/Remediation/ClearPlanHandler.cs diff --git a/Dashboard/Services/Remediation/DatabaseServiceRemediationExecutor.cs b/deprecated/Dashboard/Services/Remediation/DatabaseServiceRemediationExecutor.cs similarity index 100% rename from Dashboard/Services/Remediation/DatabaseServiceRemediationExecutor.cs rename to deprecated/Dashboard/Services/Remediation/DatabaseServiceRemediationExecutor.cs diff --git a/Dashboard/Services/Remediation/DbConfigHandler.cs b/deprecated/Dashboard/Services/Remediation/DbConfigHandler.cs similarity index 100% rename from Dashboard/Services/Remediation/DbConfigHandler.cs rename to deprecated/Dashboard/Services/Remediation/DbConfigHandler.cs diff --git a/Dashboard/Services/Remediation/FileAutogrowthHandler.cs b/deprecated/Dashboard/Services/Remediation/FileAutogrowthHandler.cs similarity index 100% rename from Dashboard/Services/Remediation/FileAutogrowthHandler.cs rename to deprecated/Dashboard/Services/Remediation/FileAutogrowthHandler.cs diff --git a/Dashboard/Services/Remediation/ForcePlanHandler.cs b/deprecated/Dashboard/Services/Remediation/ForcePlanHandler.cs similarity index 100% rename from Dashboard/Services/Remediation/ForcePlanHandler.cs rename to deprecated/Dashboard/Services/Remediation/ForcePlanHandler.cs diff --git a/Dashboard/Services/Remediation/IRemediationExecutor.cs b/deprecated/Dashboard/Services/Remediation/IRemediationExecutor.cs similarity index 100% rename from Dashboard/Services/Remediation/IRemediationExecutor.cs rename to deprecated/Dashboard/Services/Remediation/IRemediationExecutor.cs diff --git a/Dashboard/Services/Remediation/IRemediationHandler.cs b/deprecated/Dashboard/Services/Remediation/IRemediationHandler.cs similarity index 100% rename from Dashboard/Services/Remediation/IRemediationHandler.cs rename to deprecated/Dashboard/Services/Remediation/IRemediationHandler.cs diff --git a/Dashboard/Services/Remediation/RcsiHandler.cs b/deprecated/Dashboard/Services/Remediation/RcsiHandler.cs similarity index 100% rename from Dashboard/Services/Remediation/RcsiHandler.cs rename to deprecated/Dashboard/Services/Remediation/RcsiHandler.cs diff --git a/Dashboard/Services/Remediation/RemediationApplyModels.cs b/deprecated/Dashboard/Services/Remediation/RemediationApplyModels.cs similarity index 100% rename from Dashboard/Services/Remediation/RemediationApplyModels.cs rename to deprecated/Dashboard/Services/Remediation/RemediationApplyModels.cs diff --git a/Dashboard/Services/Remediation/RemediationApplyService.cs b/deprecated/Dashboard/Services/Remediation/RemediationApplyService.cs similarity index 100% rename from Dashboard/Services/Remediation/RemediationApplyService.cs rename to deprecated/Dashboard/Services/Remediation/RemediationApplyService.cs diff --git a/Dashboard/Services/Remediation/RemediationAuditHelpers.cs b/deprecated/Dashboard/Services/Remediation/RemediationAuditHelpers.cs similarity index 100% rename from Dashboard/Services/Remediation/RemediationAuditHelpers.cs rename to deprecated/Dashboard/Services/Remediation/RemediationAuditHelpers.cs diff --git a/Dashboard/Services/Remediation/RemediationAuditWriter.cs b/deprecated/Dashboard/Services/Remediation/RemediationAuditWriter.cs similarity index 100% rename from Dashboard/Services/Remediation/RemediationAuditWriter.cs rename to deprecated/Dashboard/Services/Remediation/RemediationAuditWriter.cs diff --git a/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs b/deprecated/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs similarity index 100% rename from Dashboard/Services/Remediation/RemediationHandlerRegistry.cs rename to deprecated/Dashboard/Services/Remediation/RemediationHandlerRegistry.cs diff --git a/Dashboard/Services/Remediation/RemediationIdentity.cs b/deprecated/Dashboard/Services/Remediation/RemediationIdentity.cs similarity index 100% rename from Dashboard/Services/Remediation/RemediationIdentity.cs rename to deprecated/Dashboard/Services/Remediation/RemediationIdentity.cs diff --git a/Dashboard/Services/Remediation/RemediationResults.cs b/deprecated/Dashboard/Services/Remediation/RemediationResults.cs similarity index 100% rename from Dashboard/Services/Remediation/RemediationResults.cs rename to deprecated/Dashboard/Services/Remediation/RemediationResults.cs diff --git a/Dashboard/Services/Remediation/ServerConfigHandler.cs b/deprecated/Dashboard/Services/Remediation/ServerConfigHandler.cs similarity index 100% rename from Dashboard/Services/Remediation/ServerConfigHandler.cs rename to deprecated/Dashboard/Services/Remediation/ServerConfigHandler.cs diff --git a/Dashboard/Services/ServerManager.cs b/deprecated/Dashboard/Services/ServerManager.cs similarity index 100% rename from Dashboard/Services/ServerManager.cs rename to deprecated/Dashboard/Services/ServerManager.cs diff --git a/Dashboard/Services/UserPreferencesAlertSettings.cs b/deprecated/Dashboard/Services/UserPreferencesAlertSettings.cs similarity index 100% rename from Dashboard/Services/UserPreferencesAlertSettings.cs rename to deprecated/Dashboard/Services/UserPreferencesAlertSettings.cs diff --git a/Dashboard/Services/UserPreferencesService.cs b/deprecated/Dashboard/Services/UserPreferencesService.cs similarity index 100% rename from Dashboard/Services/UserPreferencesService.cs rename to deprecated/Dashboard/Services/UserPreferencesService.cs diff --git a/Dashboard/SettingsWindow.xaml b/deprecated/Dashboard/SettingsWindow.xaml similarity index 100% rename from Dashboard/SettingsWindow.xaml rename to deprecated/Dashboard/SettingsWindow.xaml diff --git a/Dashboard/SettingsWindow.xaml.cs b/deprecated/Dashboard/SettingsWindow.xaml.cs similarity index 100% rename from Dashboard/SettingsWindow.xaml.cs rename to deprecated/Dashboard/SettingsWindow.xaml.cs diff --git a/Dashboard/Themes/CoolBreezeTheme.xaml b/deprecated/Dashboard/Themes/CoolBreezeTheme.xaml similarity index 100% rename from Dashboard/Themes/CoolBreezeTheme.xaml rename to deprecated/Dashboard/Themes/CoolBreezeTheme.xaml diff --git a/Dashboard/Themes/DarkTheme.xaml b/deprecated/Dashboard/Themes/DarkTheme.xaml similarity index 100% rename from Dashboard/Themes/DarkTheme.xaml rename to deprecated/Dashboard/Themes/DarkTheme.xaml diff --git a/Dashboard/Themes/LightTheme.xaml b/deprecated/Dashboard/Themes/LightTheme.xaml similarity index 100% rename from Dashboard/Themes/LightTheme.xaml rename to deprecated/Dashboard/Themes/LightTheme.xaml diff --git a/Dashboard/TracePatternHistoryWindow.xaml b/deprecated/Dashboard/TracePatternHistoryWindow.xaml similarity index 100% rename from Dashboard/TracePatternHistoryWindow.xaml rename to deprecated/Dashboard/TracePatternHistoryWindow.xaml diff --git a/Dashboard/TracePatternHistoryWindow.xaml.cs b/deprecated/Dashboard/TracePatternHistoryWindow.xaml.cs similarity index 100% rename from Dashboard/TracePatternHistoryWindow.xaml.cs rename to deprecated/Dashboard/TracePatternHistoryWindow.xaml.cs diff --git a/Dashboard/WaitDrillDownWindow.xaml b/deprecated/Dashboard/WaitDrillDownWindow.xaml similarity index 100% rename from Dashboard/WaitDrillDownWindow.xaml rename to deprecated/Dashboard/WaitDrillDownWindow.xaml diff --git a/Dashboard/WaitDrillDownWindow.xaml.cs b/deprecated/Dashboard/WaitDrillDownWindow.xaml.cs similarity index 100% rename from Dashboard/WaitDrillDownWindow.xaml.cs rename to deprecated/Dashboard/WaitDrillDownWindow.xaml.cs diff --git a/Dashboard/app.manifest b/deprecated/Dashboard/app.manifest similarity index 100% rename from Dashboard/app.manifest rename to deprecated/Dashboard/app.manifest diff --git a/Dashboard/packages.lock.json b/deprecated/Dashboard/packages.lock.json similarity index 100% rename from Dashboard/packages.lock.json rename to deprecated/Dashboard/packages.lock.json diff --git a/Dashboard/schema/tables.json b/deprecated/Dashboard/schema/tables.json similarity index 100% rename from Dashboard/schema/tables.json rename to deprecated/Dashboard/schema/tables.json diff --git a/Installer.Core/DependencyInstaller.cs b/deprecated/Installer.Core/DependencyInstaller.cs similarity index 100% rename from Installer.Core/DependencyInstaller.cs rename to deprecated/Installer.Core/DependencyInstaller.cs diff --git a/Installer.Core/InstallGuard.cs b/deprecated/Installer.Core/InstallGuard.cs similarity index 100% rename from Installer.Core/InstallGuard.cs rename to deprecated/Installer.Core/InstallGuard.cs diff --git a/Installer.Core/InstallationService.cs b/deprecated/Installer.Core/InstallationService.cs similarity index 100% rename from Installer.Core/InstallationService.cs rename to deprecated/Installer.Core/InstallationService.cs diff --git a/Installer.Core/InstalledVersionClassifier.cs b/deprecated/Installer.Core/InstalledVersionClassifier.cs similarity index 100% rename from Installer.Core/InstalledVersionClassifier.cs rename to deprecated/Installer.Core/InstalledVersionClassifier.cs diff --git a/Installer.Core/Installer.Core.csproj b/deprecated/Installer.Core/Installer.Core.csproj similarity index 86% rename from Installer.Core/Installer.Core.csproj rename to deprecated/Installer.Core/Installer.Core.csproj index 882fe52ef..e40fc7a11 100644 --- a/Installer.Core/Installer.Core.csproj +++ b/deprecated/Installer.Core/Installer.Core.csproj @@ -25,8 +25,8 @@ - - + + diff --git a/Installer.Core/Models/InstallationProgress.cs b/deprecated/Installer.Core/Models/InstallationProgress.cs similarity index 100% rename from Installer.Core/Models/InstallationProgress.cs rename to deprecated/Installer.Core/Models/InstallationProgress.cs diff --git a/Installer.Core/Models/InstallationResult.cs b/deprecated/Installer.Core/Models/InstallationResult.cs similarity index 100% rename from Installer.Core/Models/InstallationResult.cs rename to deprecated/Installer.Core/Models/InstallationResult.cs diff --git a/Installer.Core/Models/InstallationResultCode.cs b/deprecated/Installer.Core/Models/InstallationResultCode.cs similarity index 100% rename from Installer.Core/Models/InstallationResultCode.cs rename to deprecated/Installer.Core/Models/InstallationResultCode.cs diff --git a/Installer.Core/Models/ServerInfo.cs b/deprecated/Installer.Core/Models/ServerInfo.cs similarity index 100% rename from Installer.Core/Models/ServerInfo.cs rename to deprecated/Installer.Core/Models/ServerInfo.cs diff --git a/Installer.Core/Models/UpgradeInfo.cs b/deprecated/Installer.Core/Models/UpgradeInfo.cs similarity index 100% rename from Installer.Core/Models/UpgradeInfo.cs rename to deprecated/Installer.Core/Models/UpgradeInfo.cs diff --git a/Installer.Core/PathValidation.cs b/deprecated/Installer.Core/PathValidation.cs similarity index 100% rename from Installer.Core/PathValidation.cs rename to deprecated/Installer.Core/PathValidation.cs diff --git a/Installer.Core/Patterns.cs b/deprecated/Installer.Core/Patterns.cs similarity index 100% rename from Installer.Core/Patterns.cs rename to deprecated/Installer.Core/Patterns.cs diff --git a/Installer.Core/RepairOutcome.cs b/deprecated/Installer.Core/RepairOutcome.cs similarity index 100% rename from Installer.Core/RepairOutcome.cs rename to deprecated/Installer.Core/RepairOutcome.cs diff --git a/Installer.Core/ScriptProvider.cs b/deprecated/Installer.Core/ScriptProvider.cs similarity index 100% rename from Installer.Core/ScriptProvider.cs rename to deprecated/Installer.Core/ScriptProvider.cs diff --git a/Installer.Core/ServerSetupPolicy.cs b/deprecated/Installer.Core/ServerSetupPolicy.cs similarity index 100% rename from Installer.Core/ServerSetupPolicy.cs rename to deprecated/Installer.Core/ServerSetupPolicy.cs diff --git a/Installer.Core/ServerSetupState.cs b/deprecated/Installer.Core/ServerSetupState.cs similarity index 100% rename from Installer.Core/ServerSetupState.cs rename to deprecated/Installer.Core/ServerSetupState.cs diff --git a/Installer.Core/packages.lock.json b/deprecated/Installer.Core/packages.lock.json similarity index 100% rename from Installer.Core/packages.lock.json rename to deprecated/Installer.Core/packages.lock.json diff --git a/Installer.Tests/AdversarialTests.cs b/deprecated/Installer.Tests/AdversarialTests.cs similarity index 100% rename from Installer.Tests/AdversarialTests.cs rename to deprecated/Installer.Tests/AdversarialTests.cs diff --git a/Installer.Tests/BuildConnectionStringTests.cs b/deprecated/Installer.Tests/BuildConnectionStringTests.cs similarity index 100% rename from Installer.Tests/BuildConnectionStringTests.cs rename to deprecated/Installer.Tests/BuildConnectionStringTests.cs diff --git a/Installer.Tests/CleanInstallCancellationTests.cs b/deprecated/Installer.Tests/CleanInstallCancellationTests.cs similarity index 100% rename from Installer.Tests/CleanInstallCancellationTests.cs rename to deprecated/Installer.Tests/CleanInstallCancellationTests.cs diff --git a/Installer.Tests/FileFilteringTests.cs b/deprecated/Installer.Tests/FileFilteringTests.cs similarity index 100% rename from Installer.Tests/FileFilteringTests.cs rename to deprecated/Installer.Tests/FileFilteringTests.cs diff --git a/Installer.Tests/GlobalUsings.cs b/deprecated/Installer.Tests/GlobalUsings.cs similarity index 100% rename from Installer.Tests/GlobalUsings.cs rename to deprecated/Installer.Tests/GlobalUsings.cs diff --git a/Installer.Tests/Helpers/TempDirectoryBuilder.cs b/deprecated/Installer.Tests/Helpers/TempDirectoryBuilder.cs similarity index 100% rename from Installer.Tests/Helpers/TempDirectoryBuilder.cs rename to deprecated/Installer.Tests/Helpers/TempDirectoryBuilder.cs diff --git a/Installer.Tests/Helpers/TestDatabaseHelper.cs b/deprecated/Installer.Tests/Helpers/TestDatabaseHelper.cs similarity index 100% rename from Installer.Tests/Helpers/TestDatabaseHelper.cs rename to deprecated/Installer.Tests/Helpers/TestDatabaseHelper.cs diff --git a/Installer.Tests/IdempotencyTests.cs b/deprecated/Installer.Tests/IdempotencyTests.cs similarity index 100% rename from Installer.Tests/IdempotencyTests.cs rename to deprecated/Installer.Tests/IdempotencyTests.cs diff --git a/Installer.Tests/InstallGuardTests.cs b/deprecated/Installer.Tests/InstallGuardTests.cs similarity index 100% rename from Installer.Tests/InstallGuardTests.cs rename to deprecated/Installer.Tests/InstallGuardTests.cs diff --git a/Installer.Tests/InstalledVersionClassifierTests.cs b/deprecated/Installer.Tests/InstalledVersionClassifierTests.cs similarity index 100% rename from Installer.Tests/InstalledVersionClassifierTests.cs rename to deprecated/Installer.Tests/InstalledVersionClassifierTests.cs diff --git a/Installer.Tests/Installer.Tests.csproj b/deprecated/Installer.Tests/Installer.Tests.csproj similarity index 100% rename from Installer.Tests/Installer.Tests.csproj rename to deprecated/Installer.Tests/Installer.Tests.csproj diff --git a/Installer.Tests/PathValidationTests.cs b/deprecated/Installer.Tests/PathValidationTests.cs similarity index 100% rename from Installer.Tests/PathValidationTests.cs rename to deprecated/Installer.Tests/PathValidationTests.cs diff --git a/Installer.Tests/RepairOutcomeTests.cs b/deprecated/Installer.Tests/RepairOutcomeTests.cs similarity index 100% rename from Installer.Tests/RepairOutcomeTests.cs rename to deprecated/Installer.Tests/RepairOutcomeTests.cs diff --git a/Installer.Tests/ServerSetupPolicyTests.cs b/deprecated/Installer.Tests/ServerSetupPolicyTests.cs similarity index 100% rename from Installer.Tests/ServerSetupPolicyTests.cs rename to deprecated/Installer.Tests/ServerSetupPolicyTests.cs diff --git a/Installer.Tests/SummaryReportPathTests.cs b/deprecated/Installer.Tests/SummaryReportPathTests.cs similarity index 100% rename from Installer.Tests/SummaryReportPathTests.cs rename to deprecated/Installer.Tests/SummaryReportPathTests.cs diff --git a/Installer.Tests/UpgradeOrderingTests.cs b/deprecated/Installer.Tests/UpgradeOrderingTests.cs similarity index 100% rename from Installer.Tests/UpgradeOrderingTests.cs rename to deprecated/Installer.Tests/UpgradeOrderingTests.cs diff --git a/Installer.Tests/VersionDetectionTests.cs b/deprecated/Installer.Tests/VersionDetectionTests.cs similarity index 100% rename from Installer.Tests/VersionDetectionTests.cs rename to deprecated/Installer.Tests/VersionDetectionTests.cs diff --git a/Installer.Tests/packages.lock.json b/deprecated/Installer.Tests/packages.lock.json similarity index 100% rename from Installer.Tests/packages.lock.json rename to deprecated/Installer.Tests/packages.lock.json diff --git a/Installer/.gitignore b/deprecated/Installer/.gitignore similarity index 100% rename from Installer/.gitignore rename to deprecated/Installer/.gitignore diff --git a/Installer/EDD.ico b/deprecated/Installer/EDD.ico similarity index 100% rename from Installer/EDD.ico rename to deprecated/Installer/EDD.ico diff --git a/Installer/PerformanceMonitorInstaller.csproj b/deprecated/Installer/PerformanceMonitorInstaller.csproj similarity index 100% rename from Installer/PerformanceMonitorInstaller.csproj rename to deprecated/Installer/PerformanceMonitorInstaller.csproj diff --git a/Installer/Program.cs b/deprecated/Installer/Program.cs similarity index 100% rename from Installer/Program.cs rename to deprecated/Installer/Program.cs diff --git a/Installer/README.md b/deprecated/Installer/README.md similarity index 100% rename from Installer/README.md rename to deprecated/Installer/README.md diff --git a/Installer/packages.lock.json b/deprecated/Installer/packages.lock.json similarity index 100% rename from Installer/packages.lock.json rename to deprecated/Installer/packages.lock.json From b7f3c480dd44f82ca31f1a1d85251bdbd5fce1d4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:54:56 -0400 Subject: [PATCH 002/296] CHANGELOG: note Full Dashboard + CLI Installer deprecation (#1612) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae7fb5f0f..a4ad4199e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Full Dashboard and the CLI Installer are being retired.** They moved to a `deprecated/` folder and are no longer built into release artifacts; releases now ship Lite + Darling only, with Darling the flagship replacement for the Full Dashboard. The deprecated code still compiles and its tests run in CI. ([#1612]) + ## [3.2.0] - 2026-07-21 ### Added @@ -494,6 +498,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1599]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1599 [#1608]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1608 [#1609]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1609 +[#1612]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1612 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 From 74d8228f2e055607a378815c9c89254862727319 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:59:19 -0400 Subject: [PATCH 003/296] nightly.yml: strip Full Dashboard + CLI Installer from the nightly pre-release too Co-Authored-By: Claude Opus 4.8 --- .github/workflows/nightly.yml | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2621bb0bd..f067b446a 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -53,7 +53,7 @@ jobs: id: version shell: pwsh run: | - $base = ([xml](Get-Content deprecated/Dashboard/Dashboard.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } + $base = ([xml](Get-Content Lite/PerformanceMonitorLite.csproj)).Project.PropertyGroup.Version | Where-Object { $_ } $date = Get-Date -Format "yyyyMMdd" $nightly = "$base-nightly.$date" echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT @@ -61,24 +61,16 @@ jobs: - name: Restore dependencies run: | - dotnet restore deprecated/Dashboard/Dashboard.csproj --locked-mode dotnet restore Lite/PerformanceMonitorLite.csproj --locked-mode - dotnet restore deprecated/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 - - name: Publish Dashboard - run: dotnet publish deprecated/Dashboard/Dashboard.csproj -c Release -o publish/Dashboard - - name: Publish Lite run: dotnet publish Lite/PerformanceMonitorLite.csproj -c Release -o publish/Lite - - name: Publish CLI Installer - run: dotnet publish deprecated/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 @@ -113,23 +105,8 @@ jobs: $version = "${{ steps.version.outputs.VERSION }}" New-Item -ItemType Directory -Force -Path releases - Compress-Archive -Path 'publish/Dashboard/*' -DestinationPath "releases/PerformanceMonitorDashboard-$version.zip" -Force Compress-Archive -Path 'publish/Lite/*' -DestinationPath "releases/PerformanceMonitorLite-$version.zip" -Force - $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 'deprecated/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 } - if (Test-Path 'README.md') { Copy-Item 'README.md' $instDir } - if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' $instDir } - if (Test-Path 'THIRD_PARTY_NOTICES.md') { Copy-Item 'THIRD_PARTY_NOTICES.md' $instDir } - - 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 From a28f0fb8a799d607fec400ba0fb6e501780fd0c7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:56:52 -0400 Subject: [PATCH 004/296] Fix install-darling.ps1 shortcut step failing under SYSTEM/non-interactive (SSM) context GetFolderPath('Desktop'/'StartMenu') returns '' when there is no loaded user profile (SSM/remote-exec/SYSTEM), and Join-Path then throws, failing the shortcut step AFTER the real upgrade work had already succeeded. Guard it: build only the targets whose folder resolves, and skip cleanly (with a note) otherwise. Found upgrading a prod box to v3.2.0 over SSM. Co-Authored-By: Claude Opus 4.8 --- Darling/tools/install-darling.ps1 | 33 ++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/Darling/tools/install-darling.ps1 b/Darling/tools/install-darling.ps1 index 7ad248131..908669c97 100644 --- a/Darling/tools/install-darling.ps1 +++ b/Darling/tools/install-darling.ps1 @@ -140,19 +140,28 @@ if ($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() + # GetFolderPath returns '' for Desktop/StartMenu in a non-interactive / SYSTEM context (e.g. an + # SSM or remote-exec session with no loaded user profile), and Join-Path then throws. Guard it: + # keep only the shortcut targets whose folder actually resolves, and skip cleanly otherwise. + $desktop = [Environment]::GetFolderPath('Desktop') + $startMenu = [Environment]::GetFolderPath('StartMenu') + $targets = @() + if ($desktop) { $targets += (Join-Path $desktop 'Darling Viewer.lnk') } + if ($startMenu) { $targets += (Join-Path $startMenu 'Programs\Darling Viewer.lnk') } + if ($targets.Count -gt 0) { + $shell = New-Object -ComObject WScript.Shell + 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 ($($targets.Count)). Pin to taskbar from the Start Menu entry if wanted." + } + else { + Write-Host 'No interactive Desktop/Start Menu (non-interactive or SYSTEM context) - skipping viewer shortcuts.' -ForegroundColor Yellow } - 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 From aa735e7f5eea2d87acaa63cfb6657b2ccc6a1683 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:57:58 -0400 Subject: [PATCH 005/296] CHANGELOG: install-darling.ps1 SSM shortcut fix (#1613) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ad4199e..405e5e412 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Full Dashboard and the CLI Installer are being retired.** They moved to a `deprecated/` folder and are no longer built into release artifacts; releases now ship Lite + Darling only, with Darling the flagship replacement for the Full Dashboard. The deprecated code still compiles and its tests run in CI. ([#1612]) +### Fixed + +- `install-darling.ps1`: the viewer-shortcut step no longer throws when run in a non-interactive / SYSTEM context (SSM, remote-exec) where `GetFolderPath('Desktop'/'StartMenu')` returns empty. It creates only the shortcuts whose folder resolves and skips cleanly otherwise. ([#1613]) + ## [3.2.0] - 2026-07-21 ### Added @@ -499,6 +503,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1608]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1608 [#1609]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1609 [#1612]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1612 +[#1613]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1613 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 From b0b419942f6fcba576dc29d69d24df81fee4723b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:00:22 -0400 Subject: [PATCH 006/296] install-darling.ps1: generalize the shortcut-guard rationale (not SSM-specific) The empty GetFolderPath is any no-user-profile context (service, scheduled task, CI, remote session); SSM was just how it surfaced. Guard is unchanged. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- Darling/tools/install-darling.ps1 | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 405e5e412..b8ebca51a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `install-darling.ps1`: the viewer-shortcut step no longer throws when run in a non-interactive / SYSTEM context (SSM, remote-exec) where `GetFolderPath('Desktop'/'StartMenu')` returns empty. It creates only the shortcuts whose folder resolves and skips cleanly otherwise. ([#1613]) +- `install-darling.ps1`: the viewer-shortcut step no longer throws when run with no loaded user profile (a Windows service, scheduled task, CI runner, or remote session) where `GetFolderPath('Desktop'/'StartMenu')` returns empty. It creates only the shortcuts whose folder resolves and skips cleanly otherwise. ([#1613]) ## [3.2.0] - 2026-07-21 diff --git a/Darling/tools/install-darling.ps1 b/Darling/tools/install-darling.ps1 index 908669c97..80066d3c5 100644 --- a/Darling/tools/install-darling.ps1 +++ b/Darling/tools/install-darling.ps1 @@ -140,9 +140,9 @@ if ($Network) { # -- 6. Viewer shortcuts -------------------------------------------------------------------------- if (-not $NoShortcuts) { if (Test-Path $viewerExe) { - # GetFolderPath returns '' for Desktop/StartMenu in a non-interactive / SYSTEM context (e.g. an - # SSM or remote-exec session with no loaded user profile), and Join-Path then throws. Guard it: - # keep only the shortcut targets whose folder actually resolves, and skip cleanly otherwise. + # GetFolderPath returns '' for Desktop/StartMenu whenever there is no loaded user profile - a + # Windows service, a scheduled task, a CI runner, a remote/SSM session - and Join-Path then + # throws. Guard it: keep only the targets whose folder resolves, and skip cleanly otherwise. $desktop = [Environment]::GetFolderPath('Desktop') $startMenu = [Environment]::GetFolderPath('StartMenu') $targets = @() From 9f24ab7fea4ec38021f603028495b0461b6a388d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:38:30 -0400 Subject: [PATCH 007/296] Darling: strip embedded NULs before Postgres COPY (#1614) SQL Server NVARCHAR allows embedded NUL (0x00) characters and query text from sys.dm_exec_sql_text sometimes carries them, but Postgres text columns reject the byte, so one NUL-laden cached query failed the entire query_stats COPY batch every cycle with 22021 "invalid byte sequence for encoding UTF8: 0x00". DBCC FREEPROCCACHE never helped because the app re-caches the same query immediately. Every collector string funnels through PgCollectorRowWriter.Value(string?), so stripping NULs there covers all collected text: query text, plan XML, deadlock and blocked-process XML included. String.Replace returns the original instance when nothing matches, so clean strings do not allocate. Verified against a live Postgres: without the strip the E2E COPY test fails with the exact reported 22021; with it the NUL-laden text round-trips stored clean. Closes #1614 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 +++++ Darling/Darling.Tests/PgCopyWriterTests.cs | 16 ++++++++++++++-- .../PgCollectorRowWriter.cs | 11 ++++++++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ad4199e..768bb06d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Full Dashboard and the CLI Installer are being retired.** They moved to a `deprecated/` folder and are no longer built into release artifacts; releases now ship Lite + Darling only, with Darling the flagship replacement for the Full Dashboard. The deprecated code still compiles and its tests run in CI. ([#1612]) +### Fixed + +- **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. + ## [3.2.0] - 2026-07-21 ### Added @@ -499,6 +503,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1608]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1608 [#1609]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1609 [#1612]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1612 +[#1614]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1614 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 diff --git a/Darling/Darling.Tests/PgCopyWriterTests.cs b/Darling/Darling.Tests/PgCopyWriterTests.cs index a3f7fe008..3cd413915 100644 --- a/Darling/Darling.Tests/PgCopyWriterTests.cs +++ b/Darling/Darling.Tests/PgCopyWriterTests.cs @@ -82,6 +82,16 @@ public void MigrationScripts_StartAtV1_StrictlyIncreasing_V1CreatesEveryTable() } } + [Fact] + public void StripEmbeddedNuls_RemovesNulsPostgresRejects_LeavesCleanTextAlone() + { + /* SQL Server NVARCHAR allows embedded NUL; Postgres text rejects it with 22021 (#1614). */ + Assert.Equal("SELECT 1;", PgCollectorRowWriter.StripEmbeddedNuls("SELECT 1;")); + Assert.Equal("SELECT 1;", PgCollectorRowWriter.StripEmbeddedNuls("SELECT\0 1;\0")); + Assert.Equal(string.Empty, PgCollectorRowWriter.StripEmbeddedNuls("\0\0")); + Assert.Equal(string.Empty, PgCollectorRowWriter.StripEmbeddedNuls(string.Empty)); + } + [Fact] public async Task EndToEnd_MigrateCopyReadBack_AgainstDevPostgres() { @@ -96,13 +106,15 @@ public async Task EndToEnd_MigrateCopyReadBack_AgainstDevPostgres() 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. */ + /* COPY one deadlocks row through the definition's own WritePayload. The victim text + carries an embedded NUL — the #1614 repro: SQL Server NVARCHAR allows it, Postgres + rejects the raw byte with 22021, and the writer must strip it or this COPY fails. */ var collectionTime = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); var row = new DeadlocksCollector.Row { DeadlockTime = collectionTime, VictimProcessId = "process-e2e", - VictimSqlText = "UPDATE t SET x = 1;", + VictimSqlText = "UPDATE t SET x = 1;\0", GraphXml = "", }; diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs b/Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs index 175839073..1c7a70b67 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/PgCollectorRowWriter.cs @@ -85,9 +85,18 @@ public static string CopyCommandFor(ICollectorSchemaInfo schema) /// Naive-UTC storage: strip the Kind so Npgsql accepts the value for `timestamp`. private static DateTime Naive(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Unspecified); + /// + /// Postgres `text` cannot hold NUL (0x00), which SQL Server NVARCHAR allows — one NUL-laden + /// query text from dm_exec_sql_text fails the whole COPY batch with 22021 "invalid byte + /// sequence for encoding UTF8: 0x00" (#1614). Every collector string funnels through + /// , so stripping here covers all of them. String.Replace returns + /// the original instance when nothing matches, so clean strings (the vast majority) don't allocate. + /// + public static string StripEmbeddedNuls(string value) => value.Replace("\0", string.Empty); + public ICollectorRowWriter Value(string? value) { - if (value is null) { Target.WriteNull(); } else { Target.Write(value, NpgsqlDbType.Text); } + if (value is null) { Target.WriteNull(); } else { Target.Write(StripEmbeddedNuls(value), NpgsqlDbType.Text); } return this; } From 6f46af985543c47cd2e3335071984cf7a7da0e39 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:46:58 -0400 Subject: [PATCH 008/296] docs(Darling): add 'verify it's reachable' recipe for LAN MCP/web endpoints Enabling an endpoint isn't the same as reaching it. Documents the two failures that read as 'enabled but broken': the listener still loopback-bound after a restart/reinstall (re-run --enable-mcp elevated), and the client pointed at localhost from off-box. Prompted by a client agent unable to reach MCP after a v3.2.0 reinstall. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 ++++ Darling/README.md | 13 +++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ad4199e..a9ddabf8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Full Dashboard and the CLI Installer are being retired.** They moved to a `deprecated/` folder and are no longer built into release artifacts; releases now ship Lite + Darling only, with Darling the flagship replacement for the Full Dashboard. The deprecated code still compiles and its tests run in CI. ([#1612]) +### Documentation + +- Darling README: added a "verify it's actually reachable" recipe for the opt-in LAN MCP/web endpoints — confirm the listener is on the LAN address (not loopback), the scoped firewall rule covers the client, and the client connects to the box IP rather than `localhost` — plus what to re-run after a reinstall. Enabling an endpoint is not the same as reaching it. ([#1614]) + ## [3.2.0] - 2026-07-21 ### Added diff --git a/Darling/README.md b/Darling/README.md index 238b67be0..6dd781c13 100644 --- a/Darling/README.md +++ b/Darling/README.md @@ -627,6 +627,19 @@ 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. +### Verify it's actually reachable (and the two failures that look like bugs) + +Enabling an endpoint is **not** the same as reaching it, and both common failures leave the store flag reading `true`, so "it says enabled" is not proof. After `--enable-mcp` / `--enable-web`, verify on the **service host**: + +1. **The listener is on the LAN address, not loopback.** `Get-NetTCPConnection -State Listen | Where-Object LocalPort -eq 5152` (or `5153` for web) must show the box's LAN IP, e.g. `10.0.0.5:5152` — **not** only `::1` / `127.0.0.1`. *Enabled but still loopback-bound* is the single most common failure: the store flag is on, but the endpoint never re-bound to the LAN address in the `network` block. Re-running `--enable-mcp` **elevated** re-applies the `listen` and re-binds it. +2. **The scoped firewall rule exists and covers the client.** `Get-NetFirewallRule -DisplayName 'PerformanceMonitor Darling MCP (port 5152)'` (or `... Web (port 5153)`) should be `Enabled=True, Action=Allow`, scoped to the `network.allowFrom` CIDR. A non-elevated `--enable-*` skips this and prints the exact command to run by hand. + +Then from the **client** host: + +3. **Connect to the box's LAN IP, never `localhost`.** Use `http://:5152/`. `localhost` / `127.0.0.1` only resolves *on the box itself*, so an off-box MCP client pointed at localhost fails silently — this is the number-one "MCP won't connect" cause. Send `Authorization: Bearer ` (the `network.token`), and do a **fresh** `initialize` + `tools/list` rather than trusting a cached tool list from a previous version. + +**After a reinstall:** the installer replaces binaries but does **not** touch `darling.json` (the zip ships only `darling.sample.json`) or the store, so the `network` block and both live flags survive the upgrade. If MCP stops connecting afterward it is almost always failure 1 (the restart left it loopback-bound) or failure 3 (the client pointed at `localhost`), **not** lost config — re-run `--enable-mcp` **elevated** to re-apply the LAN listen and firewall; a full `--configure-network` re-run is only needed if the `network` block itself is gone. + ### Store endpoint (viewer over the LAN) Add a `network` block to `postgres` (managed mode): From 63b72f440853ba936e4c2d403ad96028cfda7fb3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 06:47:26 -0400 Subject: [PATCH 009/296] CHANGELOG: fix PR ref (#1616) + link for the Darling docs recipe Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9ddabf8d..3330eeeb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Documentation -- Darling README: added a "verify it's actually reachable" recipe for the opt-in LAN MCP/web endpoints — confirm the listener is on the LAN address (not loopback), the scoped firewall rule covers the client, and the client connects to the box IP rather than `localhost` — plus what to re-run after a reinstall. Enabling an endpoint is not the same as reaching it. ([#1614]) +- Darling README: added a "verify it's actually reachable" recipe for the opt-in LAN MCP/web endpoints — confirm the listener is on the LAN address (not loopback), the scoped firewall rule covers the client, and the client connects to the box IP rather than `localhost` — plus what to re-run after a reinstall. Enabling an endpoint is not the same as reaching it. ([#1616]) ## [3.2.0] - 2026-07-21 @@ -503,6 +503,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1608]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1608 [#1609]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1609 [#1612]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1612 +[#1616]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1616 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 From cd7742c43a5af2a7feb1d170f7b0babcf81fed68 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:22:35 -0400 Subject: [PATCH 010/296] Darling: adaptive 'auto' time bucket + persist view scope across refresh Compose time-series panels can use timeBucket:"auto", resolved to a concrete grain from the panel's window (minute <=2d, hour <=60d, day beyond) so any range from 1h to 90d renders a readable line and never trips the 5000-bucket ceiling. The composer defaults new time-series panels to auto; the MCP catalog recommends it. Also fix the rendered view resetting the picked range/server/filters every 60s (the background refresh rebuilt state from the view default): a per-view scope memory now survives the re-render. Co-Authored-By: Claude Opus 4.8 --- Darling/Darling.Tests/DarlingComposeTests.cs | 33 ++++++++++++++++ .../Compose/ComposeCompiler.cs | 10 +++-- .../Compose/MeasureCatalog.cs | 25 ++++++++++++ .../Mcp/DarlingMcpCustomViewTools.cs | 3 +- .../wwwroot/js/editor.js | 12 +++--- .../wwwroot/js/pages/views.js | 38 ++++++++++++++----- 6 files changed, 101 insertions(+), 20 deletions(-) diff --git a/Darling/Darling.Tests/DarlingComposeTests.cs b/Darling/Darling.Tests/DarlingComposeTests.cs index 5b3827933..bc740013e 100644 --- a/Darling/Darling.Tests/DarlingComposeTests.cs +++ b/Darling/Darling.Tests/DarlingComposeTests.cs @@ -364,6 +364,39 @@ public void Compile_Percentile_OnlyRendersForPerEvent() Assert.Contains("percentile_cont(0.95)", sql, StringComparison.Ordinal); } + [Theory] + [InlineData(ComposeTimeBucket.None)] + [InlineData(ComposeTimeBucket.Minute)] + [InlineData(ComposeTimeBucket.Hour)] + [InlineData(ComposeTimeBucket.Day)] + public void ResolveBucket_LeavesNonAutoUnchanged(ComposeTimeBucket bucket) + { + Assert.Equal(bucket, MeasureCatalog.ResolveBucket(bucket, 3600d)); + } + + [Fact] + public void ResolveBucket_Auto_PicksGrainFromWindow() + { + Assert.Equal(ComposeTimeBucket.Minute, MeasureCatalog.ResolveBucket(ComposeTimeBucket.Auto, 2d * 86_400d)); // 2 days -> minute + Assert.Equal(ComposeTimeBucket.Hour, MeasureCatalog.ResolveBucket(ComposeTimeBucket.Auto, 2d * 86_400d + 1d)); // just over 2 days -> hour + Assert.Equal(ComposeTimeBucket.Hour, MeasureCatalog.ResolveBucket(ComposeTimeBucket.Auto, 60d * 86_400d)); // 60 days -> hour + Assert.Equal(ComposeTimeBucket.Day, MeasureCatalog.ResolveBucket(ComposeTimeBucket.Auto, 60d * 86_400d + 1d)); // over 60 days -> day + } + + [Theory] + [InlineData(6, "date_trunc('minute'")] // 6h window -> minute grain + [InlineData(24 * 10, "date_trunc('hour'")] // 10 days -> hour grain + [InlineData(24 * 90, "date_trunc('day'")] // 90 days -> day grain + public void Compile_AutoBucket_ResolvesGrainFromWindow(int windowHours, string expectedTrunc) + { + var plan = ValidPlan("{\"source\":\"wait_stats\",\"measure\":\"wait_time_ms\",\"aggregate\":\"sum\",\"timeBucket\":\"auto\",\"viz\":\"line\"}"); + var end = WindowEnd; + var start = end.AddHours(-windowHours); + var (compiled, error) = ComposeCompiler.Compile(plan, new ComposeRunContext(null, start, end, ComposeRunContext.NoVariables)); + Assert.True(error is null, error); + Assert.Contains(expectedTrunc, compiled!.Sql, StringComparison.Ordinal); + } + [Fact] public void Compile_UnitConversion_ScalesMicrosecondsToMilliseconds() { diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs index 34c622179..47426bad6 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs @@ -88,17 +88,21 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, throw new ArgumentNullException(nameof(context)); } + /* Auto resolves to a concrete grain from the window before anything downstream (ceiling + date_trunc); + a non-Auto bucket passes through unchanged, so existing panels are byte-for-byte identical. */ + var effectiveBucket = plan.TimeBucket; if (plan.Mode == PanelMode.TimeSeries) { var windowSeconds = (context.EndUtc - context.StartUtc).TotalSeconds; - var bucketSeconds = MeasureCatalog.BucketSeconds(plan.TimeBucket); + effectiveBucket = MeasureCatalog.ResolveBucket(plan.TimeBucket, windowSeconds); + var bucketSeconds = MeasureCatalog.BucketSeconds(effectiveBucket); 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 " + + $"the window and '{MeasureCatalog.WireName(effectiveBucket)}' bucket would produce {buckets:0} points " + $"(max {ComposeLimits.MaxBuckets}); choose a coarser bucket or a shorter window."); } } @@ -147,7 +151,7 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, if (plan.Mode == PanelMode.TimeSeries) { - var bucketExpr = $"date_trunc('{MeasureCatalog.DateTruncField(plan.TimeBucket)}', {FactAlias}.{timeColumn})"; + var bucketExpr = $"date_trunc('{MeasureCatalog.DateTruncField(effectiveBucket)}', {FactAlias}.{timeColumn})"; selectExprs.Add(bucketExpr + " AS bucket"); groupExprs.Add(bucketExpr); } diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs index 1affbe04f..ce0ae05f9 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/MeasureCatalog.cs @@ -92,6 +92,11 @@ public enum ComposeTimeBucket Minute, Hour, Day, + + /// Adaptive: the compiler resolves this to a concrete grain (minute/hour/day) from the panel's + /// window at compile time (see ), so any range renders a readable, + /// bounded point count. Never reaches date_trunc as-is. + Auto, } /// The fixed filter-operator vocabulary — each maps to a compile-time SQL operator string @@ -1150,6 +1155,7 @@ private static readonly (ComposeTimeBucket Value, string Wire, int Seconds)[] s_ { (ComposeTimeBucket.None, "none", 0), (ComposeTimeBucket.Minute, "minute", 60), (ComposeTimeBucket.Hour, "hour", 3600), (ComposeTimeBucket.Day, "day", 86_400), + (ComposeTimeBucket.Auto, "auto", 0), }; private static readonly (ComposeFilterOp Value, string Wire)[] s_opWire = @@ -1189,6 +1195,25 @@ public static bool TryParseTimeBucket(string? wire, out ComposeTimeBucket value) /// Seconds per bucket (0 for ) — drives the window×resolution ceiling. public static int BucketSeconds(ComposeTimeBucket value) => s_bucketWire.First(x => x.Value == value).Seconds; + /// Resolve to a concrete grain for a window of + /// : minute up to 2 days, hour up to 60 days, day beyond — so any range + /// yields a readable, bounded point count (≤ ~2880, well under ). + /// Any non-Auto bucket is returned unchanged, so existing panels compile byte-for-byte as before. + public static ComposeTimeBucket ResolveBucket(ComposeTimeBucket value, double windowSeconds) + { + if (value != ComposeTimeBucket.Auto) + { + return value; + } + + if (windowSeconds <= 2d * 86_400d) + { + return ComposeTimeBucket.Minute; + } + + return windowSeconds <= 60d * 86_400d ? ComposeTimeBucket.Hour : ComposeTimeBucket.Day; + } + /// 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 { diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs index 17ba37306..d45904398 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpCustomViewTools.cs @@ -259,7 +259,8 @@ public static async Task RunCustomViewPanel( "'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 " + + "an optional 'timeBucket' (time series; prefer 'auto', which adapts the grain minute/hour/day to the " + + "panel's window so any range renders) 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.")] diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js index 5f46f4401..6a223d725 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/editor.js @@ -131,7 +131,7 @@ export function newComposedPanel() { aggregate: "", unit: "", shape: "timeseries", - timeBucket: "hour", + timeBucket: "auto", topN: 10, filters: [], groupBy: [], @@ -187,7 +187,7 @@ export function descToComposedPanel(d) { aggregate: d.aggregate || "", unit: d.unit || "", shape: hasBucket ? "timeseries" : hasTopN ? "ranked" : "scalar", - timeBucket: hasBucket ? d.timeBucket : "hour", + timeBucket: hasBucket ? d.timeBucket : "auto", topN: hasTopN ? d.topN : 10, filters: Array.isArray(d.filters) ? d.filters.map(descToFilter) : [], groupBy: Array.isArray(d.groupBy) ? d.groupBy.map(String) : [], @@ -236,7 +236,7 @@ export function composedPanelToDesc(p) { 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"; + if (p.shape === "timeseries") d.timeBucket = p.timeBucket || "auto"; 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; @@ -859,7 +859,7 @@ function sampleUnavailableText(res) { 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 BUCKET_LABELS = { minute: "Per minute", hour: "Per hour", day: "Per day", auto: "Auto (fit to range)" }; 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" }; @@ -1040,7 +1040,7 @@ export function buildComposedPanelBody(p, opts) { btn.addEventListener("click", () => { if (p.shape === v) return; p.shape = v; - if (v === "timeseries" && !p.timeBucket) p.timeBucket = "hour"; + if (v === "timeseries" && !p.timeBucket) p.timeBucket = "auto"; 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]; @@ -1056,7 +1056,7 @@ export function buildComposedPanelBody(p, opts) { 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.value = p.timeBucket || "auto"; sel.addEventListener("change", () => { p.timeBucket = sel.value; onLive(); diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js index bb185a9fc..ae00cbce0 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js @@ -252,6 +252,28 @@ function importPanel() { /* ─────────────────────────── renderer ─────────────────────────── */ +/* Per-view scope memory: the picked server / time range / variable values survive the app's 60s background + re-render (app.js refresh() → route() rebuilds the whole view), which otherwise snapped the range back to the + view's default every minute. Keyed by view id, session-lived — a full page reload clears it, restoring the + view's declared default. */ +const viewScopeMemory = new Map(); + +function seedState(id, defaultHours, variables) { + const cached = viewScopeMemory.get(String(id)); + if (cached) { + return { server: cached.server, hours: cached.hours, values: { ...cached.values } }; + } + const state = { server: "All", hours: defaultHours, values: {} }; + for (const v of variables) { + if (v.dimension !== "server" && v.default) state.values[v.name] = v.default; + } + return state; +} + +function rememberScope(id, state) { + viewScopeMemory.set(String(id), { server: state.server, hours: state.hours, values: { ...state.values } }); +} + export async function renderView(main, id) { mount(main, loadingStrip("Loading view…")); @@ -304,10 +326,7 @@ export async function renderView(main, id) { 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; - } + const state = seedState(id, defaultHours, variables); function currentScope() { return { @@ -324,7 +343,8 @@ export async function renderView(main, id) { } /* 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; + const onChange = () => { rememberScope(id, state); renderGrid(); }; + const controls = hasComposed ? buildViewControls(fleet, variables, state, defaultHours, onChange) : null; mount(main, [head, status, controls, gridBox]); renderGrid(); @@ -370,10 +390,7 @@ async function renderNotebookDoc(main, view, session, catalog, fleetRes) { 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; - } + const state = seedState(view.id, defaultHours, variables); function currentScope() { return { @@ -389,7 +406,8 @@ async function renderNotebookDoc(main, view, session, catalog, fleetRes) { mount(docBox, cells.map((cell) => renderCell(cell, readSet, sourceSet, currentScope()))); } - const controls = hasPanels ? buildViewControls(fleet, variables, state, defaultHours, renderDoc) : null; + const onChange = () => { rememberScope(view.id, state); renderDoc(); }; + const controls = hasPanels ? buildViewControls(fleet, variables, state, defaultHours, onChange) : null; mount(main, [head, status, controls, docBox]); renderDoc(); } From 4cde540b8b983851aa1edcb70ba6e0081d7d25ef Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:32:08 -0400 Subject: [PATCH 011/296] Darling: add Web to the --configure-network wizard (#1617) The wizard offered Store and MCP exposure but not the web dashboard, even though --enable-web's own output pointed operators at --configure-network to expose it on the LAN - a dead end that forced hand-editing web.network into darling.json (hit live on a production install on 2026-07-22). Web is now a first-class third surface, fully symmetric with Store/MCP: - Menu: [1] Store [2] MCP [3] Web [4] All (or comma combinations like "1,3") [5] Disable, with a strict TryParseSurfaceChoice parser that rejects the whole input on any unknown token. - GatherWebInputs mirrors GatherMcpInputs: keep an existing web token by default, else generate + DPAPI-protect a fresh one; listen/CIDR loop re-prompts with the resolver's own degrade reason. - Validation is delegated to the real web bind resolver, extracted as DarlingWebHostService.ResolveWebBind (the web twin of ResolveMcpBind, a pure extraction of the inline TryStartServerAsync construction) and re-checked on the reparsed final text before anything is written. - BuildWebNetworkBlock mirrors BuildMcpNetworkBlock; the upsert creates a missing web section for pre-#1562 configs. - One-time token print (MCP first, then web), next steps with the scoped firewall command (the exact WebFirewallRuleName --enable-web reconciles) and the browser login URL ?token= hint, and the web-enabled control-plane note. - Disable removes all three network blocks; the BYO ignored-block offer now also detects a web block. - MCP's printed firewall rule name now comes from McpFirewallRuleName (string-identical before/after) so the wizard and host cannot drift. Docs: README LAN-endpoints + guided-setup sections, install-darling.ps1 -Network help, Program.cs verb comment, and the wizard/editor doc comments now describe all three surfaces. Tests: web wizard flow (token generated, printed once, stored encrypted, login hint), keep-existing-token, "2,3" comma combination, "4" all-three end-to-end, parser theories, disable-removes-web, ResolveWebBind projection pins, web upsert into the sample + missing-parent creation. Real-binary smoke run verified the end-to-end flow and the written block shape. Closes #1617 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 + .../Darling.Tests/DarlingCliCommandsTests.cs | 208 ++++++++++++++- .../DarlingNetworkConfigEditorTests.cs | 59 +++++ .../DarlingWebSupervisorTests.cs | 57 ++++ .../DarlingCliCommands.cs | 248 ++++++++++++++++-- .../DarlingNetworkConfigEditor.cs | 29 +- .../Mcp/DarlingWebHostService.cs | 28 +- .../Program.cs | 11 +- Darling/README.md | 4 +- Darling/tools/install-darling.ps1 | 5 +- 10 files changed, 611 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 768bb06d6..a65eda901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Darling: `--configure-network` can now expose the WEB DASHBOARD** ([#1617]) - the wizard offered Store and MCP but not the web dashboard, even though `--enable-web`'s own output told operators to run `--configure-network` to expose it on the LAN - a dead end that forced hand-editing `web.network` into darling.json. Web is now a first-class third surface, fully symmetric with Store/MCP: its own menu choice (plus comma combinations like `1,3`, and `4` = all three), a keep-or-generate DPAPI access token, listen/CIDR inputs validated by the SAME bind resolver the web host fail-closes on (extracted as `ResolveWebBind`, the web twin of `ResolveMcpBind` - never a reimplementation), the comment-preserving `web.network` write, a one-time token print, and next-steps text including the browser login URL (`http://:/?token=...`, exchanged for a session cookie). Disable now removes all three network blocks. After the wizard, `--enable-web` opens the scoped firewall rule on the first try - no hand-editing required. + ### Changed - **Full Dashboard and the CLI Installer are being retired.** They moved to a `deprecated/` folder and are no longer built into release artifacts; releases now ship Lite + Darling only, with Darling the flagship replacement for the Full Dashboard. The deprecated code still compiles and its tests run in CI. ([#1612]) @@ -504,6 +508,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1609]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1609 [#1612]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1612 [#1614]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1614 +[#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 diff --git a/Darling/Darling.Tests/DarlingCliCommandsTests.cs b/Darling/Darling.Tests/DarlingCliCommandsTests.cs index 7a6b7e648..8d9132a0e 100644 --- a/Darling/Darling.Tests/DarlingCliCommandsTests.cs +++ b/Darling/Darling.Tests/DarlingCliCommandsTests.cs @@ -12,8 +12,10 @@ using System.Threading; using System.Threading.Tasks; using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Hosting; using Xunit; using Host = PerformanceMonitor.Darling.Service.Mcp.DarlingMcpHostService; +using WebHost = PerformanceMonitor.Darling.Service.Mcp.DarlingWebHostService; namespace Darling.Tests; @@ -481,6 +483,202 @@ public async Task ConfigureNetwork_Mcp_GeneratesToken_PrintsPlaintextOnce_WarnsO } } + [Fact] + public async Task ConfigureNetwork_Web_GeneratesToken_PrintsPlaintextOnce_StoresEncrypted_HintsLoginUrl() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The wizard generates a DPAPI-protected token."); + + var root = Directory.CreateTempSubdirectory("darling-confignet-web-"); + try + { + var configPath = CopySampleTo(root.FullName); + + /* choice=Web, bind IP, CIDR, decline restart. No existing token -> one is generated. */ + var input = Script("3", "192.168.1.205", "192.168.1.0/24", "n"); + var output = new StringWriter(); + var error = new StringWriter(); + + var exit = await DarlingCliCommands.ConfigureNetworkAsync(configPath, input, output, error, CancellationToken.None); + Assert.Equal(0, exit); + + /* The written file parses and the WEB BIND RESOLVER (the one the web host fail-closes on) + reports it network-exposed — the exact acceptance for #1617's hand-edit incident. */ + var written = await File.ReadAllTextAsync(configPath); + var config = DarlingConfig.Parse(written); + var decision = WebHost.ResolveWebBind(config.Web, managed: true); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, decision.Mode); + Assert.Equal("192.168.1.205", config.Web.Network!.Listen); + Assert.Equal("192.168.1.0/24", config.Web.Network!.AllowFrom); + + /* Stored only DPAPI-encrypted; the plaintext it decrypts to is the token printed to STDOUT. */ + var encrypted = config.Web.Network!.EncryptedToken; + Assert.False(string.IsNullOrWhiteSpace(encrypted)); + var plaintext = DarlingSecrets.Unprotect(encrypted!); + + Assert.Equal(1, CountOccurrences(output.ToString(), plaintext)); // STDOUT: exactly once + Assert.Contains("SAVE THIS NOW", error.ToString(), StringComparison.Ordinal); // STDERR: the warning + + /* The next-steps handoff includes the browser login hint — the one step Web does differently. */ + Assert.Contains("http://192.168.1.205:5153/?token=", output.ToString(), StringComparison.Ordinal); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public async Task ConfigureNetwork_Web_ExistingToken_KeptByDefault() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The wizard queries the Windows service + uses DPAPI."); + + var root = Directory.CreateTempSubdirectory("darling-confignet-web-keep-"); + try + { + var configPath = Path.Combine(root.FullName, "darling.json"); + await File.WriteAllTextAsync(configPath, """ + { + "postgres": { "managed": true }, + "web": { + "network": { "listen": "192.168.1.205", "allowFrom": "192.168.1.0/24", "encryptedToken": "KEEP-ME-BLOB" } + }, + "servers": [ { "host": "S" } ] + } + """); + + /* choice=Web, keep-token default (empty line), bind IP, CIDR, decline restart. */ + var input = Script("3", "", "10.0.0.5", "10.0.0.0/24", "n"); + var output = new StringWriter(); + var error = new StringWriter(); + + var exit = await DarlingCliCommands.ConfigureNetworkAsync(configPath, input, output, error, CancellationToken.None); + Assert.Equal(0, exit); + + /* The existing DPAPI blob survives; new listen/allowFrom land; no plaintext was generated. */ + var config = DarlingConfig.Parse(await File.ReadAllTextAsync(configPath)); + Assert.Equal("KEEP-ME-BLOB", config.Web.Network!.EncryptedToken); + Assert.Equal("10.0.0.5", config.Web.Network!.Listen); + Assert.DoesNotContain("SAVE THIS NOW", error.ToString(), StringComparison.Ordinal); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public async Task ConfigureNetwork_CommaCombination_McpAndWeb_WritesBothBlocks() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The wizard generates DPAPI-protected tokens."); + + var root = Directory.CreateTempSubdirectory("darling-confignet-combo-"); + try + { + var configPath = CopySampleTo(root.FullName); + + /* choice="2,3": MCP inputs first (bind, CIDR), then web (bind, CIDR), decline restart. + Both surfaces generate fresh tokens -> two SAVE THIS warnings, two STDOUT plaintexts. */ + var input = Script("2,3", "192.168.1.205", "192.168.1.0/24", "192.168.1.205", "192.168.1.0/24", "n"); + var output = new StringWriter(); + var error = new StringWriter(); + + var exit = await DarlingCliCommands.ConfigureNetworkAsync(configPath, input, output, error, CancellationToken.None); + Assert.Equal(0, exit); + + var config = DarlingConfig.Parse(await File.ReadAllTextAsync(configPath)); + Assert.Equal(Host.McpBindMode.NetworkAndLoopback, Host.ResolveMcpBind(config.Mcp, managed: true).Mode); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, WebHost.ResolveWebBind(config.Web, managed: true).Mode); + + /* The store block was NOT touched (surface selection is exact). */ + Assert.False(DarlingManagedPostgres.ResolveNetworkExposure(config.Postgres.Network, CertPath, KeyPath).Exposed); + + /* Two distinct tokens, each printed exactly once. */ + var mcpPlain = DarlingSecrets.Unprotect(config.Mcp.Network!.EncryptedToken!); + var webPlain = DarlingSecrets.Unprotect(config.Web.Network!.EncryptedToken!); + Assert.NotEqual(mcpPlain, webPlain); + Assert.Equal(1, CountOccurrences(output.ToString(), mcpPlain)); + Assert.Equal(1, CountOccurrences(output.ToString(), webPlain)); + Assert.Equal(2, CountOccurrences(error.ToString(), "SAVE THIS NOW")); + } + finally + { + root.Delete(recursive: true); + } + } + + [Fact] + public async Task ConfigureNetwork_All_WritesAllThreeBlocks_EachResolverExposed() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "The wizard generates DPAPI-protected tokens."); + + var root = Directory.CreateTempSubdirectory("darling-confignet-all-"); + try + { + var configPath = CopySampleTo(root.FullName); + + /* choice="4" (all three): store inputs first (bind, CIDR, role), then MCP (bind, CIDR), + then web (bind, CIDR), decline restart — the three-upsert + three-guard path in one run. */ + var input = Script("4", + "192.168.1.205", "192.168.1.0/24", "viewer", + "192.168.1.205", "192.168.1.0/24", + "192.168.1.205", "192.168.1.0/24", + "n"); + var output = new StringWriter(); + var error = new StringWriter(); + + var exit = await DarlingCliCommands.ConfigureNetworkAsync(configPath, input, output, error, CancellationToken.None); + Assert.Equal(0, exit); + + var config = DarlingConfig.Parse(await File.ReadAllTextAsync(configPath)); + Assert.True(DarlingManagedPostgres.ResolveNetworkExposure(config.Postgres.Network, CertPath, KeyPath).Exposed); + Assert.Equal(Host.McpBindMode.NetworkAndLoopback, Host.ResolveMcpBind(config.Mcp, managed: true).Mode); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, WebHost.ResolveWebBind(config.Web, managed: true).Mode); + + /* All three next-steps blocks made it out (store handoff, MCP + web firewall commands, login URL). */ + var stdout = output.ToString(); + Assert.Contains("--print-viewer-connection", stdout, StringComparison.Ordinal); + Assert.Contains("PerformanceMonitor Darling MCP (port", stdout, StringComparison.Ordinal); + Assert.Contains("PerformanceMonitor Darling Web (port", stdout, StringComparison.Ordinal); + Assert.Contains("?token=", stdout, StringComparison.Ordinal); + } + finally + { + root.Delete(recursive: true); + } + } + + [Theory] + [InlineData("1", true, false, false)] + [InlineData("2", false, true, false)] + [InlineData("3", false, false, true)] + [InlineData("4", true, true, true)] + [InlineData("1,3", true, false, true)] + [InlineData("2 , 3", false, true, true)] // tokens are trimmed + [InlineData("1,2,3", true, true, true)] + [InlineData("1,4", true, true, true)] // 4 dominates + public void TryParseSurfaceChoice_ValidSelections(string choice, bool store, bool mcp, bool web) + { + Assert.True(DarlingCliCommands.TryParseSurfaceChoice(choice, out var doStore, out var doMcp, out var doWeb)); + Assert.Equal(store, doStore); + Assert.Equal(mcp, doMcp); + Assert.Equal(web, doWeb); + } + + [Theory] + [InlineData("0")] + [InlineData("5")] // Disable is handled BEFORE the parser; it is not a surface + [InlineData("shop")] + [InlineData("1,shop")] // a typo rejects the WHOLE input — never silently configure a subset + [InlineData(",")] + [InlineData("")] + public void TryParseSurfaceChoice_RejectsUnknownTokens_NothingSelected(string choice) + { + Assert.False(DarlingCliCommands.TryParseSurfaceChoice(choice, out var doStore, out var doMcp, out var doWeb)); + Assert.False(doStore); + Assert.False(doMcp); + Assert.False(doWeb); + } + [Fact] public async Task ConfigureNetwork_Byo_RefusesAndWritesNothing() { @@ -510,7 +708,7 @@ await File.WriteAllTextAsync(configPath, } [Fact] - public async Task ConfigureNetwork_Disable_RemovesBlock_BacksUp_ResolverLoopback() + public async Task ConfigureNetwork_Disable_RemovesAllBlocks_WebIncluded_BacksUp_ResolversLoopback() { Assert.SkipUnless(OperatingSystem.IsWindows(), "The wizard queries the Windows service."); @@ -525,11 +723,14 @@ await File.WriteAllTextAsync(configPath, """ "port": 5641, "network": { "listen": "192.168.1.205", "allowFrom": "192.168.1.0/24", "role": "viewer" } }, + "web": { + "network": { "listen": "192.168.1.205", "allowFrom": "192.168.1.0/24", "encryptedToken": "BLOB" } + }, "servers": [ { "host": "S" } ] } """); - var input = Script("4", "n"); // Disable, then decline restart + var input = Script("5", "n"); // Disable, then decline restart var output = new StringWriter(); var error = new StringWriter(); @@ -539,6 +740,9 @@ await File.WriteAllTextAsync(configPath, """ var written = await File.ReadAllTextAsync(configPath); var config = DarlingConfig.Parse(written); Assert.False(DarlingManagedPostgres.ResolveNetworkExposure(config.Postgres.Network, CertPath, KeyPath).Exposed); + Assert.Equal( + DarlingHostBinding.BindMode.LoopbackOnly, + WebHost.ResolveWebBind(config.Web, managed: true).Mode); Assert.NotEmpty(Directory.GetFiles(root.FullName, "darling.json.bak-*")); } finally diff --git a/Darling/Darling.Tests/DarlingNetworkConfigEditorTests.cs b/Darling/Darling.Tests/DarlingNetworkConfigEditorTests.cs index 02a9a2b1e..93a12785c 100644 --- a/Darling/Darling.Tests/DarlingNetworkConfigEditorTests.cs +++ b/Darling/Darling.Tests/DarlingNetworkConfigEditorTests.cs @@ -10,8 +10,10 @@ using System.IO; using PerformanceMonitor.Darling.Service; using Xunit; +using PerformanceMonitor.Darling.Service.Hosting; using Editor = PerformanceMonitor.Darling.Service.DarlingNetworkConfigEditor; using Host = PerformanceMonitor.Darling.Service.Mcp.DarlingMcpHostService; +using WebHost = PerformanceMonitor.Darling.Service.Mcp.DarlingWebHostService; namespace Darling.Tests; @@ -145,6 +147,63 @@ public void UpsertBothParents_EachResolverExposed_BothTemplatesSurvive() Assert.Equal(CountOccurrences(sample, "// \"network\": {"), CountOccurrences(edited, "// \"network\": {")); } + /* ============================ web insert (#1617) ============================ */ + + [Fact] + public void UpsertWeb_IntoSample_TemplateSurvives_ParsesAndResolverExposed() + { + var sample = LoadSample(); + /* A non-empty encryptedToken satisfies the resolver's PRESENCE check (no DPAPI decryption here). */ + var block = Editor.BuildWebNetworkBlock("192.168.1.205", "192.168.1.0/24", encryptedToken: "DPAPI-BLOB", plaintextToken: null); + var edited = Editor.UpsertNetworkBlock(sample, "web", block); + + var config = DarlingConfig.Parse(edited); + Assert.Contains("// \"network\": {", edited, StringComparison.Ordinal); + + var decision = WebHost.ResolveWebBind(config.Web, managed: true); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, decision.Mode); + Assert.Equal(DarlingHostBinding.BindReason.NetworkExposed, decision.Reason); + Assert.Equal("DPAPI-BLOB", config.Web.Network!.EncryptedToken); + } + + [Fact] + public void UpsertAllThreeParents_EachResolverExposed_AllTemplatesSurvive() + { + var sample = LoadSample(); + var edited = Editor.UpsertNetworkBlock(sample, "postgres", + Editor.BuildStoreNetworkBlock("10.0.0.5", "10.0.0.0/24", "viewer")); + edited = Editor.UpsertNetworkBlock(edited, "mcp", + Editor.BuildMcpNetworkBlock("10.0.0.5", "10.0.0.0/24", encryptedToken: "BLOB", plaintextToken: null)); + edited = Editor.UpsertNetworkBlock(edited, "web", + Editor.BuildWebNetworkBlock("10.0.0.5", "10.0.0.0/24", encryptedToken: "BLOB", plaintextToken: null)); + + var config = DarlingConfig.Parse(edited); + Assert.True(DarlingManagedPostgres.ResolveNetworkExposure(config.Postgres.Network, CertPath, KeyPath).Exposed); + Assert.Equal(Host.McpBindMode.NetworkAndLoopback, Host.ResolveMcpBind(config.Mcp, managed: true).Mode); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, WebHost.ResolveWebBind(config.Web, managed: true).Mode); + + /* All commented templates still present (count of the commented marker unchanged from the sample). */ + Assert.Equal(CountOccurrences(sample, "// \"network\": {"), CountOccurrences(edited, "// \"network\": {")); + } + + [Fact] + public void UpsertWeb_MissingWebSection_CreatesParentAtRoot() + { + /* An older darling.json with NO "web" section at all: the upsert must create the parent, not throw + — the exact shape a pre-#1562 config presents when the wizard adds Web exposure. */ + var json = """ + { + "postgres": { "managed": true }, + "servers": [ { "host": "S" } ] + } + """; + var edited = Editor.UpsertNetworkBlock(json, "web", + Editor.BuildWebNetworkBlock("192.168.1.205", "192.168.1.0/24", encryptedToken: "BLOB", plaintextToken: null)); + + var config = DarlingConfig.Parse(edited); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, WebHost.ResolveWebBind(config.Web, managed: true).Mode); + } + /* ============================ replace (idempotent-ish upsert) ============================ */ [Fact] diff --git a/Darling/Darling.Tests/DarlingWebSupervisorTests.cs b/Darling/Darling.Tests/DarlingWebSupervisorTests.cs index 3721e40f5..cff4ab809 100644 --- a/Darling/Darling.Tests/DarlingWebSupervisorTests.cs +++ b/Darling/Darling.Tests/DarlingWebSupervisorTests.cs @@ -7,6 +7,8 @@ */ using System; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Service.Hosting; using PerformanceMonitor.Darling.Service.Mcp; using Xunit; @@ -21,6 +23,61 @@ namespace Darling.Tests; /// public sealed class DarlingWebSupervisorTests { + /* ---- ResolveWebBind (#1617): the web twin of ResolveMcpBind — pins the PROJECTION of WebConfig onto + the shared DarlingHostBinding ladder (the ladder's own matrix lives in DarlingHostBindingTests). + The --configure-network wizard validates candidate web blocks through this exact method. ---- */ + + [Fact] + public void ResolveWebBind_Exposed_Managed_EncryptedTokenAndAllowFrom_IsNetworkAndLoopback() + { + var decision = DarlingWebHostService.ResolveWebBind(Web("192.168.1.205", "192.168.1.0/24", encryptedToken: "BLOB"), managed: true); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, decision.Mode); + Assert.Equal(DarlingHostBinding.BindReason.NetworkExposed, decision.Reason); + } + + [Fact] + public void ResolveWebBind_PlaintextTokenAlsoCountsAsPresent() + { + var decision = DarlingWebHostService.ResolveWebBind(Web("192.168.1.205", "192.168.1.0/24", plainToken: "s3cr3t"), managed: true); + Assert.Equal(DarlingHostBinding.BindMode.NetworkAndLoopback, decision.Mode); + } + + [Fact] + public void ResolveWebBind_NoToken_IsLoopbackOnly_TokenMissing() + { + var decision = DarlingWebHostService.ResolveWebBind(Web("192.168.1.205", "192.168.1.0/24"), managed: true); + Assert.Equal(DarlingHostBinding.BindMode.LoopbackOnly, decision.Mode); + Assert.Equal(DarlingHostBinding.BindReason.TokenMissing, decision.Reason); + } + + [Fact] + public void ResolveWebBind_Byo_IsLoopbackOnly_ManagedModeRequired() + { + var decision = DarlingWebHostService.ResolveWebBind(Web("192.168.1.205", "192.168.1.0/24", encryptedToken: "BLOB"), managed: false); + Assert.Equal(DarlingHostBinding.BindMode.LoopbackOnly, decision.Mode); + Assert.Equal(DarlingHostBinding.BindReason.ManagedModeRequired, decision.Reason); + } + + [Fact] + public void ResolveWebBind_NoNetworkBlock_IsLoopbackByDefault() + { + var decision = DarlingWebHostService.ResolveWebBind(new WebConfig(), managed: true); + Assert.Equal(DarlingHostBinding.BindMode.LoopbackOnly, decision.Mode); + Assert.Equal(DarlingHostBinding.BindReason.LoopbackByDefault, decision.Reason); + } + + private static WebConfig Web(string listen, string allowFrom, string? encryptedToken = null, string? plainToken = null) => new() + { + Enabled = true, + Network = new WebNetworkConfig + { + Listen = listen, + AllowFrom = allowFrom, + EncryptedToken = encryptedToken, + Token = plainToken, + }, + }; + [Theory] /* not running: start only when enabled */ [InlineData(false, 0, false, 5153, DarlingWebHostService.WebSupervisorAction.None)] diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs index 6000a6ade..e392f5e1e 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingCliCommands.cs @@ -19,6 +19,7 @@ using System.Threading; using System.Threading.Tasks; using Npgsql; +using PerformanceMonitor.Darling.Service.Hosting; using PerformanceMonitor.Darling.Service.Mcp; namespace PerformanceMonitor.Darling.Service; @@ -444,7 +445,8 @@ public static string BuildViewerConnectionString( 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). + DarlingManagedPostgres.ResolveNetworkExposure, the MCP host's DarlingMcpHostService.ResolveMcpBind, + the web host's DarlingWebHostService.ResolveWebBind). 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 @@ -452,8 +454,9 @@ The wizard never re-implements CIDR / family / role / token rules — it re-prom - 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). + - The MCP bearer / web access tokens are generated + DPAPI-protected; each 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. @@ -462,7 +465,7 @@ 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 + /// Interactive wizard that guides the operator through the opt-in store / MCP / web-dashboard 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- @@ -519,12 +522,20 @@ public static async Task ConfigureNetworkAsync( mcpNow.Reason is DarlingMcpHostService.McpBindReason.NetworkExposed or DarlingMcpHostService.McpBindReason.LoopbackByDefault ? null : McpDegradeText(mcpNow.Reason, config.Mcp); + var webNow = DarlingWebHostService.ResolveWebBind(config.Web, postgres.Managed); + var webNowExposed = webNow.Mode == DarlingHostBinding.BindMode.NetworkAndLoopback; + var webNowDegrade = + webNow.Reason is DarlingHostBinding.BindReason.NetworkExposed or DarlingHostBinding.BindReason.LoopbackByDefault + ? null + : WebDegradeText(webNow.Reason, config.Web); 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(DarlingNetworkConfigEditor.FormatExposureState( + "Web ", webNowExposed, config.Web.Network?.Listen, config.Web.Network?.AllowFrom, null, webNowDegrade)); output.WriteLine($" Service: {await DescribeServiceStateAsync(cancellationToken)}"); output.WriteLine(); @@ -535,7 +546,9 @@ mcpNow.Reason is DarlingMcpHostService.McpBindReason.NetworkExposed or DarlingMc 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); + var hasBlocks = (postgres.Network?.IsConfigured ?? false) + || (config.Mcp.Network?.IsConfigured ?? false) + || (config.Web.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); @@ -544,12 +557,13 @@ mcpNow.Reason is DarlingMcpHostService.McpBindReason.NetworkExposed or DarlingMc return 1; } - /* Surface selection. */ + /* Surface selection — one surface, a comma combination (e.g. "1,3"), all three, or disable. */ 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(" [3] Web — the browser dashboard behind a token->cookie login"); + output.WriteLine(" [4] All — every surface above (or pick a combination, e.g. 1,3)"); + output.WriteLine(" [5] 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)) @@ -558,21 +572,19 @@ mcpNow.Reason is DarlingMcpHostService.McpBindReason.NetworkExposed or DarlingMc return 0; } - if (choice == "4") + if (choice == "5") { 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) + if (!TryParseSurfaceChoice(choice, out var doStore, out var doMcp, out var doWeb)) { 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. */ + untouched and a multi-surface run is all-or-nothing. */ (string Listen, string AllowFrom, string Role)? store = null; if (doStore) { @@ -604,6 +616,25 @@ untouched and a "both" run is all-or-nothing. */ } } + (string Listen, string AllowFrom, string? EncryptedToken, string? PlainToken, string? GeneratedPlain)? web = null; + if (doWeb) + { + output.WriteLine(); + output.WriteLine("== Web dashboard exposure =="); + if (!config.Web.Enabled) + { + output.WriteLine("NOTE: web.enabled is currently false. The wizard writes the network block, but the dashboard"); + output.WriteLine(" stays down until you enable it with --enable-web or the Viewer's Settings (enabled/port"); + output.WriteLine(" are control-plane after first run; the wizard never edits them)."); + } + + web = GatherWebInputs(input, output, error, config.Web); + if (web is null) + { + return 1; + } + } + /* Build the edit through the comment-preserving surgeon. */ var newText = originalText; if (store is not null) @@ -620,6 +651,13 @@ untouched and a "both" run is all-or-nothing. */ DarlingNetworkConfigEditor.BuildMcpNetworkBlock(mcp.Value.Listen, mcp.Value.AllowFrom, mcp.Value.EncryptedToken, mcp.Value.PlainToken)); } + if (web is not null) + { + newText = DarlingNetworkConfigEditor.UpsertNetworkBlock( + newText, "web", + DarlingNetworkConfigEditor.BuildWebNetworkBlock(web.Value.Listen, web.Value.AllowFrom, web.Value.EncryptedToken, web.Value.PlainToken)); + } + /* Guard 1: the edited text must PARSE (comments/trailing-commas tolerated). */ DarlingConfig reparsed; try @@ -654,14 +692,25 @@ untouched and a "both" run is all-or-nothing. */ } } + if (web is not null) + { + var check = DarlingWebHostService.ResolveWebBind(reparsed.Web, reparsed.Postgres.Managed); + if (check.Mode != DarlingHostBinding.BindMode.NetworkAndLoopback) + { + error.WriteLine($"Internal error: the web block would fail-close ({WebDegradeText(check.Reason, reparsed.Web)}). 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. */ + /* The generated token plaintexts — STDOUT exactly once each; the save-this warning on STDERR so a + STDOUT redirect keeps the token without swallowing the warning (MCP first, then web, so a + two-token capture is unambiguous by order). */ if (mcp is not null && mcp.Value.GeneratedPlain is not null) { error.WriteLine(); @@ -670,15 +719,55 @@ STDOUT redirect keeps the token without swallowing the warning. */ output.WriteLine(mcp.Value.GeneratedPlain); } + if (web is not null && web.Value.GeneratedPlain is not null) + { + error.WriteLine(); + error.WriteLine("SAVE THIS NOW — your new web dashboard access token is shown ONCE (darling.json stores only its DPAPI blob)."); + error.WriteLine("A remote browser presents it once via ?token=... and gets a session cookie back."); + output.WriteLine(web.Value.GeneratedPlain); + } + PrintNextSteps( output, store is not null, postgres.Port, store?.AllowFrom, - mcp is not null, config.Mcp.Port, mcp?.AllowFrom, config.Mcp.Enabled); + mcp is not null, config.Mcp.Port, mcp?.AllowFrom, config.Mcp.Enabled, + web is not null, config.Web.Port, web?.AllowFrom, config.Web.Enabled, web?.Listen); await OfferRestartAsync(input, output, error, cancellationToken); return 0; } + /// + /// Parses the surface-selection choice: "1"/"2"/"3" (store/MCP/web), "4" = all three, or a comma + /// combination like "1,3". STRICT — every token must be a known surface digit, so a typo ("1,shop") + /// rejects the whole input instead of silently configuring a subset. False = unrecognized (nothing + /// selected). Pure. + /// + internal static bool TryParseSurfaceChoice(string choice, out bool doStore, out bool doMcp, out bool doWeb) + { + doStore = false; + doMcp = false; + doWeb = false; + + foreach (var raw in choice.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + { + switch (raw) + { + case "1": doStore = true; break; + case "2": doMcp = true; break; + case "3": doWeb = true; break; + case "4": doStore = true; doMcp = true; doWeb = true; break; + default: + doStore = false; + doMcp = false; + doWeb = false; + return false; + } + } + + return doStore || doMcp || doWeb; + } + /// /// 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 @@ -943,6 +1032,97 @@ private static (string Listen, string AllowFrom, string? EncryptedToken, string? } } + /// + /// Gathers the web dashboard access token (default KEEP an existing one; else generate a fresh 32-char + /// token and DPAPI-protect it) plus listen / allowFrom, RE-PROMPTING with the web bind resolver's degrade + /// reason until it accepts them — the web twin of (#1617). 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)? GatherWebInputs( + TextReader input, TextWriter output, TextWriter error, WebConfig currentWeb) + { + string? encryptedToken = null; + string? plainToken = null; + string? generatedPlain = null; + + var existing = currentWeb.Network; + var hasExistingToken = existing is not null + && (!string.IsNullOrWhiteSpace(existing.EncryptedToken) || !string.IsNullOrWhiteSpace(existing.Token)); + + if (hasExistingToken && AskYesNo(input, output, "A web access 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, "web"); + 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 WebConfig + { + Enabled = currentWeb.Enabled, + Port = currentWeb.Port, + Network = new WebNetworkConfig + { + Listen = listen, + AllowFrom = allowFrom, + EncryptedToken = encryptedToken, + Token = plainToken, + }, + }; + + var decision = DarlingWebHostService.ResolveWebBind(candidate, managed: true); + if (decision.Mode == DarlingHostBinding.BindMode.NetworkAndLoopback) + { + return (listen, allowFrom, encryptedToken, plainToken, generatedPlain); + } + + output.WriteLine($" Not accepted: {WebDegradeText(decision.Reason, candidate)}"); + output.WriteLine(" Let us try again."); + } + } + + /// Human text for a web bind degrade reason (presentation only — the resolver decides; this narrates). + private static string WebDegradeText(DarlingHostBinding.BindReason reason, WebConfig web) => reason switch + { + DarlingHostBinding.BindReason.ListenInvalid => + $"web.network.listen '{web.Network?.Listen}' is not a valid IP address (use a specific IP, or 0.0.0.0 for all interfaces).", + DarlingHostBinding.BindReason.TokenMissing => + "no access token is set (the wizard should have supplied one — this is unexpected).", + DarlingHostBinding.BindReason.AllowFromInvalid => + $"web.network.allowFrom '{web.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).", + DarlingHostBinding.BindReason.ManagedModeRequired => + "network exposure is managed-mode only.", + _ => "the web bind resolver rejected these values.", + }; + /// 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 { @@ -957,13 +1137,14 @@ private static (string Listen, string AllowFrom, string? EncryptedToken, string? _ => "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. + /// Removes all three 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"); + newText = DarlingNetworkConfigEditor.RemoveNetworkBlock(newText, "web"); if (string.Equals(newText, originalText, StringComparison.Ordinal)) { @@ -986,7 +1167,9 @@ private static async Task DisableExposureAsync( 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) + var webStillExposed = DarlingWebHostService.ResolveWebBind(reparsed.Web, reparsed.Postgres.Managed).Mode + == DarlingHostBinding.BindMode.NetworkAndLoopback; + if (storeStillExposed || mcpStillExposed || webStillExposed) { error.WriteLine("Internal error: exposure is still present after removal. No changes were written."); return 1; @@ -1057,12 +1240,13 @@ private static void PrintManualRestartCommands(TextWriter output) 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. + /// Prints the handoff reminders: the scoped firewall command(s), the store's --print-viewer-connection step, and the web dashboard's browser login hint. [SupportedOSPlatform("windows")] private static void PrintNextSteps( TextWriter output, bool storeConfigured, int storePort, string? storeCidr, - bool mcpConfigured, int mcpPort, string? mcpCidr, bool mcpEnabled) + bool mcpConfigured, int mcpPort, string? mcpCidr, bool mcpEnabled, + bool webConfigured, int webPort, string? webCidr, bool webEnabled, string? webListen) { output.WriteLine(); output.WriteLine("Next steps:"); @@ -1080,13 +1264,35 @@ private static void PrintNextSteps( { output.WriteLine(" MCP firewall rule (run ELEVATED; scoped to the port + CIDR):"); output.WriteLine(" " + DarlingManagedPostgres.BuildFirewallEnableCommand( - $"PerformanceMonitor Darling MCP (port {mcpPort})", mcpPort, mcpCidr!)); + DarlingMcpHostService.McpFirewallRuleName(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."); } } + + if (webConfigured) + { + output.WriteLine(" Web dashboard firewall rule (run ELEVATED; scoped to the port + CIDR — --enable-web also"); + output.WriteLine(" reconciles this rule for you):"); + output.WriteLine(" " + DarlingManagedPostgres.BuildFirewallEnableCommand( + DarlingWebHostService.WebFirewallRuleName(webPort), webPort, webCidr!)); + + /* The one login step a human does differently for Web: a remote browser presents the access token + once via ?token= and is 302'd back with a session cookie. A 0.0.0.0 bind has no single address + to print, so fall back to a placeholder. */ + var webHost = webListen == "0.0.0.0" ? "" : webListen; + output.WriteLine(" Remote browser login (after the service restarts):"); + output.WriteLine($" http://{webHost}:{webPort}/?token="); + output.WriteLine(" (the token is exchanged for a session cookie and stripped from the URL; loopback needs no token)"); + if (!webEnabled) + { + output.WriteLine(" NOTE: web.enabled is false, so the dashboard stays down until you enable it with --enable-web"); + output.WriteLine(" or the Viewer's Settings. The network block you just wrote applies once the web dashboard"); + output.WriteLine(" 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. diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs index 7ba997b45..73a170766 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingNetworkConfigEditor.cs @@ -21,11 +21,11 @@ namespace PerformanceMonitor.Darling.Service; /// 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. +/// three network blocks (postgres.network, mcp.network, web.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 +/// Why a real scanner and not IndexOf. The sample ships all three 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 @@ -447,6 +447,27 @@ internal static string BuildMcpNetworkBlock( ChildIndent + "}"; } + /// + /// The active (uncommented) web.network block the wizard writes (#1617) — the web-dashboard twin + /// of , same exactly-one-of token contract: 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 BuildWebNetworkBlock( + string listen, string allowFrom, string? encryptedToken, string? plaintextToken) + { + var tokenLine = encryptedToken is not null + ? FieldIndent + $"\"encryptedToken\": {JsonString(encryptedToken)} // DPAPI access token (from --encrypt-password); the browser login secret.\n" + : FieldIndent + $"\"token\": {JsonString(plaintextToken)} // plaintext access 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. diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs index 8e179d5fb..24faa01b9 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs @@ -218,6 +218,26 @@ private async Task DisposeFailedStartAsync() } } + /// + /// The effective web-dashboard bind — the web twin of : + /// projects the web network block onto the shared ladder + /// (darling-network-endpoints anti-drift). The web host was built on the shared enums from the start, so + /// unlike MCP there is no nested-enum mapping — the shared decision is returned as-is. PURE; extracted so + /// the --configure-network wizard validates candidate web blocks through the SAME resolver the host + /// fail-closes on (#1617), exactly as the wizard's MCP path calls ResolveMcpBind. + /// + internal static DarlingHostBinding.BindDecision ResolveWebBind(WebConfig web, bool managed) + { + var network = web.Network; + return 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); + } + /// /// 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 @@ -230,13 +250,7 @@ private async Task TryStartServerAsync(DarlingConfig config, int effective 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); + var bind = ResolveWebBind(web, config.Postgres.Managed); LogBindReason(web, bind.Reason); try diff --git a/Darling/PerformanceMonitor.Darling.Service/Program.cs b/Darling/PerformanceMonitor.Darling.Service/Program.cs index 67baef7c1..cc689e8e2 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Program.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Program.cs @@ -90,11 +90,12 @@ network endpoint (darling-network-endpoints D8). It DPAPI-decrypts the network r 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. */ +/* CLI verb: the interactive --configure-network wizard (#1561; web surface #1617) — guides the operator + through the opt-in store / MCP / web-dashboard 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 / web access + tokens, 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()) diff --git a/Darling/README.md b/Darling/README.md index 238b67be0..287540e61 100644 --- a/Darling/README.md +++ b/Darling/README.md @@ -602,7 +602,7 @@ Edit the two password placeholders (and the database/owner names if yours differ ## 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. +By default all three network surfaces bind **loopback only** — the store to `127.0.0.1`, the MCP server and the web dashboard to `localhost` — exactly as they always have. Three optional, independent opt-ins let a remote viewer, MCP client, or browser on your **trusted LAN** reach them. This is a home-lab / trusted-subnet feature: **never expose any of these endpoints to the internet.** All three 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 all three 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`) @@ -612,7 +612,7 @@ 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-` 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. +It shows the current exposure (read from the service's own resolvers), then walks you through the **store**, **MCP**, the **web dashboard**, any comma combination (e.g. `1,3`), or all three at once (or a **disable** that removes all 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 / web access tokens for you (DPAPI-protected; each plaintext is printed once, so save it then), edits `darling.json` **in place preserving every comment** behind a timestamped `darling.json.bak-` backup, prints the scoped firewall command(s), the `--print-viewer-connection` handoff, and the web dashboard's browser login URL (`http://:/?token=...`), 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`) diff --git a/Darling/tools/install-darling.ps1 b/Darling/tools/install-darling.ps1 index 7ad248131..99c484eea 100644 --- a/Darling/tools/install-darling.ps1 +++ b/Darling/tools/install-darling.ps1 @@ -34,8 +34,9 @@ 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. +store / MCP / web-dashboard 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( From e9acdaf8e31820659c5770e6dca653205dc93814 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:37:18 -0400 Subject: [PATCH 012/296] Darling: custom time-span picker + cap the view grid at two columns The rendered custom-view range picker gains a "Custom..." option (a number + a hours/days unit, up to the 90-day window ceiling) so a view is not limited to the six presets; a non-preset window stays selectable so it survives the 60s refresh. The panel grid is capped at two columns on wide screens (min track >=45%) so a dense view no longer crams four panels across; a span-2 panel still spans the full width, and a lone panel takes it too. Co-Authored-By: Claude Opus 4.8 --- .../wwwroot/css/app.css | 14 +++- .../wwwroot/js/pages/views.js | 78 +++++++++++++++++-- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css index bd7e2d22d..55ac9973a 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css @@ -153,16 +153,22 @@ a:hover { text-decoration: underline; } } .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. */ +/* Custom-views renderer grid (#1563): stored panels tile responsively, capped at two columns on wide screens so a + dense view no longer crams 4-across; a span-2 panel spans the full width via .panel.span-2 { grid-column: 1 / -1 } + below. The min track is >=45% of the row so at most two columns ever fit; a lone or span-2 panel takes the full + width. 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)); + grid-template-columns: repeat(auto-fit, minmax(clamp(320px, 45%, 700px), 1fr)); gap: 1.25rem; } .panel-grid .panel { margin-bottom: 0; } +/* Time-range picker (view chrome): the preset 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. */ +/* Mirror of the compose backend's window ceiling (ComposeLimits.MaxWindow = 90 days) — a custom range can't exceed it. */ +const MAX_RANGE_HOURS = 24 * 90; + +/* A human label for an arbitrary hour count (whole days render as days), matching the preset labels' style. */ +function rangeLabel(hours) { + if (hours % 24 === 0 && hours >= 24) { + const d = hours / 24; + return "Last " + d + (d === 1 ? " day" : " days"); + } + return "Last " + hours + (hours === 1 ? " hour" : " hours"); +} + +/* A time-range picker bound to state.hours: the presets, plus the view's stored default and the current value when + either is a non-preset custom window (kept selectable so the 60s refresh shows exactly what's applied), plus a + "Custom…" entry that reveals a number + unit input. The compose backend accepts any window up to MAX_RANGE_HOURS, + so an arbitrary span is purely this UI. */ function buildRangeSelect(defaultHours, state, onChange) { + const wrap = el("span", { class: "range-select" }); const sel = el("select", { class: "filter-box", "aria-label": "Time range" }); + const added = new Set(VIEW_RANGE_OPTIONS.map((r) => String(r.hours))); 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)" })); + for (const h of [state.hours, defaultHours]) { + const key = String(h); + if (h && !added.has(key)) { + sel.appendChild(el("option", { value: key, text: rangeLabel(h) })); + added.add(key); + } } + sel.appendChild(el("option", { value: "custom", text: "Custom…" })); sel.value = String(state.hours); + + const custom = buildCustomHoursInput(state, onChange); + custom.style.display = "none"; + sel.addEventListener("change", () => { + if (sel.value === "custom") { + custom.style.display = ""; + return; + } + custom.style.display = "none"; state.hours = parseInt(sel.value, 10) || defaultHours; onChange(); }); - return sel; + + wrap.appendChild(sel); + wrap.appendChild(custom); + return wrap; +} + +/* The inline custom-window input revealed by the "Custom…" range option: a number + unit (hours/days); Apply (or + Enter) commits it to state.hours, clamped to MAX_RANGE_HOURS, and re-runs the panels. */ +function buildCustomHoursInput(state, onChange) { + const box = el("span", { class: "range-custom" }); + const num = el("input", { class: "filter-box range-custom-n", type: "number", min: "1", step: "1", "aria-label": "Custom range amount" }); + const unit = el("select", { class: "filter-box range-custom-u", "aria-label": "Custom range unit" }); + unit.appendChild(el("option", { value: "1", text: "hours" })); + unit.appendChild(el("option", { value: "24", text: "days" })); + if (state.hours % 24 === 0 && state.hours >= 24) { + num.value = String(state.hours / 24); + unit.value = "24"; + } else { + num.value = String(state.hours); + unit.value = "1"; + } + const apply = el("button", { class: "btn small", type: "button", text: "Apply" }); + const commit = () => { + const n = parseInt(num.value, 10); + if (!n || n < 1) return; + state.hours = Math.min(n * parseInt(unit.value, 10), MAX_RANGE_HOURS); + onChange(); + }; + apply.addEventListener("click", commit); + num.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + commit(); + } + }); + box.appendChild(num); + box.appendChild(unit); + box.appendChild(apply); + return box; } /* The server scope picker: a
dropdown of checkboxes — "All servers (fleet)" plus each fleet server. From f4029453ea0dd81017e69a6df93713a5b87d4d90 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:39:27 -0400 Subject: [PATCH 013/296] CHANGELOG: [Unreleased] entries for #1619 Auto time bucket + custom range span (Added), the two-column grid cap (Changed), and the refresh-scope-reset fix (Fixed). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 768bb06d6..157085197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Darling Web: adaptive `auto` time bucket for composed time-series panels** ([#1619]) - a compose custom-view time-series panel can set `timeBucket: "auto"`, and the compiler resolves it to a concrete grain from the panel's window (minute up to 2 days, hour up to 60 days, day beyond) so any range from 1h to 90d renders a readable line and never trips the 5,000-bucket ceiling. Previously a fixed `hour` bucket collapsed a sub-hour workload (e.g. a 30-minute HammerDB run) into a single invisible point, while a fixed `minute` bucket errored past ~3.5 days. The composer now defaults new time-series panels to `auto` and the MCP `describe_custom_view_catalog` recommends it; non-auto buckets compile byte-for-byte as before. Pinned by `DarlingComposeTests` (auto boundary resolution + compile-by-window + non-auto passthrough). +- **Darling Web: custom time span on the view range picker** ([#1619]) - the rendered custom-view range picker gains a "Custom..." option (a number + a hours/days unit, up to the 90-day window ceiling) so a view is no longer limited to the six presets; a non-preset window stays selectable so it survives the 60s refresh. + ### Changed - **Full Dashboard and the CLI Installer are being retired.** They moved to a `deprecated/` folder and are no longer built into release artifacts; releases now ship Lite + Darling only, with Darling the flagship replacement for the Full Dashboard. The deprecated code still compiles and its tests run in CI. ([#1612]) +- **Darling Web: the custom-view panel grid is capped at two columns on wide screens** ([#1619]) - a dense view (e.g. four panels) no longer crams four-across on a wide monitor; the min column track is >=45% of the row so at most two columns ever fit, and a `span-2` (or lone) panel still takes the full width. ### Fixed - **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. +- **Darling Web: a custom view no longer resets the picked time range / server / filters every 60 seconds** ([#1619]) - the background refresh that keeps the dashboard live rebuilt each view from its declared default, snapping any range/server/filter change back within a minute. A per-view scope memory now survives the re-render, so a picked scope sticks until you change it or hard-reload. ## [3.2.0] - 2026-07-21 @@ -504,6 +511,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1609]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1609 [#1612]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1612 [#1614]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1614 +[#1619]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1619 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 From b5ea6f18ac699d9f3e3172df9686e6acf8e4c58f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:24:23 -0400 Subject: [PATCH 014/296] Darling: composer covering indexes + autovacuum tuning as idempotent startup provisioning Erik's EXPLAIN-backed fix for the Custom Views composer + analyze_*_plan timeouts, applied as results-invariant RUNTIME tuning rather than a versioned migration. Six indexes: three COVERING (INCLUDE the aggregate columns the composer SUM/AVGs, for an Index Only Scan) on procedure_stats.object_name / query_stats.query_hash / query_store_stats.query_hash, and three (server_id, handle/hash/id, collection_time DESC) lookup indexes for the single-row analyze_*_plan reads. Plus a per-table autovacuum_vacuum_insert_scale_factor = 0.02 override so the visibility map stays current on pure-insert hypertable chunks (the default 0.2 leaves the day's hot chunk stale before the daily TimescaleDB rollover, degrading Index Only Scan back to heap fetches). New PgTableTuning applies them idempotently (CREATE INDEX IF NOT EXISTS / ALTER TABLE SET), failure-isolated, at startup after migrations and the TimescaleDB block, before collectors (so the tables are already hypertables when indexed, and a first build never contends with live inserts). Deliberately NOT a migration: results-invariant perf, so a SchemaVersion bump would gate the viewer on indexes it does not need (the role-GUC provisioning reasoning). StoredPlanReader and ComposeCompiler are unchanged. Verified on a 137 GB field store: two panels that timed out at 15 s now run in 139 ms / 514 ms with 0 heap fetches. Co-Authored-By: Claude Opus 4.8 --- Darling/Darling.Tests/PgTableTuningTests.cs | 61 ++++++++++ .../DarlingWorker.cs | 17 +++ .../PgTableTuning.cs | 105 ++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 Darling/Darling.Tests/PgTableTuningTests.cs create mode 100644 Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.cs diff --git a/Darling/Darling.Tests/PgTableTuningTests.cs b/Darling/Darling.Tests/PgTableTuningTests.cs new file mode 100644 index 000000000..20527533c --- /dev/null +++ b/Darling/Darling.Tests/PgTableTuningTests.cs @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of 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.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the composer performance-tuning statements (covering indexes + per-table autovacuum-insert override, +/// Erik's EXPLAIN-backed field fix) so the tested SQL can never silently drift. These are applied as idempotent +/// RUNTIME setup (), NOT a versioned migration, so they do not bump StorageVersion or +/// gate the Viewer — the reason there is no schema-version change to pin here. +/// +public sealed class PgTableTuningTests +{ + [Fact] + public void Statements_AreTheCoveringComposerIndexes_TheLookupIndexes_AndTheAutovacuumOverride() + { + var sql = string.Join("\n", PgTableTuning.Statements); + + /* Three COVERING composer indexes (INCLUDE the aggregate columns -> Index Only Scan), schema-qualified collect. */ + Assert.Contains("idx_procedure_stats_object_name ON collect.procedure_stats (object_name, collection_time) INCLUDE (database_name, delta_worker_time, delta_elapsed_time, delta_execution_count)", sql, StringComparison.Ordinal); + Assert.Contains("idx_query_stats_query_hash ON collect.query_stats (query_hash, collection_time) INCLUDE (database_name, delta_worker_time, delta_elapsed_time, delta_execution_count)", sql, StringComparison.Ordinal); + Assert.Contains("idx_query_store_stats_query_hash ON collect.query_store_stats (query_hash, collection_time) INCLUDE (database_name, module_name, execution_count, avg_duration_us, max_duration_us, avg_cpu_time_us, max_cpu_time_us)", sql, StringComparison.Ordinal); + + /* Three single-row analyze_*_plan lookup indexes (no INCLUDE — one heap fetch is cheap). */ + Assert.Contains("idx_procedure_stats_server_handle_time ON collect.procedure_stats (server_id, sql_handle, collection_time DESC)", sql, StringComparison.Ordinal); + Assert.Contains("idx_query_stats_server_hash_time ON collect.query_stats (server_id, query_hash, collection_time DESC)", sql, StringComparison.Ordinal); + Assert.Contains("idx_query_store_stats_server_db_query_plan_time ON collect.query_store_stats (server_id, database_name, query_id, plan_id, collection_time DESC)", sql, StringComparison.Ordinal); + + /* Every index is idempotent (no-op where a field box already hand-applied it, or a prior start made it). */ + Assert.Equal(6, CountOccurrences(sql, "CREATE INDEX IF NOT EXISTS")); + Assert.DoesNotContain("CREATE INDEX ON", sql, StringComparison.Ordinal); + + /* Per-table autovacuum-insert override on exactly the three growing tables (NOT a global GUC change). */ + Assert.Contains("ALTER TABLE collect.procedure_stats SET (autovacuum_vacuum_insert_scale_factor = 0.02, autovacuum_vacuum_insert_threshold = 10000)", sql, StringComparison.Ordinal); + Assert.Contains("ALTER TABLE collect.query_stats SET (autovacuum_vacuum_insert_scale_factor = 0.02, autovacuum_vacuum_insert_threshold = 10000)", sql, StringComparison.Ordinal); + Assert.Contains("ALTER TABLE collect.query_store_stats SET (autovacuum_vacuum_insert_scale_factor = 0.02, autovacuum_vacuum_insert_threshold = 10000)", sql, StringComparison.Ordinal); + + Assert.Equal(9, PgTableTuning.Statements.Count); + } + + private static int CountOccurrences(string haystack, string needle) + { + int count = 0, i = 0; + while ((i = haystack.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) + { + count++; + i += needle.Length; + } + + return count; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index 6599bab8d..69314f4ea 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -599,6 +599,23 @@ guard skips the conversion and this heals it (collection_log is outside the coll _logger.LogWarning("TimescaleDB setup failed — continuing in plain-PostgreSQL mode: {Message}", ex.Message); } + /* Composer + analyze_*_plan performance tuning (covering indexes + per-table autovacuum-insert override) — + idempotent RUNTIME setup, NOT a versioned migration: results-invariant perf, so it must not bump + StorageVersion and gate the Viewer's schema check on indexes it does not need (the role-GUC provisioning + reasoning). AFTER the TimescaleDB block so the collector tables are already hypertables when indexed + (CREATE INDEX / ALTER TABLE SET propagate to all chunks), BEFORE collectors so a first build never + contends with live inserts. Its own try/catch: a failure degrades to un-tuned (slower) queries, never + fatal (the same optional-feature discipline as the TimescaleDB block above). */ + try + { + await using var tuningConnection = await postgres.OpenConnectionAsync(stoppingToken); + await PgTableTuning.ApplyAsync(tuningConnection, _logger, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning("Composer performance tuning failed — queries fall back to un-indexed scans: {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. */ diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.cs b/Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.cs new file mode 100644 index 000000000..5f98fc4ec --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/PgTableTuning.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.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace PerformanceMonitor.Darling.Storage; + +/// +/// Composer + analyze_*_plan performance tuning — RUNTIME setup, deliberately NOT a versioned migration. These +/// are RESULTS-INVARIANT covering indexes plus a per-table autovacuum-insert override: the Custom Views composer +/// and the analyze_*_plan reads return identical rows with or without them, just slower, so they must NOT bump +/// — that would gate the Viewer's connect-time schema check on perf indexes it does +/// not need (the same reasoning that keeps role GUCs / GRANTs in role provisioning rather than a migration). The +/// service applies them once at startup AFTER migrations and the TimescaleDB block, BEFORE collectors: so a fresh +/// store's collector tables are already hypertables when indexed (CREATE INDEX / ALTER TABLE SET propagate to all +/// existing and future chunks), a plain-PostgreSQL store gets them too, and a first build on a large adopted store +/// never contends with live inserts. Idempotent (CREATE INDEX IF NOT EXISTS / ALTER TABLE SET), so every restart +/// re-converges and a store that already has them (a field box hand-indexed, or a prior start) no-ops. +/// +/// EXPLAIN-backed field fix (2026-07-22): the composer filtered by object_name / query_hash +/// fell to a Seq Scan and timed out at the 15 s statement_timeout; a PLAIN index still paid a Bitmap Heap Scan +/// re-fetching the aggregate columns, so the three composer indexes are COVERING (INCLUDE exactly the +/// measure-catalog columns the Procedures / Queries / Query Store measures SUM/AVG) for an Index Only Scan. The +/// three _server_..._time indexes back the single-row analyze_*_plan lookups (ORDER BY collection_time DESC +/// LIMIT 1 — one heap fetch, no INCLUDE needed). The per-table +/// autovacuum_vacuum_insert_scale_factor = 0.02 override keeps the visibility map current on the pure-insert +/// hypertable chunks (the Postgres default 0.2 leaves the day's hot chunk stale before the daily TimescaleDB +/// rollover, degrading the Index Only Scan back to heap fetches). Verified: the two panels that timed out at 15 s +/// then ran in 139 ms / 514 ms with 0 heap fetches on vacuumed chunks. DarlingStoredPlanReader and ComposeCompiler +/// are unchanged — correct as-is, they just needed these indexes + the vacuum state to perform. +/// +public static class PgTableTuning +{ + /* A first CREATE INDEX on a large adopted store can take a while (it runs pre-collection, so it blocks no + live inserts, but the build itself is real work) — the same generous budget the TimescaleDB first + conversion uses. */ + private const int SetupTimeoutSeconds = 300; + + /// + /// The idempotent tuning statements, applied in order, each on its own command (failure-isolated). Three + /// COVERING composer indexes (INCLUDE the aggregate columns the Procedures / Queries / Query Store measures + /// SUM/AVG, for an Index Only Scan), three (server_id, handle/hash/id, collection_time DESC) lookup + /// indexes for the single-row analyze_*_plan reads (no INCLUDE — one heap fetch is cheap), then the per-table + /// autovacuum-insert override on exactly the three growing tables. Bare collect-qualified names; every + /// identifier is a compile-time constant, never user input, so interpolation is not a concern. + /// + public static IReadOnlyList Statements { get; } = new[] + { + "CREATE INDEX IF NOT EXISTS idx_procedure_stats_object_name ON collect.procedure_stats (object_name, collection_time) INCLUDE (database_name, delta_worker_time, delta_elapsed_time, delta_execution_count)", + "CREATE INDEX IF NOT EXISTS idx_procedure_stats_server_handle_time ON collect.procedure_stats (server_id, sql_handle, collection_time DESC)", + "CREATE INDEX IF NOT EXISTS idx_query_stats_query_hash ON collect.query_stats (query_hash, collection_time) INCLUDE (database_name, delta_worker_time, delta_elapsed_time, delta_execution_count)", + "CREATE INDEX IF NOT EXISTS idx_query_stats_server_hash_time ON collect.query_stats (server_id, query_hash, collection_time DESC)", + "CREATE INDEX IF NOT EXISTS idx_query_store_stats_query_hash ON collect.query_store_stats (query_hash, collection_time) INCLUDE (database_name, module_name, execution_count, avg_duration_us, max_duration_us, avg_cpu_time_us, max_cpu_time_us)", + "CREATE INDEX IF NOT EXISTS idx_query_store_stats_server_db_query_plan_time ON collect.query_store_stats (server_id, database_name, query_id, plan_id, collection_time DESC)", + "ALTER TABLE collect.procedure_stats SET (autovacuum_vacuum_insert_scale_factor = 0.02, autovacuum_vacuum_insert_threshold = 10000)", + "ALTER TABLE collect.query_stats SET (autovacuum_vacuum_insert_scale_factor = 0.02, autovacuum_vacuum_insert_threshold = 10000)", + "ALTER TABLE collect.query_store_stats SET (autovacuum_vacuum_insert_scale_factor = 0.02, autovacuum_vacuum_insert_threshold = 10000)", + }; + + /// + /// Applies every statement, each on its own command and FAILURE-ISOLATED: a single + /// failure (e.g. a column absent on an odd store) warns and the sweep continues — the store keeps working, + /// just without that one index. Returns the count that applied (or no-op'd) cleanly. Idempotent, so a re-run + /// on an already-tuned store no-ops. + /// + public static async Task ApplyAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var applied = 0; + foreach (var statement in Statements) + { + try + { + using var command = new NpgsqlCommand(statement, connection) { CommandTimeout = SetupTimeoutSeconds }; + await command.ExecuteNonQueryAsync(cancellationToken); + applied++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + "Composer performance-tuning statement failed — the store keeps working without it ({Statement}): {Message}", + statement, ex.Message); + } + } + + logger?.LogInformation( + "Composer performance tuning applied ({Applied}/{Total} covering-index / autovacuum statements)", + applied, Statements.Count); + return applied; + } +} From 2681186a9fb04d6c2eb868a07d2de7b1867e2492 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:23:56 -0400 Subject: [PATCH 015/296] Darling: hourly continuous aggregates for query_stats + procedure_stats (query acceleration) query_stats (~145 GB) and procedure_stats (~49 GB) are ~90% of the store, and every Custom Views composer panel over them does date_trunc('hour', collection_time) + SUM(delta_*) GROUP BY a dimension over raw per-sweep rows. Two hourly TimescaleDB continuous aggregates (query_stats_hourly, procedure_stats_hourly) pre-materialize exactly that shape, grouped by the same dimensions the composer's MeasureCatalog uses (server/database/query_hash or object_name), with SUM/MIN/MAX per delta column + a sample_count so avg composes correctly at query time (not a materialized average). Created in TimescaleSupport.EnsureContinuousAggregatesAsync, run in the worker's TimescaleDB block after hypertable + compression setup (so the source is already a hypertable), idempotent (IF NOT EXISTS + if_not_exists policy), failure-isolated. WITH NO DATA + real-time aggregation left on, so the views are correct to query immediately; the heavy historical backfill is a deliberate off-hours manual op, not startup work. Refresh policy is the conservative hourly shape (start_offset 3d, end_offset 1h). Query acceleration only: raw rows still exist for the hot window (analyze_*_plan needs them), and this makes no retention decision. ~49x / ~32x row reduction measured on the field fleet. Co-Authored-By: Claude Opus 4.8 --- .../TimescaleContinuousAggregateTests.cs | 77 ++++++++++ .../DarlingWorker.cs | 1 + .../TimescaleSupport.cs | 138 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs diff --git a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs new file mode 100644 index 000000000..75994c213 --- /dev/null +++ b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.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 PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the hourly continuous-aggregate definitions (the query_stats / procedure_stats query-acceleration +/// rollups) so the tested SQL shape can never silently drift. These are idempotent RUNTIME setup in +/// (created in the worker's TimescaleDB block), NOT a versioned migration, so +/// there is no schema-version change to pin. Query acceleration only — not retention. +/// +public sealed class TimescaleContinuousAggregateTests +{ + [Fact] + public void QueryStatsHourly_IsAContinuousAggregate_HourBucketed_GroupedByComposerDimensions() + { + var sql = TimescaleSupport.CreateQueryStatsHourlySql; + + Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_stats_hourly", sql, StringComparison.Ordinal); + Assert.Contains("WITH (timescaledb.continuous)", sql, StringComparison.Ordinal); + Assert.Contains("time_bucket('1 hour', collection_time) AS bucket", sql, StringComparison.Ordinal); + Assert.Contains("FROM collect.query_stats", sql, StringComparison.Ordinal); + /* Grouped by the composer's query_stats dimensions, in order, so a panel points here with no remapping. */ + Assert.Contains("GROUP BY server_id, server_name, database_name, query_hash, bucket", sql, StringComparison.Ordinal); + + /* SUM/MIN/MAX on each per-interval delta (so avg composes at query time as sum/count) + a sample_count; + deliberately NO materialized average (it would not re-aggregate correctly). */ + foreach (var col in new[] { "delta_worker_time", "delta_elapsed_time", "delta_execution_count" }) + { + Assert.Contains("sum(" + col + ")", sql, StringComparison.Ordinal); + Assert.Contains("min(" + col + ")", sql, StringComparison.Ordinal); + Assert.Contains("max(" + col + ")", sql, StringComparison.Ordinal); + } + + Assert.Contains("count(*) AS sample_count", sql, StringComparison.Ordinal); + Assert.DoesNotContain("avg(", sql, StringComparison.Ordinal); + + /* WITH NO DATA (no startup backfill), and real-time aggregation left ON (no materialized_only) so the + view is correct to query for any window immediately. */ + Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); + Assert.DoesNotContain("materialized_only", sql, StringComparison.Ordinal); + } + + [Fact] + public void ProcedureStatsHourly_MirrorsQueryStats_GroupedByObjectName() + { + var sql = TimescaleSupport.CreateProcedureStatsHourlySql; + + Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_hourly", sql, StringComparison.Ordinal); + Assert.Contains("WITH (timescaledb.continuous)", sql, StringComparison.Ordinal); + Assert.Contains("FROM collect.procedure_stats", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id, server_name, database_name, object_name, bucket", sql, StringComparison.Ordinal); + Assert.Contains("count(*) AS sample_count", sql, StringComparison.Ordinal); + Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); + } + + [Fact] + public void ContinuousAggregatePolicy_IsTheConservativeHourlyShape_Idempotent() + { + var sql = TimescaleSupport.AddContinuousAggregatePolicySql(TimescaleSupport.QueryStatsHourlyView); + + Assert.Contains("add_continuous_aggregate_policy('collect.query_stats_hourly'", sql, StringComparison.Ordinal); + Assert.Contains("start_offset => INTERVAL '3 days'", sql, StringComparison.Ordinal); + Assert.Contains("end_offset => INTERVAL '1 hour'", sql, StringComparison.Ordinal); + Assert.Contains("schedule_interval => INTERVAL '1 hour'", sql, StringComparison.Ordinal); + Assert.Contains("if_not_exists => true", sql, StringComparison.Ordinal); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index 6599bab8d..e2b074290 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -589,6 +589,7 @@ guard skips the conversion and this heals it (collection_log is outside the coll await TimescaleSupport.ConvertToHypertablesAsync(timescaleConnection, _logger, stoppingToken); await TimescaleSupport.ApplyCompressionPolicyAsync(timescaleConnection, _logger, stoppingToken); await TimescaleSupport.EnsureCollectionLogHypertableAsync(timescaleConnection, _logger, stoppingToken); + await TimescaleSupport.EnsureContinuousAggregatesAsync(timescaleConnection, _logger, stoppingToken); } } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index de3c1fde5..36c2a54ce 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -228,6 +228,144 @@ public static string AddCompressionPolicySql(ICollectorSchemaInfo schema) public static string AddCompressionPolicySql(string table) => $"SELECT add_compression_policy('{table}', compress_after => INTERVAL '{CompressAfterDays} days', if_not_exists => true)"; + /* ─────────────────────────── continuous aggregates (query acceleration) ─────────────────────────── */ + + /// The hourly continuous-aggregate view names — query-acceleration rollups for the two tables that + /// dominate the store (query_stats ~145 GB, procedure_stats ~49 GB, ~90% together). Every Custom Views + /// composer panel over these tables does date_trunc('hour', collection_time) + SUM(delta_*) GROUP BY a + /// dimension; these pre-materialize exactly that shape so anything older than the ~2-day hot window reads the + /// rollup instead of scanning raw per-sweep rows. NOT retention (raw still exists for the hot window; dropping + /// old raw chunks is a separate, unmade decision). + public const string QueryStatsHourlyView = "query_stats_hourly"; + + /// 's procedure_stats sibling. + public const string ProcedureStatsHourlyView = "procedure_stats_hourly"; + + /// + /// The query_stats hourly continuous aggregate. 1-hour buckets grouped by the SAME dimensions the composer's + /// MeasureCatalog uses for query_stats (server_id / server_name / database_name / query_hash), so a + /// panel can point here with no dimension remapping. SUM/MIN/MAX on each per-interval DELTA column (NOT a + /// pre-divided average — avg composes at query time as sum/execution_count_sum, which re-aggregates + /// correctly; a materialized average would not) plus a sample_count. Summing the deltas is + /// double-count-safe: they are Darling's own per-interval deltas, not raw cumulative DMV counters. Created + /// WITH NO DATA — a full historical refresh over 145 GB is heavy I/O, a deliberate off-hours manual op, NEVER + /// startup work; real-time aggregation stays ON (the default — no materialized_only), so the view is + /// correct to query for any window immediately, just un-accelerated for old windows until the policy + a + /// manual backfill materialize them. IF NOT EXISTS so a restart re-converges. A SINGLE statement: a CAGG + /// CREATE cannot run inside a transaction, so it must never be batched with the policy call. + /// + public const string CreateQueryStatsHourlySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_stats_hourly +WITH (timescaledb.continuous) AS +SELECT + server_id, + server_name, + database_name, + query_hash, + time_bucket('1 hour', collection_time) AS bucket, + sum(delta_worker_time) AS worker_time_sum, + min(delta_worker_time) AS worker_time_min, + max(delta_worker_time) AS worker_time_max, + sum(delta_elapsed_time) AS elapsed_time_sum, + min(delta_elapsed_time) AS elapsed_time_min, + max(delta_elapsed_time) AS elapsed_time_max, + sum(delta_execution_count) AS execution_count_sum, + min(delta_execution_count) AS execution_count_min, + max(delta_execution_count) AS execution_count_max, + count(*) AS sample_count +FROM collect.query_stats +GROUP BY server_id, server_name, database_name, query_hash, bucket +WITH NO DATA"; + + /// The procedure_stats hourly continuous aggregate — 's + /// sibling, grouped by object_name instead of query_hash (procedure_stats' composer + /// dimension). Same aggregation shape, same WITH NO DATA + IF NOT EXISTS discipline. + public const string CreateProcedureStatsHourlySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_hourly +WITH (timescaledb.continuous) AS +SELECT + server_id, + server_name, + database_name, + object_name, + time_bucket('1 hour', collection_time) AS bucket, + sum(delta_worker_time) AS worker_time_sum, + min(delta_worker_time) AS worker_time_min, + max(delta_worker_time) AS worker_time_max, + sum(delta_elapsed_time) AS elapsed_time_sum, + min(delta_elapsed_time) AS elapsed_time_min, + max(delta_elapsed_time) AS elapsed_time_max, + sum(delta_execution_count) AS execution_count_sum, + min(delta_execution_count) AS execution_count_min, + max(delta_execution_count) AS execution_count_max, + count(*) AS sample_count +FROM collect.procedure_stats +GROUP BY server_id, server_name, database_name, object_name, bucket +WITH NO DATA"; + + /// + /// The hourly refresh policy for a continuous aggregate: materialize the [now - 3 days, now - 1 hour] + /// window each hour. start_offset 3 days gives margin past the ~2-day compression/hot window (covers + /// same-day-arriving corrections); end_offset 1 hour leaves the still-filling current hour + /// unmaterialized (no repeated rework); schedule_interval matches the bucket. if_not_exists so a + /// restart re-converges. The conservative TimescaleDB default shape. + /// + public static string AddContinuousAggregatePolicySql(string view) + => $"SELECT add_continuous_aggregate_policy('collect.{view}', start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour', if_not_exists => true)"; + + /// + /// Creates the two hourly continuous aggregates ( / + /// ) and attaches each one's refresh policy + /// (). Runs in the worker's TimescaleDB block (CAGGs need the + /// extension), AFTER hypertables + compression are in place. The CREATE and the policy are SEPARATE commands + /// per aggregate — a CAGG CREATE cannot run inside a transaction, so it is never batched with another + /// statement. Failure-isolated per aggregate: one failure warns and the composer keeps querying raw. + /// Idempotent (IF NOT EXISTS on both), so it re-converges every restart. Does NOT backfill history (WITH NO + /// DATA + real-time aggregation keeps the view correct immediately; the heavy full refresh is a deliberate + /// off-hours op). Returns the number ready. + /// + public static async Task EnsureContinuousAggregatesAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var aggregates = new[] + { + (CreateSql: CreateQueryStatsHourlySql, View: QueryStatsHourlyView), + (CreateSql: CreateProcedureStatsHourlySql, View: ProcedureStatsHourlyView), + }; + + var ready = 0; + foreach (var (createSql, view) in aggregates) + { + try + { + using (var create = new NpgsqlCommand(createSql, connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await create.ExecuteNonQueryAsync(cancellationToken); + } + + using (var policy = new NpgsqlCommand(AddContinuousAggregatePolicySql(view), connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await policy.ExecuteNonQueryAsync(cancellationToken); + } + + ready++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + "Continuous aggregate {View} setup failed — composer queries fall back to raw scans: {Message}", + view, ex.Message); + } + } + + logger?.LogInformation( + "TimescaleDB: {Ready}/{Total} hourly continuous aggregate(s) ready (query_stats, procedure_stats)", + ready, aggregates.Length); + return ready; + } + /// /// Converts every collector table to a hypertable ( scope; /// per table). Failure-isolated per table: one failed From cdeede2c919eb17d6cc27f3a31db5b166dc01e35 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:40:55 -0400 Subject: [PATCH 016/296] CHANGELOG: [Unreleased] entry for #1621 (continuous aggregates) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 163d191d2..b4e75dd34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling: `--configure-network` can now expose the WEB DASHBOARD** ([#1617]) - the wizard offered Store and MCP but not the web dashboard, even though `--enable-web`'s own output told operators to run `--configure-network` to expose it on the LAN - a dead end that forced hand-editing `web.network` into darling.json. Web is now a first-class third surface, fully symmetric with Store/MCP: its own menu choice (plus comma combinations like `1,3`, and `4` = all three), a keep-or-generate DPAPI access token, listen/CIDR inputs validated by the SAME bind resolver the web host fail-closes on (extracted as `ResolveWebBind`, the web twin of `ResolveMcpBind` - never a reimplementation), the comment-preserving `web.network` write, a one-time token print, and next-steps text including the browser login URL (`http://:/?token=...`, exchanged for a session cookie). Disable now removes all three network blocks. After the wizard, `--enable-web` opens the scoped firewall rule on the first try - no hand-editing required. - **Darling Web: adaptive `auto` time bucket for composed time-series panels** ([#1619]) - a compose custom-view time-series panel can set `timeBucket: "auto"`, and the compiler resolves it to a concrete grain from the panel's window (minute up to 2 days, hour up to 60 days, day beyond) so any range from 1h to 90d renders a readable line and never trips the 5,000-bucket ceiling. Previously a fixed `hour` bucket collapsed a sub-hour workload (e.g. a 30-minute HammerDB run) into a single invisible point, while a fixed `minute` bucket errored past ~3.5 days. The composer now defaults new time-series panels to `auto` and the MCP `describe_custom_view_catalog` recommends it; non-auto buckets compile byte-for-byte as before. Pinned by `DarlingComposeTests` (auto boundary resolution + compile-by-window + non-auto passthrough). - **Darling Web: custom time span on the view range picker** ([#1619]) - the rendered custom-view range picker gains a "Custom..." option (a number + a hours/days unit, up to the 90-day window ceiling) so a view is no longer limited to the six presets; a non-preset window stays selectable so it survives the 60s refresh. +- **Darling: hourly continuous aggregates for query_stats + procedure_stats (composer query acceleration)** ([#1621]) - the two collector tables that dominate the store (~90%: query_stats ~145 GB, procedure_stats ~49 GB) now have hourly TimescaleDB continuous aggregates (`query_stats_hourly` / `procedure_stats_hourly`) that pre-materialize the exact shape every Custom Views composer panel over them does (`date_trunc('hour', collection_time)` + `SUM(delta_*)` GROUP BY a dimension), grouped by the same `MeasureCatalog` dimensions with SUM/MIN/MAX per delta column + a sample_count (so avg composes correctly at query time). Created idempotently as runtime setup in the worker's TimescaleDB block (not a versioned migration), `WITH NO DATA` with real-time aggregation on and the conservative hourly refresh policy (start_offset 3d, end_offset 1h). Measured ~49x / ~32x row reduction on the field fleet. Query acceleration only - raw rows still exist for the ~2-day hot window (analyze_*_plan needs the plan XML), and this makes no retention decision. ### Changed @@ -514,6 +515,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1614]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1614 [#1619]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1619 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 +[#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 From eed0dfbe063bc66db66e05a4afa3ae4eb4d831f6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:44:24 -0400 Subject: [PATCH 017/296] CHANGELOG: [Unreleased] entry for #1620 (composer covering indexes + autovacuum tuning) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 163d191d2..d912bc579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. - **Darling Web: a custom view no longer resets the picked time range / server / filters every 60 seconds** ([#1619]) - the background refresh that keeps the dashboard live rebuilt each view from its declared default, snapping any range/server/filter change back within a minute. A per-view scope memory now survives the re-render, so a picked scope sticks until you change it or hard-reload. +- **Darling: the Custom Views composer and analyze_*_plan no longer time out on large stores** ([#1620]) - the columns the composer filters/groups by (`procedure_stats.object_name`, `query_stats.query_hash`, `query_store_stats.query_hash`) and the single-row `analyze_*_plan` lookups (`sql_handle` / `query_id`+`plan_id`) had no supporting index, so on a large store they fell to a Seq Scan and hit the 15 s statement_timeout. The service now applies (idempotently, at startup, NOT a versioned migration) six indexes - three COVERING (the aggregate columns the composer SUM/AVGs are `INCLUDE`d, for an Index Only Scan) plus three lookup indexes - and a per-table `autovacuum_vacuum_insert_scale_factor = 0.02` override so the visibility map stays current on the pure-insert hypertable chunks (the default 0.2 left the day's hot chunk stale before the daily TimescaleDB rollover, degrading the Index Only Scan back to heap fetches). EXPLAIN-verified on a 137 GB field store: two panels that timed out at 15 s now run in 139 ms / 514 ms with 0 heap fetches. Results-invariant perf tuning, so it deliberately does not bump the schema version or gate the viewer. ## [3.2.0] - 2026-07-21 @@ -513,6 +514,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1612]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1612 [#1614]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1614 [#1619]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1619 +[#1620]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1620 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 From 7f5aa30d8d7a6194cdabc87eb097ecf8a3ad0326 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:06:59 -0400 Subject: [PATCH 018/296] Darling + Lite: Long-Running Query sp_server_diagnostics exclusion also matches query text The exclusion keyed only off the wait type (wait_type NOT LIKE '%SP_SERVER_DIAGNOSTICS%'), which misses a sp_server_diagnostics session captured in a different wait. A field session (multi39, 2026-07-22) fired a false Long-Running Query alert because it was in PREEMPTIVE_XE_GETTARGETSTATE (sp_server_diagnostics also does Extended Events work) rather than its usual SP_SERVER_DIAGNOSTICS_SLEEP, so the wait-type match missed it. The filter now also excludes by query text (case-insensitive, NULL-safe) so it catches the session regardless of the wait it is captured in. Applied to both apps (DarlingAlertReadAdapter + Lite LocalDataService) for parity, plus a pin test. Co-Authored-By: Claude Opus 4.8 --- .../DarlingAlertReadAdapterTests.cs | 17 +++++++++++++++++ .../DarlingAlertReadAdapter.cs | 7 ++++++- Lite/Services/LocalDataService.WaitStats.cs | 6 +++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Darling/Darling.Tests/DarlingAlertReadAdapterTests.cs b/Darling/Darling.Tests/DarlingAlertReadAdapterTests.cs index 1d4d359c3..ab722c5a4 100644 --- a/Darling/Darling.Tests/DarlingAlertReadAdapterTests.cs +++ b/Darling/Darling.Tests/DarlingAlertReadAdapterTests.cs @@ -39,6 +39,23 @@ public sealed class DarlingAlertReadAdapterTests private static readonly string TestServerKey = TestServerId.ToString(CultureInfo.InvariantCulture); private const string TestServerName = "alert-adapter-e2e"; + /// + /// Regression guard: the sp_server_diagnostics exclusion must match on BOTH the wait type and the query text. + /// A field session (multi39, 2026-07-22) fired a false Long-Running Query alert because sp_server_diagnostics + /// was captured in a PREEMPTIVE_XE_GETTARGETSTATE wait (it also does Extended Events work), which the + /// wait-type-only match missed. The query-text match (case-insensitive, NULL-safe) catches it regardless of + /// the wait it is in at capture time. Ungated (a pure SQL-fragment pin). + /// + [Fact] + public void SpServerDiagnosticsFilter_AlsoMatchesQueryText_NotJustWaitType_AndIsNullSafe() + { + var filter = DarlingAlertReadAdapter.SpServerDiagnosticsFilter; + Assert.Contains("wait_type NOT LIKE '%SP_SERVER_DIAGNOSTICS%'", filter, StringComparison.Ordinal); + Assert.Contains("query_text NOT ILIKE '%sp_server_diagnostics%'", filter, StringComparison.Ordinal); + /* NULL-safe: a legitimate long-running query with a NULL query_text must NOT be dropped by this filter. */ + Assert.Contains("r.query_text IS NULL OR", filter, StringComparison.Ordinal); + } + private static readonly string[] AllFeedSql = { DarlingAlertReadAdapter.BlockedProcessReportsSql, diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs index 6dfca234d..f99cd2b12 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingAlertReadAdapter.cs @@ -292,7 +292,12 @@ 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%'"; + /* sp_server_diagnostics (the AG/FCI health-check session) usually sits in SP_SERVER_DIAGNOSTICS_SLEEP, but it + also does Extended Events work, so it can be captured in a different wait (e.g. PREEMPTIVE_XE_GETTARGETSTATE) + where the wait-type match alone misses it and the Long-Running Query alert fires anyway. The query-text + match (case-insensitive, NULL-safe) catches it regardless of the wait it happens to be in at capture time. */ + public const string SpServerDiagnosticsFilter = + "AND r.wait_type NOT LIKE '%SP_SERVER_DIAGNOSTICS%'\n AND (r.query_text IS NULL OR r.query_text NOT ILIKE '%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')"; diff --git a/Lite/Services/LocalDataService.WaitStats.cs b/Lite/Services/LocalDataService.WaitStats.cs index 36fdf0715..67c2097e8 100644 --- a/Lite/Services/LocalDataService.WaitStats.cs +++ b/Lite/Services/LocalDataService.WaitStats.cs @@ -590,8 +590,12 @@ public async Task> GetLongRunningQueriesAsync( var thresholdMs = (long)thresholdMinutes * 60 * 1000; + // sp_server_diagnostics usually sits in SP_SERVER_DIAGNOSTICS_SLEEP, but it also does Extended Events work, + // so it can be captured in a different wait (e.g. PREEMPTIVE_XE_GETTARGETSTATE) where the wait-type match + // alone misses it and the alert fires; the query-text match (case-insensitive, NULL-safe) catches it + // regardless of the wait it happens to be in at capture time. string spServerDiagnosticsFilter = excludeSpServerDiagnostics - ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%'" : ""; + ? "AND r.wait_type NOT LIKE N'%SP_SERVER_DIAGNOSTICS%' AND (r.query_text IS NULL OR r.query_text NOT ILIKE N'%sp_server_diagnostics%')" : ""; string waitForFilter = excludeWaitFor ? "AND r.wait_type NOT IN (N'WAITFOR', N'BROKER_RECEIVE_WAITFOR')" : ""; string backupsFilter = excludeBackups From fb7b9770602b181748d875731f223bc604b7d44c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:08:48 -0400 Subject: [PATCH 019/296] CHANGELOG: [Unreleased] entry for #1622 (sp_server_diagnostics filter fix) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb0bfe58..9cd87b8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. - **Darling Web: a custom view no longer resets the picked time range / server / filters every 60 seconds** ([#1619]) - the background refresh that keeps the dashboard live rebuilt each view from its declared default, snapping any range/server/filter change back within a minute. A per-view scope memory now survives the re-render, so a picked scope sticks until you change it or hard-reload. - **Darling: the Custom Views composer and analyze_*_plan no longer time out on large stores** ([#1620]) - the columns the composer filters/groups by (`procedure_stats.object_name`, `query_stats.query_hash`, `query_store_stats.query_hash`) and the single-row `analyze_*_plan` lookups (`sql_handle` / `query_id`+`plan_id`) had no supporting index, so on a large store they fell to a Seq Scan and hit the 15 s statement_timeout. The service now applies (idempotently, at startup, NOT a versioned migration) six indexes - three COVERING (the aggregate columns the composer SUM/AVGs are `INCLUDE`d, for an Index Only Scan) plus three lookup indexes - and a per-table `autovacuum_vacuum_insert_scale_factor = 0.02` override so the visibility map stays current on the pure-insert hypertable chunks (the default 0.2 left the day's hot chunk stale before the daily TimescaleDB rollover, degrading the Index Only Scan back to heap fetches). EXPLAIN-verified on a 137 GB field store: two panels that timed out at 15 s now run in 139 ms / 514 ms with 0 heap fetches. Results-invariant perf tuning, so it deliberately does not bump the schema version or gate the viewer. +- **Darling + Lite: the Long-Running Query alert's `sp_server_diagnostics` exclusion no longer fires a false alert when the health-check session is in a non-diagnostics wait** ([#1622]) - the exclusion keyed only off the wait type (`wait_type NOT LIKE '%SP_SERVER_DIAGNOSTICS%'`), but sp_server_diagnostics also does Extended Events work, so a field session (multi39) captured in `PREEMPTIVE_XE_GETTARGETSTATE` slipped the wait-type match and fired a false Long-Running Query alert (self-resolved 36 s later; nil real impact, but the filter missed its named target). The exclusion now also matches the query text (`query_text NOT ILIKE '%sp_server_diagnostics%'`, case-insensitive and NULL-safe), catching the session regardless of the wait it is captured in. Fixed in both apps for parity, plus a pin test. ## [3.2.0] - 2026-07-21 @@ -516,6 +517,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1614]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1614 [#1619]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1619 [#1620]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1620 +[#1622]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1622 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 From 35f6e3b3f69c67cad09320734eda62a46b219a7e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:17:25 -0400 Subject: [PATCH 020/296] =?UTF-8?q?Darling:=20three-tier=20retention=20?= =?UTF-8?q?=E2=80=94=20raw=204d=20/=20hourly=20CAGGs=2021d=20/=20daily=20C?= =?UTF-8?q?AGGs=20kept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the retention layer on top of the hourly CAGGs (#1621): downsample and bucketize past the raw hot window instead of dropping outright, so nothing is fully lost, just coarsened. All idempotent RUNTIME setup in TimescaleSupport, mirroring the hourly CAGGs. - query_store_stats_hourly CAGG (built ahead of a writable-QS primary; empty on a read-only replica until one is added). - Hierarchical DAILY CAGGs query_stats_daily / procedure_stats_daily, sourced from the hourly CAGGs (not raw; 2.28.1 supports CAGG-on-CAGG), 1-day refresh cadence. GROUP BY uses the explicit time_bucket('1 day', bucket) expression, NOT the bare `bucket` alias (which binds to the hourly source column under Postgres's input-column-wins rule and would group by hour, not day). - Tiered retention via add_retention_policy: raw tables 4d, hourly CAGGs 21d; the daily CAGGs get no policy (kept indefinitely). Each tier's horizon stays past the next tier's 3-day refresh start, so a drop never outruns the aggregate preserving it. Wired into DarlingWorker after the CAGG step. query_store_stats_daily is deferred until QS-hourly has real data to source from. Cold-start caveat (documented on EnsureRetentionPoliciesAsync): on an EXISTING store with raw older than the hourly CAGG has materialized, the first raw drop can remove buckets the CAGG never captured — backfill the hourly CAGGs past the raw horizon before retention runs. Fresh installs are safe automatically. Pinned by TimescaleContinuousAggregateTests (9 tests). Co-Authored-By: Claude Opus 4.8 --- .../TimescaleContinuousAggregateTests.cs | 95 +++++++- .../DarlingWorker.cs | 2 + .../TimescaleSupport.cs | 209 ++++++++++++++++-- 3 files changed, 286 insertions(+), 20 deletions(-) diff --git a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs index 75994c213..8e3a4ebb3 100644 --- a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs +++ b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs @@ -13,10 +13,11 @@ namespace Darling.Tests; /// -/// Pins the hourly continuous-aggregate definitions (the query_stats / procedure_stats query-acceleration -/// rollups) so the tested SQL shape can never silently drift. These are idempotent RUNTIME setup in -/// (created in the worker's TimescaleDB block), NOT a versioned migration, so -/// there is no schema-version change to pin. Query acceleration only — not retention. +/// Pins the continuous-aggregate + retention-tier SQL so the tested shape can never silently drift: the three +/// HOURLY rollups (query_stats / procedure_stats / query_store_stats), the two HIERARCHICAL daily rollups (sourced +/// from the hourly CAGGs, not raw), the per-cadence refresh policies, and the tiered retention (raw 4d, hourly +/// CAGGs 21d, daily kept indefinitely). All idempotent RUNTIME setup in (the +/// worker's TimescaleDB block), NOT a versioned migration, so there is no schema-version change to pin. /// public sealed class TimescaleContinuousAggregateTests { @@ -74,4 +75,90 @@ public void ContinuousAggregatePolicy_IsTheConservativeHourlyShape_Idempotent() Assert.Contains("schedule_interval => INTERVAL '1 hour'", sql, StringComparison.Ordinal); Assert.Contains("if_not_exists => true", sql, StringComparison.Ordinal); } + + [Fact] + public void QueryStoreStatsHourly_GroupedByQsIdentity_SumsCount_AveragesTheAvgColumns() + { + var sql = TimescaleSupport.CreateQueryStoreStatsHourlySql; + + Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_store_stats_hourly", sql, StringComparison.Ordinal); + Assert.Contains("WITH (timescaledb.continuous)", sql, StringComparison.Ordinal); + Assert.Contains("time_bucket('1 hour', collection_time) AS bucket", sql, StringComparison.Ordinal); + Assert.Contains("FROM collect.query_store_stats", sql, StringComparison.Ordinal); + /* QS's own identity (query_id / plan_id), not a query_hash. */ + Assert.Contains("GROUP BY server_id, server_name, database_name, query_id, plan_id, bucket", sql, StringComparison.Ordinal); + /* QS carries already-aggregated figures (not Darling deltas): SUM the count, MAX the max_*, avg-of-avgs on avg_*. */ + Assert.Contains("sum(execution_count) AS execution_count_sum", sql, StringComparison.Ordinal); + Assert.Contains("avg(avg_duration_us) AS avg_duration_us_avg", sql, StringComparison.Ordinal); + Assert.Contains("max(max_duration_us) AS max_duration_us_max", sql, StringComparison.Ordinal); + Assert.Contains("avg(avg_cpu_time_us) AS avg_cpu_time_us_avg", sql, StringComparison.Ordinal); + Assert.Contains("max(max_cpu_time_us) AS max_cpu_time_us_max", sql, StringComparison.Ordinal); + Assert.Contains("count(*) AS sample_count", sql, StringComparison.Ordinal); + Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); + } + + [Fact] + public void QueryStatsDaily_IsHierarchical_SourcedFromHourlyCagg_GroupedByExplicitDayBucket() + { + var sql = TimescaleSupport.CreateQueryStatsDailySql; + + Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_stats_daily", sql, StringComparison.Ordinal); + Assert.Contains("WITH (timescaledb.continuous)", sql, StringComparison.Ordinal); + /* HIERARCHICAL: sourced from the hourly CAGG, NOT raw. */ + Assert.Contains("FROM collect.query_stats_hourly", sql, StringComparison.Ordinal); + Assert.Contains("time_bucket('1 day', bucket) AS bucket", sql, StringComparison.Ordinal); + /* GROUP BY uses the explicit time_bucket EXPRESSION, never the bare `bucket` alias: a bare alias binds to + the hourly source column under Postgres's input-column-wins rule and would group by hour, not day. */ + Assert.Contains("GROUP BY server_id, server_name, database_name, query_hash, time_bucket('1 day', bucket)", sql, StringComparison.Ordinal); + /* Re-aggregates the hourly rollup: SUM of sums, MIN of mins, MAX of maxes, SUM of the sample_counts. */ + Assert.Contains("sum(worker_time_sum) AS worker_time_sum", sql, StringComparison.Ordinal); + Assert.Contains("min(worker_time_min) AS worker_time_min", sql, StringComparison.Ordinal); + Assert.Contains("max(worker_time_max) AS worker_time_max", sql, StringComparison.Ordinal); + Assert.Contains("sum(sample_count) AS sample_count", sql, StringComparison.Ordinal); + Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); + } + + [Fact] + public void ProcedureStatsDaily_MirrorsQueryStatsDaily_FromProcedureHourly_ByObjectName() + { + var sql = TimescaleSupport.CreateProcedureStatsDailySql; + + Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_daily", sql, StringComparison.Ordinal); + Assert.Contains("FROM collect.procedure_stats_hourly", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id, server_name, database_name, object_name, time_bucket('1 day', bucket)", sql, StringComparison.Ordinal); + Assert.Contains("sum(sample_count) AS sample_count", sql, StringComparison.Ordinal); + Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); + } + + [Fact] + public void DailyPolicy_UsesOneDayEndOffsetAndSchedule_KeepsThreeDayStart() + { + var sql = TimescaleSupport.AddContinuousAggregatePolicySql(TimescaleSupport.QueryStatsDailyView, "1 day", "1 day"); + + Assert.Contains("add_continuous_aggregate_policy('collect.query_stats_daily'", sql, StringComparison.Ordinal); + Assert.Contains("start_offset => INTERVAL '3 days'", sql, StringComparison.Ordinal); + Assert.Contains("end_offset => INTERVAL '1 day'", sql, StringComparison.Ordinal); + Assert.Contains("schedule_interval => INTERVAL '1 day'", sql, StringComparison.Ordinal); + Assert.Contains("if_not_exists => true", sql, StringComparison.Ordinal); + } + + [Fact] + public void RetentionPolicy_IsIdempotentChunkDrop() + { + var sql = TimescaleSupport.AddRetentionPolicySql("query_stats", TimescaleSupport.RawRetentionInterval); + + Assert.Contains("add_retention_policy('collect.query_stats'", sql, StringComparison.Ordinal); + Assert.Contains("drop_after => INTERVAL '4 days'", sql, StringComparison.Ordinal); + Assert.Contains("if_not_exists => true", sql, StringComparison.Ordinal); + } + + [Fact] + public void RetentionTiers_RawFourDays_HourlyTwentyOneDays_StayPastTheNextRefreshWindow() + { + /* The buffers the whole tiering rests on: raw's 4d stays past the hourly CAGG's 3d refresh start; the + hourly CAGGs' 21d stays past the daily CAGG's 3d refresh start. Either dropping below 3 days would let a + drop outrun the aggregate meant to preserve that history. */ + Assert.Equal("4 days", TimescaleSupport.RawRetentionInterval); + Assert.Equal("21 days", TimescaleSupport.HourlyRetentionInterval); + } } diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index 1e5c58de3..cfb141cd8 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -590,6 +590,8 @@ guard skips the conversion and this heals it (collection_log is outside the coll await TimescaleSupport.ApplyCompressionPolicyAsync(timescaleConnection, _logger, stoppingToken); await TimescaleSupport.EnsureCollectionLogHypertableAsync(timescaleConnection, _logger, stoppingToken); await TimescaleSupport.EnsureContinuousAggregatesAsync(timescaleConnection, _logger, stoppingToken); + // AFTER the CAGGs exist: the tiered retention (raw 4d, hourly CAGGs 21d; daily kept indefinitely). + await TimescaleSupport.EnsureRetentionPoliciesAsync(timescaleConnection, _logger, stoppingToken); } } catch (Exception ex) when (ex is not OperationCanceledException) diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index 36c2a54ce..c7eed014c 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -241,6 +241,20 @@ public static string AddCompressionPolicySql(string table) /// 's procedure_stats sibling. public const string ProcedureStatsHourlyView = "procedure_stats_hourly"; + /// The query_store_stats hourly continuous aggregate. Built now, ahead of any writable-Query-Store + /// primary — on a read-only replica QS surfaces nothing new to harvest, so this sits empty until one is added, + /// but the rollup path exists the moment data starts flowing. Weaker cardinality reduction than the delta + /// tables (QS's own top-N sampling already surfaces a broad, shifting query/plan set), still worth having. + public const string QueryStoreStatsHourlyView = "query_store_stats_hourly"; + + /// The DAILY tier: hierarchical continuous aggregates sourced from the hourly CAGGs, NOT raw — 2.28.1 + /// supports a continuous aggregate built directly on another. Kept indefinitely (no retention policy) as the + /// "coarsened but never fully lost" tier for anything past the hourly CAGG's own horizon. + public const string QueryStatsDailyView = "query_stats_daily"; + + /// 's procedure_stats sibling (sourced from procedure_stats_hourly). + public const string ProcedureStatsDailyView = "procedure_stats_daily"; + /// /// The query_stats hourly continuous aggregate. 1-hour buckets grouped by the SAME dimensions the composer's /// MeasureCatalog uses for query_stats (server_id / server_name / database_name / query_hash), so a @@ -302,20 +316,106 @@ FROM collect.procedure_stats WITH NO DATA"; /// - /// The hourly refresh policy for a continuous aggregate: materialize the [now - 3 days, now - 1 hour] - /// window each hour. start_offset 3 days gives margin past the ~2-day compression/hot window (covers - /// same-day-arriving corrections); end_offset 1 hour leaves the still-filling current hour - /// unmaterialized (no repeated rework); schedule_interval matches the bucket. if_not_exists so a - /// restart re-converges. The conservative TimescaleDB default shape. + /// The query_store_stats hourly continuous aggregate. Grouped by QS's own identity dimensions + /// (server_id / server_name / database_name / query_id / plan_id) instead of a query_hash. query_store_stats + /// carries Query Store's already-aggregated per-collection figures (not Darling deltas), so this SUMs the + /// execution_count and takes MAX on the max_* columns; the avg_* columns are averaged (avg-of-avgs — QS does + /// not expose a total from which to weight, and QS is itself a sampled top-N, so the approximation is + /// acceptable). WITH NO DATA + IF NOT EXISTS, one statement, exactly like the delta-table hourly CAGGs. + /// + public const string CreateQueryStoreStatsHourlySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_store_stats_hourly +WITH (timescaledb.continuous) AS +SELECT + server_id, + server_name, + database_name, + query_id, + plan_id, + time_bucket('1 hour', collection_time) AS bucket, + sum(execution_count) AS execution_count_sum, + avg(avg_duration_us) AS avg_duration_us_avg, + max(max_duration_us) AS max_duration_us_max, + avg(avg_cpu_time_us) AS avg_cpu_time_us_avg, + max(max_cpu_time_us) AS max_cpu_time_us_max, + count(*) AS sample_count +FROM collect.query_store_stats +GROUP BY server_id, server_name, database_name, query_id, plan_id, bucket +WITH NO DATA"; + + /// + /// The query_stats DAILY continuous aggregate — a HIERARCHICAL CAGG sourced from + /// (not raw). Re-aggregates the hourly rollup to 1-day buckets: SUM of the hourly sums, MIN of the hourly mins, + /// MAX of the hourly maxes (each composes correctly across the coarser bucket), plus SUM of the hourly + /// sample_counts. The GROUP BY uses the explicit time_bucket('1 day', bucket) expression, NOT the bare + /// bucket alias: an unqualified bucket in GROUP BY binds to the SOURCE column (the hourly bucket) + /// under Postgres's input-column-wins ambiguity rule, which would group by hour, not day. WITH NO DATA + + /// IF NOT EXISTS; the hourly CAGG must already exist (it is created earlier in the same sweep). + /// + public const string CreateQueryStatsDailySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_stats_daily +WITH (timescaledb.continuous) AS +SELECT + server_id, + server_name, + database_name, + query_hash, + time_bucket('1 day', bucket) AS bucket, + sum(worker_time_sum) AS worker_time_sum, + min(worker_time_min) AS worker_time_min, + max(worker_time_max) AS worker_time_max, + sum(elapsed_time_sum) AS elapsed_time_sum, + min(elapsed_time_min) AS elapsed_time_min, + max(elapsed_time_max) AS elapsed_time_max, + sum(execution_count_sum) AS execution_count_sum, + min(execution_count_min) AS execution_count_min, + max(execution_count_max) AS execution_count_max, + sum(sample_count) AS sample_count +FROM collect.query_stats_hourly +GROUP BY server_id, server_name, database_name, query_hash, time_bucket('1 day', bucket) +WITH NO DATA"; + + /// The procedure_stats DAILY continuous aggregate — 's sibling, + /// sourced from and grouped by object_name. Same hierarchical + /// re-aggregation and same explicit-time_bucket GROUP BY discipline. + public const string CreateProcedureStatsDailySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_daily +WITH (timescaledb.continuous) AS +SELECT + server_id, + server_name, + database_name, + object_name, + time_bucket('1 day', bucket) AS bucket, + sum(worker_time_sum) AS worker_time_sum, + min(worker_time_min) AS worker_time_min, + max(worker_time_max) AS worker_time_max, + sum(elapsed_time_sum) AS elapsed_time_sum, + min(elapsed_time_min) AS elapsed_time_min, + max(elapsed_time_max) AS elapsed_time_max, + sum(execution_count_sum) AS execution_count_sum, + min(execution_count_min) AS execution_count_min, + max(execution_count_max) AS execution_count_max, + sum(sample_count) AS sample_count +FROM collect.procedure_stats_hourly +GROUP BY server_id, server_name, database_name, object_name, time_bucket('1 day', bucket) +WITH NO DATA"; + + /// + /// The refresh policy for a continuous aggregate: materialize [now - 3 days, now - endOffset] every + /// scheduleInterval. start_offset 3 days gives margin past the ~2-day compression/hot window + /// (covers same-day-arriving corrections) and is the buffer the retention tiers lean on — a tier's drop must + /// never outrun the next tier's 3-day refresh start. endOffset leaves the still-filling current bucket + /// unmaterialized (no repeated rework); scheduleInterval matches the bucket. Defaults are the hourly + /// shape; the daily CAGGs pass "1 day"/"1 day". if_not_exists so a restart re-converges. /// - public static string AddContinuousAggregatePolicySql(string view) - => $"SELECT add_continuous_aggregate_policy('collect.{view}', start_offset => INTERVAL '3 days', end_offset => INTERVAL '1 hour', schedule_interval => INTERVAL '1 hour', if_not_exists => true)"; + public static string AddContinuousAggregatePolicySql(string view, string endOffset = "1 hour", string scheduleInterval = "1 hour") + => $"SELECT add_continuous_aggregate_policy('collect.{view}', start_offset => INTERVAL '3 days', end_offset => INTERVAL '{endOffset}', schedule_interval => INTERVAL '{scheduleInterval}', if_not_exists => true)"; /// - /// Creates the two hourly continuous aggregates ( / - /// ) and attaches each one's refresh policy - /// (). Runs in the worker's TimescaleDB block (CAGGs need the - /// extension), AFTER hypertables + compression are in place. The CREATE and the policy are SEPARATE commands + /// Creates the continuous aggregates and attaches each one's refresh policy + /// (): three HOURLY (query_stats, procedure_stats, + /// query_store_stats) then two DAILY (query_stats, procedure_stats). The daily tier is HIERARCHICAL — each + /// daily CAGG is sourced from its hourly CAGG, so the ordered sweep creates the hourly ones first. Runs in the + /// worker's TimescaleDB block (CAGGs need the extension), AFTER hypertables + compression are in place. The + /// CREATE and the policy are SEPARATE commands /// per aggregate — a CAGG CREATE cannot run inside a transaction, so it is never batched with another /// statement. Failure-isolated per aggregate: one failure warns and the composer keeps querying raw. /// Idempotent (IF NOT EXISTS on both), so it re-converges every restart. Does NOT backfill history (WITH NO @@ -329,14 +429,22 @@ public static async Task EnsureContinuousAggregatesAsync(NpgsqlConnection c throw new ArgumentNullException(nameof(connection)); } + // Hourly CAGGs FIRST (the two delta tables + query_store_stats), THEN the daily tier — the daily CAGGs are + // hierarchical (sourced from the hourly CAGGs), so the hourly ones must be created earlier in this ordered + // sweep. Daily policies use the 1-day end-offset/schedule; the hourly ones take the helper's defaults. + // (query_store_stats_daily is deliberately NOT here yet — added once query_store_stats_hourly has real data + // from a writable-QS primary to source from.) var aggregates = new[] { - (CreateSql: CreateQueryStatsHourlySql, View: QueryStatsHourlyView), - (CreateSql: CreateProcedureStatsHourlySql, View: ProcedureStatsHourlyView), + (CreateSql: CreateQueryStatsHourlySql, View: QueryStatsHourlyView, PolicySql: AddContinuousAggregatePolicySql(QueryStatsHourlyView)), + (CreateSql: CreateProcedureStatsHourlySql, View: ProcedureStatsHourlyView, PolicySql: AddContinuousAggregatePolicySql(ProcedureStatsHourlyView)), + (CreateSql: CreateQueryStoreStatsHourlySql, View: QueryStoreStatsHourlyView, PolicySql: AddContinuousAggregatePolicySql(QueryStoreStatsHourlyView)), + (CreateSql: CreateQueryStatsDailySql, View: QueryStatsDailyView, PolicySql: AddContinuousAggregatePolicySql(QueryStatsDailyView, "1 day", "1 day")), + (CreateSql: CreateProcedureStatsDailySql, View: ProcedureStatsDailyView, PolicySql: AddContinuousAggregatePolicySql(ProcedureStatsDailyView, "1 day", "1 day")), }; var ready = 0; - foreach (var (createSql, view) in aggregates) + foreach (var (createSql, view, policySql) in aggregates) { try { @@ -345,7 +453,7 @@ public static async Task EnsureContinuousAggregatesAsync(NpgsqlConnection c await create.ExecuteNonQueryAsync(cancellationToken); } - using (var policy = new NpgsqlCommand(AddContinuousAggregatePolicySql(view), connection) { CommandTimeout = SetupTimeoutSeconds }) + using (var policy = new NpgsqlCommand(policySql, connection) { CommandTimeout = SetupTimeoutSeconds }) { await policy.ExecuteNonQueryAsync(cancellationToken); } @@ -361,11 +469,80 @@ public static async Task EnsureContinuousAggregatesAsync(NpgsqlConnection c } logger?.LogInformation( - "TimescaleDB: {Ready}/{Total} hourly continuous aggregate(s) ready (query_stats, procedure_stats)", + "TimescaleDB: {Ready}/{Total} continuous aggregate(s) ready (3 hourly: query_stats, procedure_stats, query_store_stats; 2 daily: query_stats, procedure_stats)", ready, aggregates.Length); return ready; } + /// Raw-tier retention horizon: keep per-sweep raw ~4 days — one day past the hourly CAGG's own 3-day + /// refresh window, so the raw drop never outruns the aggregate that preserves it. + public const string RawRetentionInterval = "4 days"; + + /// Hourly-CAGG-tier retention horizon: keep the hourly rollups 21 days — well past the daily CAGG's + /// 3-day refresh window, so the hourly drop never outruns the daily aggregate. The daily CAGGs themselves get + /// NO retention policy: they are the coarsened, kept-indefinitely tier. + public const string HourlyRetentionInterval = "21 days"; + + /// A TimescaleDB retention policy: schedule a background job that DROPs chunks older than + /// . if_not_exists so a restart re-converges. The actual drop is a + /// chunk-level DROP TABLE (cheap, no rewrite), so unlike the CAGG backfill it needs no off-hours window. + public static string AddRetentionPolicySql(string relation, string dropAfter) + => $"SELECT add_retention_policy('collect.{relation}', drop_after => INTERVAL '{dropAfter}', if_not_exists => true)"; + + /// + /// Attaches the tiered retention policies: the three raw tables drop at , the + /// three hourly CAGGs at ; the daily CAGGs are kept indefinitely (no + /// policy). Ordering safety is by HORIZON, not run order — each tier's drop stays comfortably past the next + /// tier's 3-day refresh start_offset (4d raw vs 3d hourly refresh; 21d hourly vs 3d daily refresh), so a drop + /// never removes history the next tier has not yet materialized. Idempotent (if_not_exists) and + /// failure-isolated per policy. MUST run AFTER so the hourly + /// CAGGs the hourly policies target already exist. Returns the number of policies in place. + /// + /// COLD-START CAVEAT (existing stores only): on a store that already holds raw history OLDER than the hourly + /// CAGG has materialized, the first raw drop can remove buckets the CAGG never captured. Fresh installs are + /// safe automatically — nothing is older than 4 days until the CAGG has been materializing that long. For an + /// EXISTING store, backfill the hourly CAGGs past the raw horizon BEFORE this policy's first run. + /// + public static async Task EnsureRetentionPoliciesAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var policies = new[] + { + (Relation: "query_stats", DropAfter: RawRetentionInterval), + (Relation: "procedure_stats", DropAfter: RawRetentionInterval), + (Relation: "query_store_stats", DropAfter: RawRetentionInterval), + (Relation: QueryStatsHourlyView, DropAfter: HourlyRetentionInterval), + (Relation: ProcedureStatsHourlyView, DropAfter: HourlyRetentionInterval), + (Relation: QueryStoreStatsHourlyView, DropAfter: HourlyRetentionInterval), + }; + + var applied = 0; + foreach (var (relation, dropAfter) in policies) + { + try + { + using var command = new NpgsqlCommand(AddRetentionPolicySql(relation, dropAfter), connection) { CommandTimeout = SetupTimeoutSeconds }; + await command.ExecuteNonQueryAsync(cancellationToken); + applied++; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + "Retention policy for {Relation} ({DropAfter}) failed — that tier keeps growing until the next restart retries: {Message}", + relation, dropAfter, ex.Message); + } + } + + logger?.LogInformation( + "TimescaleDB: {Applied}/{Total} retention policies in place (raw {Raw}, hourly CAGGs {Hourly}; daily CAGGs kept indefinitely)", + applied, policies.Length, RawRetentionInterval, HourlyRetentionInterval); + return applied; + } + /// /// Converts every collector table to a hypertable ( scope; /// per table). Failure-isolated per table: one failed From 03ed91b172943f67073acbe1959e1f2d5118c63d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:19:06 -0400 Subject: [PATCH 021/296] CHANGELOG: [Unreleased] entry for #1623 (three-tier retention) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9cd87b8a4..e6293059d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling Web: adaptive `auto` time bucket for composed time-series panels** ([#1619]) - a compose custom-view time-series panel can set `timeBucket: "auto"`, and the compiler resolves it to a concrete grain from the panel's window (minute up to 2 days, hour up to 60 days, day beyond) so any range from 1h to 90d renders a readable line and never trips the 5,000-bucket ceiling. Previously a fixed `hour` bucket collapsed a sub-hour workload (e.g. a 30-minute HammerDB run) into a single invisible point, while a fixed `minute` bucket errored past ~3.5 days. The composer now defaults new time-series panels to `auto` and the MCP `describe_custom_view_catalog` recommends it; non-auto buckets compile byte-for-byte as before. Pinned by `DarlingComposeTests` (auto boundary resolution + compile-by-window + non-auto passthrough). - **Darling Web: custom time span on the view range picker** ([#1619]) - the rendered custom-view range picker gains a "Custom..." option (a number + a hours/days unit, up to the 90-day window ceiling) so a view is no longer limited to the six presets; a non-preset window stays selectable so it survives the 60s refresh. - **Darling: hourly continuous aggregates for query_stats + procedure_stats (composer query acceleration)** ([#1621]) - the two collector tables that dominate the store (~90%: query_stats ~145 GB, procedure_stats ~49 GB) now have hourly TimescaleDB continuous aggregates (`query_stats_hourly` / `procedure_stats_hourly`) that pre-materialize the exact shape every Custom Views composer panel over them does (`date_trunc('hour', collection_time)` + `SUM(delta_*)` GROUP BY a dimension), grouped by the same `MeasureCatalog` dimensions with SUM/MIN/MAX per delta column + a sample_count (so avg composes correctly at query time). Created idempotently as runtime setup in the worker's TimescaleDB block (not a versioned migration), `WITH NO DATA` with real-time aggregation on and the conservative hourly refresh policy (start_offset 3d, end_offset 1h). Measured ~49x / ~32x row reduction on the field fleet. Query acceleration only - raw rows still exist for the ~2-day hot window (analyze_*_plan needs the plan XML), and this makes no retention decision. +- **Darling: three-tier retention - downsample past the raw hot window, keep the rollups** ([#1623]) - builds the retention layer on the hourly CAGGs (#1621): instead of a flat "drop raw after N days," three tiers so nothing is fully lost, only coarsened. **Raw 4 days** (`add_retention_policy` on query_stats / procedure_stats / query_store_stats - one day past the hourly CAGG's own 3-day refresh window). **Hourly CAGGs 21 days** - adds `query_store_stats_hourly` (built now, ahead of a writable-Query-Store primary; empty on a read-only replica until one exists) plus 21-day retention on all three hourly CAGGs. **Daily CAGGs kept indefinitely** - new HIERARCHICAL `query_stats_daily` / `procedure_stats_daily` sourced from the hourly CAGGs (not raw; 2.28.1 supports CAGG-on-CAGG), 1-day cadence, no retention policy. Each tier's horizon stays past the next tier's 3-day refresh start so a drop never outruns the aggregate preserving it; `add_retention_policy` is a chunk-level drop, so no off-hours window needed. All idempotent runtime setup in the worker's TimescaleDB block. `query_store_stats_daily` deferred until QS-hourly has real data to source from. Cold-start caveat on existing stores: backfill the hourly CAGGs past the raw horizon before the 4-day raw drop's first run (fresh installs are safe automatically). ### Changed @@ -518,6 +519,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1619]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1619 [#1620]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1620 [#1622]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1622 +[#1623]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1623 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 From 58cc8608c4a0b4044277395e3e3468b2f36016db Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:30:54 -0400 Subject: [PATCH 022/296] Darling: reshape QS + procedure_stats CAGGs to the composer's dimensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the #1623 CAGG shapes so the composer can route to them (the read-routing follow-up), while the CAGGs are still empty (QS, read-only replica) or a day or two old. - query_store_stats_hourly: regrouped from query_id/plan_id to the composer's QS dims (module_name/query_hash) + execution-WEIGHTED sums (sum(avg_* * execution_count)) so the composer's weighted mean composes EXACTLY (duration_us_weighted_sum / execution_count_sum), not an avg-of-avgs. This is the shape a writable-Query-Store primary needs — QS composer panels were unroutable and the weighted mean unreconstructable as #1623 built it. - procedure_stats hourly + daily: added schema_name to the grouping (a composer dimension that had been dropped, so schema_name panels couldn't route). - DropStaleContinuousAggregatesAsync (wired before the ensure sweep): CREATE ... IF NOT EXISTS cannot ALTER a CAGG, so an existing old-shape CAGG is dropped (detected structurally: old QS still has query_id, old procedure_stats lacks schema_name) and recreated in the new shape. No-op once reshaped / on a fresh store; failure-isolated. query_stats CAGGs are unchanged. object_name-on-query_stats routing is deliberately deferred (would need sql_handle on both big CAGGs for a read-time join — bloats the tables the CAGGs exist to shrink; revisit with a retained module map if it matters). Pinned by TimescaleContinuousAggregateTests (updated for the new shapes). Co-Authored-By: Claude Opus 4.8 --- .../TimescaleContinuousAggregateTests.cs | 25 +++-- .../DarlingWorker.cs | 3 + .../TimescaleSupport.cs | 106 +++++++++++++++--- 3 files changed, 108 insertions(+), 26 deletions(-) diff --git a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs index 8e3a4ebb3..23f71ed7f 100644 --- a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs +++ b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs @@ -52,14 +52,15 @@ deliberately NO materialized average (it would not re-aggregate correctly). */ } [Fact] - public void ProcedureStatsHourly_MirrorsQueryStats_GroupedByObjectName() + public void ProcedureStatsHourly_GroupedBySchemaAndObject() { var sql = TimescaleSupport.CreateProcedureStatsHourlySql; Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_hourly", sql, StringComparison.Ordinal); Assert.Contains("WITH (timescaledb.continuous)", sql, StringComparison.Ordinal); Assert.Contains("FROM collect.procedure_stats", sql, StringComparison.Ordinal); - Assert.Contains("GROUP BY server_id, server_name, database_name, object_name, bucket", sql, StringComparison.Ordinal); + /* schema_name + object_name — both composer dimensions (a panel by schema_name alone re-aggregates). */ + Assert.Contains("GROUP BY server_id, server_name, database_name, schema_name, object_name, bucket", sql, StringComparison.Ordinal); Assert.Contains("count(*) AS sample_count", sql, StringComparison.Ordinal); Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); } @@ -77,7 +78,7 @@ public void ContinuousAggregatePolicy_IsTheConservativeHourlyShape_Idempotent() } [Fact] - public void QueryStoreStatsHourly_GroupedByQsIdentity_SumsCount_AveragesTheAvgColumns() + public void QueryStoreStatsHourly_GroupedByComposerDims_CarriesWeightedSums() { var sql = TimescaleSupport.CreateQueryStoreStatsHourlySql; @@ -85,14 +86,20 @@ public void QueryStoreStatsHourly_GroupedByQsIdentity_SumsCount_AveragesTheAvgCo Assert.Contains("WITH (timescaledb.continuous)", sql, StringComparison.Ordinal); Assert.Contains("time_bucket('1 hour', collection_time) AS bucket", sql, StringComparison.Ordinal); Assert.Contains("FROM collect.query_store_stats", sql, StringComparison.Ordinal); - /* QS's own identity (query_id / plan_id), not a query_hash. */ - Assert.Contains("GROUP BY server_id, server_name, database_name, query_id, plan_id, bucket", sql, StringComparison.Ordinal); - /* QS carries already-aggregated figures (not Darling deltas): SUM the count, MAX the max_*, avg-of-avgs on avg_*. */ + /* The COMPOSER's QS dimensions (module_name / query_hash) so a composed QS panel can route here — NOT + Query Store's own query_id / plan_id, which the composer never exposes. */ + Assert.Contains("GROUP BY server_id, server_name, database_name, module_name, query_hash, bucket", sql, StringComparison.Ordinal); + Assert.DoesNotContain("query_id", sql, StringComparison.Ordinal); + Assert.DoesNotContain("plan_id", sql, StringComparison.Ordinal); + /* Execution-WEIGHTED sums so the composer's weighted mean = duration_us_weighted_sum / execution_count_sum + composes EXACTLY (avg*count = the interval total, summed = the true total) — never an avg-of-avgs. */ Assert.Contains("sum(execution_count) AS execution_count_sum", sql, StringComparison.Ordinal); - Assert.Contains("avg(avg_duration_us) AS avg_duration_us_avg", sql, StringComparison.Ordinal); + Assert.Contains("sum(avg_duration_us::double precision * execution_count) AS duration_us_weighted_sum", sql, StringComparison.Ordinal); + Assert.Contains("sum(avg_cpu_time_us::double precision * execution_count) AS cpu_us_weighted_sum", sql, StringComparison.Ordinal); Assert.Contains("max(max_duration_us) AS max_duration_us_max", sql, StringComparison.Ordinal); - Assert.Contains("avg(avg_cpu_time_us) AS avg_cpu_time_us_avg", sql, StringComparison.Ordinal); Assert.Contains("max(max_cpu_time_us) AS max_cpu_time_us_max", sql, StringComparison.Ordinal); + /* The imprecise avg-of-avgs shape is gone. */ + Assert.DoesNotContain("avg(avg_duration_us)", sql, StringComparison.Ordinal); Assert.Contains("count(*) AS sample_count", sql, StringComparison.Ordinal); Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); } @@ -125,7 +132,7 @@ public void ProcedureStatsDaily_MirrorsQueryStatsDaily_FromProcedureHourly_ByObj Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_daily", sql, StringComparison.Ordinal); Assert.Contains("FROM collect.procedure_stats_hourly", sql, StringComparison.Ordinal); - Assert.Contains("GROUP BY server_id, server_name, database_name, object_name, time_bucket('1 day', bucket)", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id, server_name, database_name, schema_name, object_name, time_bucket('1 day', bucket)", sql, StringComparison.Ordinal); Assert.Contains("sum(sample_count) AS sample_count", sql, StringComparison.Ordinal); Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); } diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index cfb141cd8..d035155c4 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -589,6 +589,9 @@ guard skips the conversion and this heals it (collection_log is outside the coll await TimescaleSupport.ConvertToHypertablesAsync(timescaleConnection, _logger, stoppingToken); await TimescaleSupport.ApplyCompressionPolicyAsync(timescaleConnection, _logger, stoppingToken); await TimescaleSupport.EnsureCollectionLogHypertableAsync(timescaleConnection, _logger, stoppingToken); + // Reshape: drop stale old-shape QS / procedure_stats CAGGs FIRST so the ensure below rebuilds them + // in the composer-dimension shape (no-op once reshaped, and on a fresh store nothing matches). + await TimescaleSupport.DropStaleContinuousAggregatesAsync(timescaleConnection, _logger, stoppingToken); await TimescaleSupport.EnsureContinuousAggregatesAsync(timescaleConnection, _logger, stoppingToken); // AFTER the CAGGs exist: the tiered retention (raw 4d, hourly CAGGs 21d; daily kept indefinitely). await TimescaleSupport.EnsureRetentionPoliciesAsync(timescaleConnection, _logger, stoppingToken); diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index c7eed014c..0abc8f3b7 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -291,14 +291,16 @@ FROM collect.query_stats WITH NO DATA"; /// The procedure_stats hourly continuous aggregate — 's - /// sibling, grouped by object_name instead of query_hash (procedure_stats' composer - /// dimension). Same aggregation shape, same WITH NO DATA + IF NOT EXISTS discipline. + /// sibling, grouped by schema_name + object_name (procedure_stats' composer dimensions; a panel + /// grouping by schema_name alone re-aggregates over its objects). Same aggregation shape, same WITH NO DATA + + /// IF NOT EXISTS discipline. public const string CreateProcedureStatsHourlySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_hourly WITH (timescaledb.continuous) AS SELECT server_id, server_name, database_name, + schema_name, object_name, time_bucket('1 hour', collection_time) AS bucket, sum(delta_worker_time) AS worker_time_sum, @@ -312,16 +314,18 @@ FROM collect.query_stats max(delta_execution_count) AS execution_count_max, count(*) AS sample_count FROM collect.procedure_stats -GROUP BY server_id, server_name, database_name, object_name, bucket +GROUP BY server_id, server_name, database_name, schema_name, object_name, bucket WITH NO DATA"; /// - /// The query_store_stats hourly continuous aggregate. Grouped by QS's own identity dimensions - /// (server_id / server_name / database_name / query_id / plan_id) instead of a query_hash. query_store_stats - /// carries Query Store's already-aggregated per-collection figures (not Darling deltas), so this SUMs the - /// execution_count and takes MAX on the max_* columns; the avg_* columns are averaged (avg-of-avgs — QS does - /// not expose a total from which to weight, and QS is itself a sampled top-N, so the approximation is - /// acceptable). WITH NO DATA + IF NOT EXISTS, one statement, exactly like the delta-table hourly CAGGs. + /// The query_store_stats hourly continuous aggregate, grouped by the COMPOSER's Query Store dimensions + /// (server / database_name / module_name / query_hash) so a composed QS panel can route here — NOT Query + /// Store's own query_id/plan_id, which the composer never exposes. Carries the EXECUTION-WEIGHTED sums + /// (sum(avg_* * execution_count)) so the composer's weighted mean composes EXACTLY as + /// duration_us_weighted_sum / execution_count_sum across any window (avg*count = the interval's total, + /// summed = the true total) — never an avg-of-avgs. This matters the moment a writable-Query-Store primary is + /// added (the scenario this CAGG exists to be ready for); on the current read-only replica it is simply empty. + /// WITH NO DATA + IF NOT EXISTS, one statement. /// public const string CreateQueryStoreStatsHourlySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_store_stats_hourly WITH (timescaledb.continuous) AS @@ -329,17 +333,17 @@ FROM collect.procedure_stats server_id, server_name, database_name, - query_id, - plan_id, + module_name, + query_hash, time_bucket('1 hour', collection_time) AS bucket, sum(execution_count) AS execution_count_sum, - avg(avg_duration_us) AS avg_duration_us_avg, + sum(avg_duration_us::double precision * execution_count) AS duration_us_weighted_sum, + sum(avg_cpu_time_us::double precision * execution_count) AS cpu_us_weighted_sum, max(max_duration_us) AS max_duration_us_max, - avg(avg_cpu_time_us) AS avg_cpu_time_us_avg, max(max_cpu_time_us) AS max_cpu_time_us_max, count(*) AS sample_count FROM collect.query_store_stats -GROUP BY server_id, server_name, database_name, query_id, plan_id, bucket +GROUP BY server_id, server_name, database_name, module_name, query_hash, bucket WITH NO DATA"; /// @@ -374,14 +378,15 @@ FROM collect.query_stats_hourly WITH NO DATA"; /// The procedure_stats DAILY continuous aggregate — 's sibling, - /// sourced from and grouped by object_name. Same hierarchical - /// re-aggregation and same explicit-time_bucket GROUP BY discipline. + /// sourced from and grouped by schema_name + object_name. + /// Same hierarchical re-aggregation and same explicit-time_bucket GROUP BY discipline. public const string CreateProcedureStatsDailySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.procedure_stats_daily WITH (timescaledb.continuous) AS SELECT server_id, server_name, database_name, + schema_name, object_name, time_bucket('1 day', bucket) AS bucket, sum(worker_time_sum) AS worker_time_sum, @@ -395,7 +400,7 @@ FROM collect.query_stats_hourly max(execution_count_max) AS execution_count_max, sum(sample_count) AS sample_count FROM collect.procedure_stats_hourly -GROUP BY server_id, server_name, database_name, object_name, time_bucket('1 day', bucket) +GROUP BY server_id, server_name, database_name, schema_name, object_name, time_bucket('1 day', bucket) WITH NO DATA"; /// @@ -409,6 +414,73 @@ FROM collect.procedure_stats_hourly public static string AddContinuousAggregatePolicySql(string view, string endOffset = "1 hour", string scheduleInterval = "1 hour") => $"SELECT add_continuous_aggregate_policy('collect.{view}', start_offset => INTERVAL '3 days', end_offset => INTERVAL '{endOffset}', schedule_interval => INTERVAL '{scheduleInterval}', if_not_exists => true)"; + /// + /// The composer-dimension reshape: the QS hourly CAGG regrouped query_id/plan_id → module_name/query_hash + /// (+ weighted sums), and the procedure_stats CAGGs gained schema_name. CREATE ... IF NOT EXISTS cannot + /// ALTER an existing CAGG, so a store that already built the OLD shape must DROP it first; + /// (run right after) recreates it in the new shape. Each affected + /// CAGG is empty (QS on a read-only replica) or only a day or two old, so the drop loses little and the refresh + /// backfills the recent window within the hour. Staleness is detected STRUCTURALLY — the OLD QS CAGG still has + /// a query_id column; the OLD procedure_stats CAGG lacks schema_name — so this is a strict no-op + /// once reshaped, and on a fresh store (no CAGG yet) nothing matches. Failure-isolated: a failed drop leaves the + /// old shape in place (logged), never kills startup. query_stats CAGGs are unchanged and untouched. CASCADE + /// drops the dependent daily CAGG, which the ensure sweep also recreates. + /// + public static async Task DropStaleContinuousAggregatesAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + if (connection is null) + { + throw new ArgumentNullException(nameof(connection)); + } + + var reshapes = new[] + { + /* OLD query_store_stats_hourly grouped by query_id/plan_id → stale iff it still has a query_id column. */ + (View: "query_store_stats_hourly", + StaleCheck: "SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'query_store_stats_hourly' AND column_name = 'query_id')"), + /* OLD procedure_stats_hourly lacked schema_name → stale iff the view EXISTS but has no schema_name + column. CASCADE also drops procedure_stats_daily, which the ensure sweep recreates. */ + (View: "procedure_stats_hourly", + StaleCheck: "SELECT (EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'procedure_stats_hourly') AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'procedure_stats_hourly' AND column_name = 'schema_name'))"), + }; + + var dropped = 0; + foreach (var (view, staleCheck) in reshapes) + { + try + { + bool stale; + using (var check = new NpgsqlCommand(staleCheck, connection) { CommandTimeout = SetupTimeoutSeconds }) + { + stale = await check.ExecuteScalarAsync(cancellationToken) is true; + } + + if (!stale) + { + continue; + } + + using (var drop = new NpgsqlCommand($"DROP MATERIALIZED VIEW IF EXISTS collect.{view} CASCADE", connection) { CommandTimeout = SetupTimeoutSeconds }) + { + await drop.ExecuteNonQueryAsync(cancellationToken); + } + + dropped++; + logger?.LogInformation( + "TimescaleDB: dropped stale continuous aggregate {View} (composer-dimension reshape) — recreated in the new shape this cycle.", + view); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning( + "Reshape drop of {View} failed — it stays in the OLD shape until the next restart retries: {Message}", + view, ex.Message); + } + } + + return dropped; + } + /// /// Creates the continuous aggregates and attaches each one's refresh policy /// (): three HOURLY (query_stats, procedure_stats, From cc3afb11474d5bf9e0fd6f503ba9a1c48373822b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:33:07 -0400 Subject: [PATCH 023/296] CHANGELOG: [Unreleased] entry for #1624 (CAGG reshape to composer dims) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6293059d..7a1e4aac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Full Dashboard and the CLI Installer are being retired.** They moved to a `deprecated/` folder and are no longer built into release artifacts; releases now ship Lite + Darling only, with Darling the flagship replacement for the Full Dashboard. The deprecated code still compiles and its tests run in CI. ([#1612]) - **Darling Web: the custom-view panel grid is capped at two columns on wide screens** ([#1619]) - a dense view (e.g. four panels) no longer crams four-across on a wide monitor; the min column track is >=45% of the row so at most two columns ever fit, and a `span-2` (or lone) panel still takes the full width. +- **Darling: the query_store_stats + procedure_stats continuous aggregates are reshaped to the composer's dimensions** ([#1624]) - the #1621/#1623 CAGGs grouped query_store_stats by Query Store's native query_id/plan_id (which the composer never uses - it reads QS by module_name/query_hash) and dropped procedure_stats' schema_name, so a composed panel over those dimensions could not route to the rollup, and the QS execution-weighted mean was unreconstructable (it stored avg-of-avgs). Regrouped query_store_stats_hourly to server/database_name/module_name/query_hash carrying execution-weighted sums (so the weighted mean composes exactly), and added schema_name to the procedure_stats hourly + daily CAGGs. Reshaped now while the CAGGs are empty (QS is read-only on the current replica) or a day or two old, via a guarded drop+recreate that detects the old shape structurally. Matters ahead of a writable-Query-Store primary; query_stats CAGGs are unchanged. ### Fixed @@ -520,6 +521,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1620]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1620 [#1622]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1622 [#1623]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1623 +[#1624]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1624 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 From 151dd26bb8f897d707b409d15f6662828639e4f9 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:54:23 -0400 Subject: [PATCH 024/296] Darling: composer CAGG source router (age-based tier selection) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First increment of the read-routing feature. ComposeSourceRouter picks the physical source tier (raw / hourly CAGG / daily CAGG) for a composed panel by the AGE of the window's oldest point — never the display grain (so Ranked/Scalar panels, which resolve no grain, route correctly) and never the window span (so a purely historical window reaches the tier that still retains it instead of returning empty). Gates on CAGG dimension coverage (post-#1624 reshape: QS by module_name/query_hash, procedure_stats with schema_name; query_stats object_name stays raw as a #1568 module join), and keeps route thresholds a margin inside the retention horizons (pinned as a test invariant). Not yet wired into ComposeCompiler — that (the FROM/time-column/bucket swap, the BuildValueExpr column remap, and the ComposeRunContext.NowUtc the age needs) is the next increment. 13 pin tests. Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/ComposeSourceRouterTests.cs | 159 +++++++++++++++++ .../Compose/ComposeSourceRouter.cs | 165 ++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100644 Darling/Darling.Tests/ComposeSourceRouterTests.cs create mode 100644 Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs diff --git a/Darling/Darling.Tests/ComposeSourceRouterTests.cs b/Darling/Darling.Tests/ComposeSourceRouterTests.cs new file mode 100644 index 000000000..cbedc2a13 --- /dev/null +++ b/Darling/Darling.Tests/ComposeSourceRouterTests.cs @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * 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.Service; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins : source selection by the window's oldest-point AGE (not display grain, +/// not window span), the CAGG dimension-coverage gate (post-reshape), and the margin-below-retention invariant. +/// Pure decision logic — no DB — so it runs ungated. +/// +public sealed class ComposeSourceRouterTests +{ + private static readonly DateTime Now = new(2026, 7, 23, 12, 0, 0, DateTimeKind.Unspecified); + + private static ComposeMeasure Measure(string key) => + MeasureCatalog.Measures.First(m => string.Equals(m.Key, key, StringComparison.Ordinal)); + + private static PanelPlan Plan( + string measureKey, + PanelMode mode = PanelMode.TimeSeries, + IReadOnlyList? groupBy = null) => + new() + { + Measure = Measure(measureKey), + Unit = "ms", + Mode = mode, + Filters = Array.Empty(), + GroupBy = groupBy ?? Array.Empty(), + Viz = "line", + }; + + [Fact] + public void RecentWindow_RoutesRaw() + { + /* oldest point 2 days old — inside the 3-day raw route max → raw. */ + var route = ComposeSourceRouter.Resolve(Plan("query_worker_us"), Now, Now.AddDays(-2)); + Assert.Equal(ComposeSourceTier.Raw, route.Tier); + Assert.False(route.IsCagg); + Assert.Null(route.CaggRelation); + } + + [Fact] + public void MidWindow_RoutesHourlyCagg() + { + var route = ComposeSourceRouter.Resolve(Plan("query_worker_us"), Now, Now.AddDays(-10)); + Assert.Equal(ComposeSourceTier.Hourly, route.Tier); + Assert.Equal("query_stats_hourly", route.CaggRelation); + } + + [Fact] + public void OldWindow_RoutesDailyCagg() + { + var route = ComposeSourceRouter.Resolve(Plan("query_worker_us"), Now, Now.AddDays(-40)); + Assert.Equal(ComposeSourceTier.Daily, route.Tier); + Assert.Equal("query_stats_daily", route.CaggRelation); + } + + [Fact] + public void HistoricalWindow_RoutesByAge_NotWindowSpan() + { + /* A 5-day-SPAN window that is 30→25 days OLD must route by age (30d → daily), NOT by span (5d → hourly): + the hourly chunks for that range were already dropped, so span-based routing would return empty. */ + var route = ComposeSourceRouter.Resolve(Plan("query_worker_us"), Now, Now.AddDays(-30)); + Assert.Equal(ComposeSourceTier.Daily, route.Tier); + } + + [Fact] + public void RankedMode_RoutesByAge_WithNoDisplayGrain() + { + /* Ranked panels resolve no display grain at all — the v1-killer case. Age-based routing still works: + a 40-day "top N" reaches the daily CAGG instead of truncating at raw's 4 days. */ + var route = ComposeSourceRouter.Resolve(Plan("query_worker_us", PanelMode.Ranked), Now, Now.AddDays(-40)); + Assert.Equal(ComposeSourceTier.Daily, route.Tier); + } + + [Fact] + public void NoCaggTable_AlwaysRaw() + { + /* wait_stats has no CAGG → raw even for a 40-day window (routing is a no-op for the ~30 non-CAGG tables). */ + var route = ComposeSourceRouter.Resolve(Plan("wait_time_ms"), Now, Now.AddDays(-40)); + Assert.Equal(ComposeSourceTier.Raw, route.Tier); + } + + [Fact] + public void ObjectNameDimension_StaysRaw() + { + /* query_stats grouped by object_name is a #1568 module join, not a CAGG column → stays raw (truncates 4d). */ + var objectName = MeasureCatalog.Dimension("query_stats", "object_name")!; + var plan = Plan("query_worker_us", groupBy: new[] { objectName }); + Assert.True(plan.UsesModuleJoin); + var route = ComposeSourceRouter.Resolve(plan, Now, Now.AddDays(-40)); + Assert.Equal(ComposeSourceTier.Raw, route.Tier); + } + + [Fact] + public void CoveredDimension_QueryHash_Routes() + { + var queryHash = MeasureCatalog.Dimension("query_stats", "query_hash")!; + var route = ComposeSourceRouter.Resolve(Plan("query_worker_us", groupBy: new[] { queryHash }), Now, Now.AddDays(-10)); + Assert.Equal(ComposeSourceTier.Hourly, route.Tier); + } + + [Fact] + public void ServerDimension_IsUniversallyCovered() + { + var server = MeasureCatalog.ServerDimension("query_stats"); + var route = ComposeSourceRouter.Resolve(Plan("query_worker_us", groupBy: new[] { server }), Now, Now.AddDays(-10)); + Assert.Equal(ComposeSourceTier.Hourly, route.Tier); + } + + [Fact] + public void ProcedureStats_SchemaName_NowRoutes() + { + /* schema_name was added to the procedure_stats CAGG in the reshape (#1624) → it now routes. */ + var schemaName = MeasureCatalog.Dimension("procedure_stats", "schema_name")!; + var route = ComposeSourceRouter.Resolve(Plan("proc_worker_us", groupBy: new[] { schemaName }), Now, Now.AddDays(-10)); + Assert.Equal(ComposeSourceTier.Hourly, route.Tier); + Assert.Equal("procedure_stats_hourly", route.CaggRelation); + } + + [Fact] + public void QueryStore_RoutesByComposerDims() + { + /* query_store_stats routes by module_name/query_hash (the reshaped composer dims). */ + var route = ComposeSourceRouter.Resolve(Plan("qs_executions"), Now, Now.AddDays(-10)); + Assert.Equal(ComposeSourceTier.Hourly, route.Tier); + Assert.Equal("query_store_stats_hourly", route.CaggRelation); + } + + [Fact] + public void QueryStore_NoDailyCagg_OldWindowStaysHourly() + { + /* No query_store_stats_daily yet (deferred) → a 40-day window falls to the hourly CAGG (capped at its 21d), + NOT a non-existent daily. */ + var route = ComposeSourceRouter.Resolve(Plan("qs_executions"), Now, Now.AddDays(-40)); + Assert.Equal(ComposeSourceTier.Hourly, route.Tier); + Assert.Equal("query_store_stats_hourly", route.CaggRelation); + } + + [Fact] + public void RouteThresholds_StayBelowRetentionHorizons() + { + /* The safety invariant: a route max must be strictly inside its retention horizon, so a drop lagging the + boundary can never leave the chosen tier missing its oldest chunk. Raw kept 4d, hourly CAGGs kept 21d. */ + Assert.True(ComposeSourceRouter.RawRouteMaxAge < TimeSpan.FromDays(4)); + Assert.True(ComposeSourceRouter.HourlyRouteMaxAge < TimeSpan.FromDays(21)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs new file mode 100644 index 000000000..a32a652a8 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server 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.Darling.Service; + +/// Which physical relation a composed panel reads: the raw collector table, or one of its +/// continuous-aggregate rollups. +public enum ComposeSourceTier +{ + /// The raw per-sweep collector table (collect.<table>). + Raw, + + /// The hourly continuous aggregate (collect.<table>_hourly). + Hourly, + + /// The daily continuous aggregate (collect.<table>_daily). + Daily, +} + +/// +/// The routing decision for one panel: which tier to read and the relation/time-column that implies. The raw tier +/// carries no relation (the compiler keeps its existing SourceTable + prefix-time-column path); a CAGG tier +/// names the rollup view, whose time column is always the bucket the CAGG produced. +/// +public sealed record ComposeRoute(ComposeSourceTier Tier, string? CaggRelation) +{ + /// The raw route — the compiler's unchanged behaviour. + public static readonly ComposeRoute Raw = new(ComposeSourceTier.Raw, null); + + public bool IsCagg => Tier != ComposeSourceTier.Raw; + + /// Every CAGG's time dimension is the time_bucket(...) AS bucket column. + public const string CaggTimeColumn = "bucket"; +} + +/// One raw table's continuous-aggregate coverage: its hourly (and optional daily) rollup view names and +/// the dimensions those rollups are grouped by — the set a panel's group-by/filter dimensions must be a subset of +/// to route (the universal server dimension always qualifies, since server_id/server_name lead every CAGG's +/// GROUP BY). +public sealed record ComposeCaggInfo( + string RawTable, + string HourlyView, + string? DailyView, + IReadOnlySet Dimensions) +{ + public bool Covers(string dimensionName) => + string.Equals(dimensionName, MeasureCatalog.ServerDimensionName, StringComparison.Ordinal) + || Dimensions.Contains(dimensionName); +} + +/// +/// The catalog of which raw tables have CAGGs, keyed by raw table name — the composer-dimension shape after the +/// CAGG reshape (query_store_stats regrouped to module_name/query_hash; procedure_stats carrying schema_name). +/// Dimensions here are the CAGGs' GROUP BY columns (minus the universal server prefix); a panel routes only when +/// its dimensions are covered (see ). query_stats' object_name is +/// deliberately absent: it is a #1568 module join (ViaModuleJoin), not a CAGG column, so those panels stay raw. +/// query_store_stats has no daily CAGG yet (deferred until a writable-QS primary supplies real data). +/// +public static class ComposeCaggCatalog +{ + private static readonly Dictionary s_byTable = new(StringComparer.Ordinal) + { + ["query_stats"] = new( + "query_stats", "query_stats_hourly", "query_stats_daily", + new HashSet(StringComparer.Ordinal) { "database_name", "query_hash" }), + + ["procedure_stats"] = new( + "procedure_stats", "procedure_stats_hourly", "procedure_stats_daily", + new HashSet(StringComparer.Ordinal) { "database_name", "schema_name", "object_name" }), + + ["query_store_stats"] = new( + "query_store_stats", "query_store_stats_hourly", DailyView: null, + new HashSet(StringComparer.Ordinal) { "database_name", "module_name", "query_hash" }), + }; + + /// The CAGG coverage for , or null if it has no continuous aggregate. + public static ComposeCaggInfo? For(string rawTable) => + s_byTable.TryGetValue(rawTable, out var info) ? info : null; +} + +/// +/// Chooses the physical source tier for a composed panel by the AGE of the window's oldest point, never by the +/// display grain (an explicit bucket, or the Ranked/Scalar modes that resolve no grain at all, would otherwise +/// route wrong). Retention drops chunks by ACTUAL now, so the oldest point's age is measured from a caller-supplied +/// nowUtc (deterministic for tests), not from the window's end — a purely historical window ("30 to 25 days +/// ago") must reach the tier that still retains it or the query returns empty rows. +/// +/// Route thresholds sit a margin BELOW each retention horizon (raw kept 4d → route ≤3d; hourly kept 21d → +/// route ≤20d), so a drop lagging the boundary (1-day chunk granularity + the 3-day CAGG refresh) can never leave +/// the chosen tier missing the oldest chunk. The margin is pinned as a test invariant against the retention +/// constants. A whole window routes to the single tier its OLDEST point needs — uniform coarsening, no cross-tier +/// union (a future optimization); real-time aggregation on the CAGG stitches the still-filling recent edge. +/// +public static class ComposeSourceRouter +{ + /// Raw is chosen only for windows whose oldest point is within this age — a day inside the 4-day raw + /// retention, so raw never routes to an about-to-drop chunk. + public static readonly TimeSpan RawRouteMaxAge = TimeSpan.FromDays(3); + + /// The hourly CAGG is chosen up to this age — a day inside the 21-day hourly retention; older windows + /// fall to the daily CAGG (or stay on the hourly, capped, when no daily CAGG exists yet). + public static readonly TimeSpan HourlyRouteMaxAge = TimeSpan.FromDays(20); + + /// + /// The tier for given the window's oldest point () + /// relative to . Falls through to — the compiler's + /// unchanged path — whenever the table has no CAGG, the panel uses the #1568 module join (object_name), any + /// grouped/filtered dimension is outside the CAGG's coverage, or the window is recent enough that raw still + /// holds it. + /// + public static ComposeRoute Resolve(PanelPlan plan, DateTime nowUtc, DateTime windowStartUtc) + { + ArgumentNullException.ThrowIfNull(plan); + + var cagg = ComposeCaggCatalog.For(plan.Measure.SourceTable); + if (cagg is null) + { + return ComposeRoute.Raw; + } + + /* object_name on query_stats is stitched from procedure_stats read-time (#1568) — not a CAGG column. */ + if (plan.UsesModuleJoin) + { + return ComposeRoute.Raw; + } + + /* Every grouped/filtered dimension must live in the CAGG's GROUP BY, else the rollup can't reproduce it. */ + foreach (var dimension in plan.GroupBy) + { + if (!cagg.Covers(dimension.Name)) + { + return ComposeRoute.Raw; + } + } + + foreach (var filter in plan.Filters) + { + if (!cagg.Covers(filter.Dimension.Name)) + { + return ComposeRoute.Raw; + } + } + + var oldestAge = nowUtc - windowStartUtc; + if (oldestAge <= RawRouteMaxAge) + { + return ComposeRoute.Raw; + } + + if (oldestAge <= HourlyRouteMaxAge || cagg.DailyView is null) + { + return new ComposeRoute(ComposeSourceTier.Hourly, cagg.HourlyView); + } + + return new ComposeRoute(ComposeSourceTier.Daily, cagg.DailyView); + } +} From 9cea281bbbca2a51eac322a7aa45c66a22f8af57 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:19:46 -0400 Subject: [PATCH 025/296] Darling: wire CAGG read-routing into the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 2 of the read-routing feature (the router landed in the previous commit). - ComposeCaggValueMapper: rewrites a measure's aggregate to read the CAGG's pre-aggregated columns — SUM(delta_x)->SUM(x_sum), MIN/MAX the pre-computed columns, AVG(delta_x)-> SUM(x_sum)/SUM(sample_count) (exact — deltas never NULL), and Sum-ratios mapping both operands. CanRemap gates strictly to the two delta tables (query_stats, procedure_stats); Query Store's weighted/gauge remap and every other case fall back to raw (a later increment). - ComposeCompiler.Compile: resolves the route up front (EndUtc as "now" — the endpoint always ends the window at DateTime.UtcNow), falls back to raw when the measure can't be remapped, swaps the FROM relation + time column to the CAGG, clamps the display grain up to the tier's grain (a rollup can't render finer than it was materialized), and BuildValueExpr delegates to the mapper on a CAGG route. A CAGG route never coincides with the #1568 module join (object_name gates to raw), so the module CTE path is untouched. Existing compile tests are byte-for-byte unchanged (their 6h window keeps them inside the raw horizon). New tests cover hourly/daily FROM + the day-grain clamp, the column remap, AVG, Sum-ratios, and the non-CAGG / object_name / recent-window raw fallbacks. 18 mapper pins + 6 compile tests + the 13 router pins from increment 1. Co-Authored-By: Claude Opus 4.8 --- .../ComposeCaggValueMapperTests.cs | 116 ++++++++++++++++++ Darling/Darling.Tests/DarlingComposeTests.cs | 84 +++++++++++++ .../Compose/ComposeCaggValueMapper.cs | 101 +++++++++++++++ .../Compose/ComposeCompiler.cs | 38 +++++- 4 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 Darling/Darling.Tests/ComposeCaggValueMapperTests.cs create mode 100644 Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.cs diff --git a/Darling/Darling.Tests/ComposeCaggValueMapperTests.cs b/Darling/Darling.Tests/ComposeCaggValueMapperTests.cs new file mode 100644 index 000000000..3b072f2b1 --- /dev/null +++ b/Darling/Darling.Tests/ComposeCaggValueMapperTests.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 PerformanceMonitor.Darling.Service; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins — the raw-delta → CAGG-column rewrite, the single place a wrong column +/// would mean WRONG DATA on a panel (not just a truncation), so every emission is asserted exactly. Pure string +/// production, no DB — ungated. +/// +public sealed class ComposeCaggValueMapperTests +{ + private static ComposeMeasure Measure(string key) => + MeasureCatalog.Measures.First(m => string.Equals(m.Key, key, StringComparison.Ordinal)); + + private static PanelPlan Plan(string measureKey, ComposeAggregate aggregate = ComposeAggregate.Sum) => + new() + { + Measure = Measure(measureKey), + Aggregate = aggregate, + Unit = "ms", + Filters = Array.Empty(), + GroupBy = Array.Empty(), + Viz = "line", + }; + + /* ---------------- CanRemap gate ---------------- */ + + [Theory] + [InlineData("query_worker_us", ComposeAggregate.Sum, true)] + [InlineData("query_worker_us", ComposeAggregate.Avg, true)] + [InlineData("query_worker_us", ComposeAggregate.Min, true)] + [InlineData("query_worker_us", ComposeAggregate.Max, true)] + [InlineData("proc_executions", ComposeAggregate.Sum, true)] + [InlineData("query_avg_elapsed_us", ComposeAggregate.Sum, true)] /* Sum-ratio */ + [InlineData("proc_avg_cpu_us", ComposeAggregate.Sum, true)] /* Sum-ratio */ + [InlineData("wait_time_ms", ComposeAggregate.Sum, false)] /* no CAGG */ + [InlineData("qs_executions", ComposeAggregate.Sum, false)] /* Query Store — deferred */ + [InlineData("qs_avg_duration_us", ComposeAggregate.Sum, false)] /* QS weighted — deferred */ + [InlineData("sqlserver_cpu_utilization", ComposeAggregate.Avg, false)] /* gauge, no CAGG */ + public void CanRemap_GatesToDeltaTablesOnly(string measureKey, ComposeAggregate aggregate, bool expected) => + Assert.Equal(expected, ComposeCaggValueMapper.CanRemap(Measure(measureKey), aggregate)); + + /* ---------------- scalar remap (query_worker_us => worker_time_*) ---------------- */ + + [Fact] + public void Scalar_Sum_ReadsSumColumn() + { + Assert.Equal( + "CAST(SUM(f.worker_time_sum) AS double precision)", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("query_worker_us", ComposeAggregate.Sum))); + } + + [Fact] + public void Scalar_Min_ReadsMinColumn() + { + Assert.Equal( + "CAST(MIN(f.worker_time_min) AS double precision)", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("query_worker_us", ComposeAggregate.Min))); + } + + [Fact] + public void Scalar_Max_ReadsMaxColumn() + { + Assert.Equal( + "CAST(MAX(f.worker_time_max) AS double precision)", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("query_worker_us", ComposeAggregate.Max))); + } + + [Fact] + public void Scalar_Avg_IsSumOverSampleCount_NotAvgOfSums() + { + /* The exact reconstruction: AVG(delta) == SUM(x_sum)/SUM(sample_count), never AVG(x_sum). */ + Assert.Equal( + "(CAST(SUM(f.worker_time_sum) AS double precision) / NULLIF(SUM(f.sample_count), 0))", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("query_worker_us", ComposeAggregate.Avg))); + } + + [Fact] + public void Scalar_ProcedureStats_MapsSameConvention() + { + Assert.Equal( + "CAST(SUM(f.elapsed_time_sum) AS double precision)", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("proc_elapsed_us", ComposeAggregate.Sum))); + } + + /* ---------------- Sum-ratio remap (both operands => *_sum) ---------------- */ + + [Fact] + public void SumRatio_MapsBothOperandsToSumColumns() + { + /* query_avg_elapsed_us = query_elapsed_us (delta_elapsed_time) / query_executions (delta_execution_count). */ + Assert.Equal( + "(CAST(SUM(f.elapsed_time_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0))", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("query_avg_elapsed_us"))); + } + + [Fact] + public void SumRatio_Cpu_MapsWorkerOverExecutions() + { + Assert.Equal( + "(CAST(SUM(f.worker_time_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0))", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("proc_avg_cpu_us"))); + } +} diff --git a/Darling/Darling.Tests/DarlingComposeTests.cs b/Darling/Darling.Tests/DarlingComposeTests.cs index bc740013e..5d70de1b2 100644 --- a/Darling/Darling.Tests/DarlingComposeTests.cs +++ b/Darling/Darling.Tests/DarlingComposeTests.cs @@ -446,6 +446,90 @@ public void Compile_RejectsTooFineABucketOverTooWideAWindow() Assert.Contains("points", error!, StringComparison.OrdinalIgnoreCase); } + /* ─────────────── CAGG read-routing (age-based source selection) ─────────────── */ + + private static (ComposeCompiled? Compiled, string? Error) CompileAged(string planJson, int daysOld, string[]? servers = null) + { + var plan = ValidPlan(planJson); + var end = WindowEnd; /* EndUtc serves as "now" in the compiler */ + var start = end.AddDays(-daysOld); + return ComposeCompiler.Compile(plan, new ComposeRunContext(servers, start, end, ComposeRunContext.NoVariables)); + } + + [Fact] + public void Compile_OldWindow_RoutesQueryStatsToHourlyCagg_RemappingColumns() + { + var (compiled, error) = CompileAged( + "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 10); + Assert.True(error is null, error); + var sql = compiled!.Sql; + Assert.Contains("FROM collect.query_stats_hourly AS f", sql, StringComparison.Ordinal); + Assert.Contains("CAST(SUM(f.worker_time_sum) AS double precision)", sql, StringComparison.Ordinal); + Assert.Contains("date_trunc('hour', f.bucket)", sql, StringComparison.Ordinal); /* the CAGG time column */ + Assert.DoesNotContain("delta_worker_time", sql, StringComparison.Ordinal); /* not the raw column */ + } + + [Fact] + public void Compile_VeryOldWindow_RoutesToDailyCagg_AndCoarsensDisplayGrainToDay() + { + /* hour requested, but 40 days old -> daily CAGG, and the grain clamps up to day (can't render finer). */ + var (compiled, error) = CompileAged( + "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 40); + Assert.True(error is null, error); + Assert.Contains("FROM collect.query_stats_daily AS f", compiled!.Sql, StringComparison.Ordinal); + Assert.Contains("date_trunc('day', f.bucket)", compiled.Sql, StringComparison.Ordinal); + } + + [Fact] + public void Compile_OldWindow_AvgRemapsToSumOverSampleCount() + { + var (compiled, error) = CompileAged( + "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"avg\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 10); + Assert.True(error is null, error); + Assert.Contains("CAST(SUM(f.worker_time_sum) AS double precision) / NULLIF(SUM(f.sample_count), 0)", compiled!.Sql, StringComparison.Ordinal); + } + + [Fact] + public void Compile_OldWindow_SumRatio_RemapsBothOperands() + { + var (compiled, error) = CompileAged( + "{\"source\":\"query_stats\",\"ratio\":\"query_avg_elapsed_us\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 10); + Assert.True(error is null, error); + Assert.Contains("SUM(f.elapsed_time_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0)", compiled!.Sql, StringComparison.Ordinal); + } + + [Fact] + public void Compile_OldWindow_NonCaggTable_StaysRaw() + { + var (compiled, _) = CompileAged( + "{\"source\":\"wait_stats\",\"measure\":\"wait_time_ms\",\"aggregate\":\"sum\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 40); + Assert.Contains("FROM collect.wait_stats AS f", compiled!.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("_hourly", compiled.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("_daily", compiled.Sql, StringComparison.Ordinal); + } + + [Fact] + public void Compile_OldWindow_ObjectNameDimension_StaysRaw() + { + /* object_name is a #1568 module join, not a CAGG column -> raw even at 40 days old. */ + var (compiled, _) = CompileAged( + "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"topN\":10,\"groupBy\":[\"object_name\"],\"viz\":\"bar\"}", + daysOld: 40, servers: new[] { "PROD-01" }); + Assert.Contains("FROM collect.query_stats AS f", compiled!.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("query_stats_hourly", compiled.Sql, StringComparison.Ordinal); + Assert.DoesNotContain("query_stats_daily", compiled.Sql, StringComparison.Ordinal); + } + + [Fact] + public void Compile_RecentWindow_QueryStats_StaysRaw() + { + /* The 6-hour helper window is well inside the raw horizon -> raw columns, byte-for-byte as before. */ + var sql = Compile(ValidPlan("{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"timeBucket\":\"hour\",\"viz\":\"line\"}")); + Assert.Contains("FROM collect.query_stats AS f", sql, StringComparison.Ordinal); + Assert.Contains("delta_worker_time", sql, StringComparison.Ordinal); + Assert.DoesNotContain("worker_time_sum", sql, StringComparison.Ordinal); + } + [Fact] public void Compile_ResolvesAVariableToABoundValue() { diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.cs new file mode 100644 index 000000000..e325ee4e5 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.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; + +namespace PerformanceMonitor.Darling.Service; + +/// +/// Rewrites a measure's aggregate expression to read a continuous aggregate's PRE-aggregated columns instead of the +/// raw per-sweep delta column, when has chosen a CAGG tier. The value stays +/// identical because the CAGG summed exactly what the raw path would: SUM(delta_x) becomes +/// SUM(x_sum), MIN/MAX the pre-computed x_min/x_max, and AVG(delta_x) the +/// exact SUM(x_sum)/SUM(sample_count) (the deltas are never NULL, so count(*) is the divisor — proven +/// exact in review). A Sum-mode ratio maps both operands the same way. +/// +/// Scope of this increment: the two DELTA tables (query_stats, procedure_stats) — Cumulative scalars and +/// their Sum-ratios, the CAGG columns of which follow the fixed "strip delta_, append _sum/_min +/// /_max" naming. Query Store (execution-weighted means, gauge-only aggregates) and every non-delta case +/// return false from , so the compiler falls those panels back to raw — a later increment. +/// +public static class ComposeCaggValueMapper +{ + /// The compiler's fact-table alias (mirrors ComposeCompiler's private FactAlias). + private const string F = "f"; + + /// The delta tables whose CAGG columns follow the strip-delta_ naming this mapper handles. + private static bool IsDeltaCaggTable(string sourceTable) => + sourceTable is "query_stats" or "procedure_stats"; + + /// + /// Whether at can be re-expressed against its CAGG. The + /// compiler MUST fall back to raw when this is false (there is no correct rollup column to read). True only for + /// a delta-table Cumulative scalar (Sum/Avg/Min/Max) or a delta-table Sum-mode ratio. + /// + public static bool CanRemap(ComposeMeasure measure, ComposeAggregate aggregate) + { + ArgumentNullException.ThrowIfNull(measure); + + if (!IsDeltaCaggTable(measure.SourceTable)) + { + return false; + } + + if (measure.Kind == MeasureKind.Ratio) + { + /* Only the execution-weighted "avg per execution" ratios, which are SUM(delta)/SUM(delta) — both + operands re-aggregate additively on the CAGG. Weighted/Avg modes are Query Store / gauge only. */ + return measure.RatioMode == MeasureRatioMode.Sum; + } + + return measure.Archetype == MeasureArchetype.Cumulative + && aggregate is ComposeAggregate.Sum + or ComposeAggregate.Avg + or ComposeAggregate.Min + or ComposeAggregate.Max; + } + + /// + /// The native (double-precision, NOT yet unit-scaled — the caller applies the same unit conversion as the raw + /// path) aggregate expression reading the CAGG columns. Precondition: is true for + /// 's measure + aggregate. + /// + public static string BuildCaggNativeExpr(PanelPlan plan) + { + ArgumentNullException.ThrowIfNull(plan); + var measure = plan.Measure; + + if (measure.Kind == MeasureKind.Ratio) + { + var numerator = MeasureCatalog.Measure(measure.NumeratorKey!)!; + var denominator = MeasureCatalog.Measure(measure.DenominatorKey!)!; + return $"(CAST(SUM({F}.{SumColumn(numerator)}) AS double precision) " + + $"/ NULLIF(SUM({F}.{SumColumn(denominator)}), 0))"; + } + + var baseColumn = CaggBase(measure.DeltaColumn!); + return plan.Aggregate switch + { + ComposeAggregate.Sum => $"CAST(SUM({F}.{baseColumn}_sum) AS double precision)", + ComposeAggregate.Min => $"CAST(MIN({F}.{baseColumn}_min) AS double precision)", + ComposeAggregate.Max => $"CAST(MAX({F}.{baseColumn}_max) AS double precision)", + /* AVG(delta) == SUM(delta)/COUNT(delta) == SUM(x_sum)/SUM(sample_count), exact (deltas never NULL). */ + ComposeAggregate.Avg => + $"(CAST(SUM({F}.{baseColumn}_sum) AS double precision) / NULLIF(SUM({F}.sample_count), 0))", + _ => throw new InvalidOperationException( + $"aggregate {plan.Aggregate} is not CAGG-remappable — the caller must gate on CanRemap first."), + }; + } + + /// A Cumulative measure's CAGG SUM column: its delta column with delta_ stripped + _sum. + private static string SumColumn(ComposeMeasure measure) => CaggBase(measure.AggregationColumn!) + "_sum"; + + /// The CAGG base name for a delta_* column (the reshape's sum(delta_x) AS x_sum naming). + private static string CaggBase(string deltaColumn) => + deltaColumn.StartsWith("delta_", StringComparison.Ordinal) ? deltaColumn["delta_".Length..] : deltaColumn; +} diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs index 47426bad6..3f11d8926 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs @@ -88,6 +88,15 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, throw new ArgumentNullException(nameof(context)); } + /* Source routing: read a CAGG rollup instead of raw when the window's oldest point is past the raw + horizon (ComposeSourceRouter). EndUtc is "now" — the endpoint always sets end = DateTime.UtcNow. Fall + back to raw when the measure's value expression can't be remapped to the CAGG columns (CanRemap). */ + var route = ComposeSourceRouter.Resolve(plan, context.EndUtc, context.StartUtc); + if (route.IsCagg && !ComposeCaggValueMapper.CanRemap(plan.Measure, plan.Aggregate)) + { + route = ComposeRoute.Raw; + } + /* Auto resolves to a concrete grain from the window before anything downstream (ceiling + date_trunc); a non-Auto bucket passes through unchanged, so existing panels are byte-for-byte identical. */ var effectiveBucket = plan.TimeBucket; @@ -95,6 +104,13 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, { var windowSeconds = (context.EndUtc - context.StartUtc).TotalSeconds; effectiveBucket = MeasureCatalog.ResolveBucket(plan.TimeBucket, windowSeconds); + /* A CAGG can't render finer than its own bucket — clamp the display grain up to the tier's grain + (hourly -> at least hour, daily -> at least day) so date_trunc re-aggregates, never under-reads. */ + if (route.IsCagg) + { + effectiveBucket = CoarserBucket( + effectiveBucket, route.Tier == ComposeSourceTier.Daily ? ComposeTimeBucket.Day : ComposeTimeBucket.Hour); + } var bucketSeconds = MeasureCatalog.BucketSeconds(effectiveBucket); if (bucketSeconds > 0) { @@ -117,7 +133,7 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, var hasServerScope = context.Servers is { Count: > 0 }; var serverScopeParam = hasServerScope ? p.AddTextArray(context.Servers!) : null; - var timeColumn = s_timeColumnByTable[plan.Measure.SourceTable]; + var timeColumn = route.IsCagg ? ComposeRoute.CaggTimeColumn : s_timeColumnByTable[plan.Measure.SourceTable]; var sql = new StringBuilder(); /* The #1568 module CTE (window-bounded) — only when a dimension is stitched from it. */ @@ -163,10 +179,11 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, groupExprs.Add(expr); } - selectExprs.Add(BuildValueExpr(plan) + " AS value"); + selectExprs.Add(BuildValueExpr(plan, route) + " AS value"); sql.Append("SELECT ").Append(string.Join(", ", selectExprs)).Append('\n'); - sql.Append("FROM ").Append(PgSchemaGenerator.CollectSchema).Append('.').Append(plan.Measure.SourceTable) + sql.Append("FROM ").Append(PgSchemaGenerator.CollectSchema).Append('.') + .Append(route.IsCagg ? route.CaggRelation! : plan.Measure.SourceTable) .Append(" AS ").Append(FactAlias).Append('\n'); if (plan.UsesModuleJoin) @@ -281,12 +298,25 @@ private static ComposeCompiled CompileAnnotation(ComposeAnnotationSource source, private static string ColumnRef(ComposeDimension dimension) => (dimension.ViaModuleJoin ? ModuleAlias : FactAlias) + "." + dimension.Column; + /// The coarser of two buckets (None < Minute < Hour < Day) — clamps a display grain up to a + /// CAGG tier's own grain, since a rollup can never be rendered finer than it was materialized. + private static ComposeTimeBucket CoarserBucket(ComposeTimeBucket a, ComposeTimeBucket b) => + (ComposeTimeBucket)Math.Max((int)a, (int)b); + /// 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) + private static string BuildValueExpr(PanelPlan plan, ComposeRoute route) { var measure = plan.Measure; + /* A CAGG route reads the pre-aggregated rollup columns instead of the raw delta (the route gate guarantees + the measure + aggregate is remappable — CanRemap); unit-scaled exactly like the raw paths below. */ + if (route.IsCagg) + { + return ApplyUnitConversion( + ComposeCaggValueMapper.BuildCaggNativeExpr(plan), measure.UnitFamily, measure.NativeUnit, plan.Unit); + } + if (measure.Kind == MeasureKind.Ratio) { string native; From edb2d707df25b0144ad2481c756944089d9a707c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:21:08 -0400 Subject: [PATCH 026/296] CHANGELOG: [Unreleased] entry for #1625 (composer CAGG read-routing) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1e4aac3..c0e426ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling Web: custom time span on the view range picker** ([#1619]) - the rendered custom-view range picker gains a "Custom..." option (a number + a hours/days unit, up to the 90-day window ceiling) so a view is no longer limited to the six presets; a non-preset window stays selectable so it survives the 60s refresh. - **Darling: hourly continuous aggregates for query_stats + procedure_stats (composer query acceleration)** ([#1621]) - the two collector tables that dominate the store (~90%: query_stats ~145 GB, procedure_stats ~49 GB) now have hourly TimescaleDB continuous aggregates (`query_stats_hourly` / `procedure_stats_hourly`) that pre-materialize the exact shape every Custom Views composer panel over them does (`date_trunc('hour', collection_time)` + `SUM(delta_*)` GROUP BY a dimension), grouped by the same `MeasureCatalog` dimensions with SUM/MIN/MAX per delta column + a sample_count (so avg composes correctly at query time). Created idempotently as runtime setup in the worker's TimescaleDB block (not a versioned migration), `WITH NO DATA` with real-time aggregation on and the conservative hourly refresh policy (start_offset 3d, end_offset 1h). Measured ~49x / ~32x row reduction on the field fleet. Query acceleration only - raw rows still exist for the ~2-day hot window (analyze_*_plan needs the plan XML), and this makes no retention decision. - **Darling: three-tier retention - downsample past the raw hot window, keep the rollups** ([#1623]) - builds the retention layer on the hourly CAGGs (#1621): instead of a flat "drop raw after N days," three tiers so nothing is fully lost, only coarsened. **Raw 4 days** (`add_retention_policy` on query_stats / procedure_stats / query_store_stats - one day past the hourly CAGG's own 3-day refresh window). **Hourly CAGGs 21 days** - adds `query_store_stats_hourly` (built now, ahead of a writable-Query-Store primary; empty on a read-only replica until one exists) plus 21-day retention on all three hourly CAGGs. **Daily CAGGs kept indefinitely** - new HIERARCHICAL `query_stats_daily` / `procedure_stats_daily` sourced from the hourly CAGGs (not raw; 2.28.1 supports CAGG-on-CAGG), 1-day cadence, no retention policy. Each tier's horizon stays past the next tier's 3-day refresh start so a drop never outruns the aggregate preserving it; `add_retention_policy` is a chunk-level drop, so no off-hours window needed. All idempotent runtime setup in the worker's TimescaleDB block. `query_store_stats_daily` deferred until QS-hourly has real data to source from. Cold-start caveat on existing stores: backfill the hourly CAGGs past the raw horizon before the 4-day raw drop's first run (fresh installs are safe automatically). +- **Darling Web: composed panels read the CAGG rollups for old windows instead of truncating at the raw horizon** ([#1625]) - the read side of the retention tiers (#1623): once the 4-day raw drop runs, a Custom Views panel looking back further than 4 days would show only 4 days (the older history lives in the hourly/daily CAGGs the composer never read). The compiler now routes by the window's oldest-point AGE - recent -> raw, hour-age -> the hourly CAGG (21d), day-age -> the daily CAGG (indefinite) - and rewrites the measure's aggregate to the CAGG's pre-aggregated columns (`SUM(delta_x)` -> `SUM(x_sum)`, `AVG` -> `SUM(x_sum)/SUM(sample_count)` exactly, Sum-ratios mapping both operands). Age-based (not display grain, not window span) so Ranked/Scalar panels and historical windows route correctly; the display grain clamps up to the tier's grain since a rollup can't render finer than it was materialized. Scoped to query_stats + procedure_stats; Query Store and query_stats `object_name` (a #1568 module join) fall back to raw for now. Every existing panel is byte-for-byte unchanged inside the raw horizon. This unblocks activating retention at the field box - the raw drop coarsens old windows instead of truncating them. ### Changed @@ -522,6 +523,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1622]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1622 [#1623]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1623 [#1624]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1624 +[#1625]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1625 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 From 8ce7b23df78ddcf98dcb8e1eb203ab7cc423a71d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:31:27 -0400 Subject: [PATCH 027/296] Darling: extend CAGG read-routing to Query Store measures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query Store is no longer deferred — DARLING01 monitors a writable-QS instance (SQL2025 + HammerDB), so QS routing is real and testable. ComposeCaggValueMapper now maps the QS measures to their reshaped CAGG columns: - the execution-weighted average ratios (qs_avg_duration_us / qs_avg_cpu_us) -> SUM(duration_us_weighted_sum | cpu_us_weighted_sum) / NULLIF(SUM(execution_count_sum), 0) — the exact weighted mean the #1624 reshape stored the product-sum for, not an avg-of-avgs; - qs_executions (Delta over execution_count) -> SUM(execution_count_sum), or that over sample_count for Avg; - the peak gauges (qs_max_duration_us / qs_max_cpu_us) -> MAX(*_max), MAX only. CanRemap gates out the aggregates the reshaped CAGG kept no column for (MIN/MAX of executions, AVG/MIN of the peaks), which fall back to raw. Restructured into delta-table vs Query-Store remap paths. 10 new pins (QS CanRemap cases + each QS emission) + a QS routing compile test (exercises the no-daily-CAGG hourly fallback). Co-Authored-By: Claude Opus 4.8 --- .../ComposeCaggValueMapperTests.cs | 59 ++++++++- Darling/Darling.Tests/DarlingComposeTests.cs | 12 ++ .../Compose/ComposeCaggValueMapper.cs | 123 ++++++++++++++---- 3 files changed, 165 insertions(+), 29 deletions(-) diff --git a/Darling/Darling.Tests/ComposeCaggValueMapperTests.cs b/Darling/Darling.Tests/ComposeCaggValueMapperTests.cs index 3b072f2b1..5719c1f27 100644 --- a/Darling/Darling.Tests/ComposeCaggValueMapperTests.cs +++ b/Darling/Darling.Tests/ComposeCaggValueMapperTests.cs @@ -43,13 +43,17 @@ private static PanelPlan Plan(string measureKey, ComposeAggregate aggregate = Co [InlineData("query_worker_us", ComposeAggregate.Min, true)] [InlineData("query_worker_us", ComposeAggregate.Max, true)] [InlineData("proc_executions", ComposeAggregate.Sum, true)] - [InlineData("query_avg_elapsed_us", ComposeAggregate.Sum, true)] /* Sum-ratio */ - [InlineData("proc_avg_cpu_us", ComposeAggregate.Sum, true)] /* Sum-ratio */ - [InlineData("wait_time_ms", ComposeAggregate.Sum, false)] /* no CAGG */ - [InlineData("qs_executions", ComposeAggregate.Sum, false)] /* Query Store — deferred */ - [InlineData("qs_avg_duration_us", ComposeAggregate.Sum, false)] /* QS weighted — deferred */ + [InlineData("query_avg_elapsed_us", ComposeAggregate.Sum, true)] /* delta Sum-ratio */ + [InlineData("proc_avg_cpu_us", ComposeAggregate.Sum, true)] /* delta Sum-ratio */ + [InlineData("qs_executions", ComposeAggregate.Sum, true)] /* QS -> execution_count_sum */ + [InlineData("qs_executions", ComposeAggregate.Avg, true)] /* QS -> sum / sample_count */ + [InlineData("qs_executions", ComposeAggregate.Max, false)] /* QS kept no per-row max column */ + [InlineData("qs_avg_duration_us", ComposeAggregate.Sum, true)] /* QS execution-weighted mean */ + [InlineData("qs_max_duration_us", ComposeAggregate.Max, true)] /* QS peak -> _max */ + [InlineData("qs_max_duration_us", ComposeAggregate.Avg, false)] /* only MAX kept */ + [InlineData("wait_time_ms", ComposeAggregate.Sum, false)] /* no CAGG */ [InlineData("sqlserver_cpu_utilization", ComposeAggregate.Avg, false)] /* gauge, no CAGG */ - public void CanRemap_GatesToDeltaTablesOnly(string measureKey, ComposeAggregate aggregate, bool expected) => + public void CanRemap_GatesToSupportedCaggCases(string measureKey, ComposeAggregate aggregate, bool expected) => Assert.Equal(expected, ComposeCaggValueMapper.CanRemap(Measure(measureKey), aggregate)); /* ---------------- scalar remap (query_worker_us => worker_time_*) ---------------- */ @@ -113,4 +117,47 @@ public void SumRatio_Cpu_MapsWorkerOverExecutions() "(CAST(SUM(f.worker_time_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0))", ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("proc_avg_cpu_us"))); } + + /* ---------------- Query Store remap (execution-weighted mean, executions, peaks) ---------------- */ + + [Fact] + public void QueryStore_WeightedRatio_Duration_UsesWeightedSumOverExecutionCount() + { + /* SUM(avg_duration_us * execution_count) / SUM(execution_count) reconstructs from the reshaped columns. */ + Assert.Equal( + "(CAST(SUM(f.duration_us_weighted_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0))", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("qs_avg_duration_us"))); + } + + [Fact] + public void QueryStore_WeightedRatio_Cpu_UsesCpuWeightedSum() + { + Assert.Equal( + "(CAST(SUM(f.cpu_us_weighted_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0))", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("qs_avg_cpu_us"))); + } + + [Fact] + public void QueryStore_Executions_Sum_ReadsExecutionCountSum() + { + Assert.Equal( + "CAST(SUM(f.execution_count_sum) AS double precision)", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("qs_executions", ComposeAggregate.Sum))); + } + + [Fact] + public void QueryStore_Executions_Avg_IsSumOverSampleCount() + { + Assert.Equal( + "(CAST(SUM(f.execution_count_sum) AS double precision) / NULLIF(SUM(f.sample_count), 0))", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("qs_executions", ComposeAggregate.Avg))); + } + + [Fact] + public void QueryStore_PeakDuration_Max_ReadsMaxColumn() + { + Assert.Equal( + "CAST(MAX(f.max_duration_us_max) AS double precision)", + ComposeCaggValueMapper.BuildCaggNativeExpr(Plan("qs_max_duration_us", ComposeAggregate.Max))); + } } diff --git a/Darling/Darling.Tests/DarlingComposeTests.cs b/Darling/Darling.Tests/DarlingComposeTests.cs index 5d70de1b2..b0914a624 100644 --- a/Darling/Darling.Tests/DarlingComposeTests.cs +++ b/Darling/Darling.Tests/DarlingComposeTests.cs @@ -498,6 +498,18 @@ public void Compile_OldWindow_SumRatio_RemapsBothOperands() Assert.Contains("SUM(f.elapsed_time_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0)", compiled!.Sql, StringComparison.Ordinal); } + [Fact] + public void Compile_OldWindow_QueryStore_WeightedRatio_RoutesToHourlyCagg_RemapsWeightedMean() + { + /* QS has no daily CAGG, so even 40 days old routes to the hourly rollup; the weighted mean remaps to the + reshaped weighted-sum over execution_count_sum (exact, not an avg-of-avgs). */ + var (compiled, error) = CompileAged( + "{\"source\":\"query_store_stats\",\"ratio\":\"qs_avg_duration_us\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 40); + Assert.True(error is null, error); + Assert.Contains("FROM collect.query_store_stats_hourly AS f", compiled!.Sql, StringComparison.Ordinal); + Assert.Contains("SUM(f.duration_us_weighted_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0)", compiled.Sql, StringComparison.Ordinal); + } + [Fact] public void Compile_OldWindow_NonCaggTable_StaysRaw() { diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.cs index e325ee4e5..26d5bbedf 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCaggValueMapper.cs @@ -12,44 +12,55 @@ namespace PerformanceMonitor.Darling.Service; /// /// Rewrites a measure's aggregate expression to read a continuous aggregate's PRE-aggregated columns instead of the -/// raw per-sweep delta column, when has chosen a CAGG tier. The value stays -/// identical because the CAGG summed exactly what the raw path would: SUM(delta_x) becomes -/// SUM(x_sum), MIN/MAX the pre-computed x_min/x_max, and AVG(delta_x) the -/// exact SUM(x_sum)/SUM(sample_count) (the deltas are never NULL, so count(*) is the divisor — proven -/// exact in review). A Sum-mode ratio maps both operands the same way. +/// raw per-sweep column, when has chosen a CAGG tier. The value stays identical +/// because the CAGG summed exactly what the raw path would. /// -/// Scope of this increment: the two DELTA tables (query_stats, procedure_stats) — Cumulative scalars and -/// their Sum-ratios, the CAGG columns of which follow the fixed "strip delta_, append _sum/_min -/// /_max" naming. Query Store (execution-weighted means, gauge-only aggregates) and every non-delta case -/// return false from , so the compiler falls those panels back to raw — a later increment. +/// Two table families, two remaps: +/// • DELTA tables (query_stats, procedure_stats): SUM(delta_x)→SUM(x_sum), MIN/MAX the +/// pre-computed x_min/x_max, AVG(delta_x)→SUM(x_sum)/SUM(sample_count) (exact — deltas +/// never NULL); Sum-ratios map both operands. The CAGG columns follow the fixed "strip delta_, append +/// _sum/_min/_max" naming. +/// • QUERY STORE (query_store_stats): its measures are not deltas, so each maps to a specific reshaped +/// column — qs_executions (Delta over execution_count) → execution_count_sum (Sum) or that over +/// sample_count (Avg); the peak gauges (max_duration_us/max_cpu_time_us) → their _max +/// column, MAX only; and the execution-weighted average ratios → the pre-multiplied weighted sum over +/// execution_count_sum (the correct weighted mean, not an avg-of-avgs). Aggregates the reshaped CAGG did not +/// keep a column for (MIN/MAX of executions, AVG/MIN of the peaks) return false from and +/// fall back to raw. /// public static class ComposeCaggValueMapper { /// The compiler's fact-table alias (mirrors ComposeCompiler's private FactAlias). private const string F = "f"; - /// The delta tables whose CAGG columns follow the strip-delta_ naming this mapper handles. + private const string QueryStoreTable = "query_store_stats"; + private static bool IsDeltaCaggTable(string sourceTable) => sourceTable is "query_stats" or "procedure_stats"; /// /// Whether at can be re-expressed against its CAGG. The - /// compiler MUST fall back to raw when this is false (there is no correct rollup column to read). True only for - /// a delta-table Cumulative scalar (Sum/Avg/Min/Max) or a delta-table Sum-mode ratio. + /// compiler MUST fall back to raw when this is false — there is no correct rollup column to read. /// public static bool CanRemap(ComposeMeasure measure, ComposeAggregate aggregate) { ArgumentNullException.ThrowIfNull(measure); - if (!IsDeltaCaggTable(measure.SourceTable)) + if (IsDeltaCaggTable(measure.SourceTable)) { - return false; + return CanRemapDelta(measure, aggregate); } + return string.Equals(measure.SourceTable, QueryStoreTable, StringComparison.Ordinal) + && CanRemapQueryStore(measure, aggregate); + } + + private static bool CanRemapDelta(ComposeMeasure measure, ComposeAggregate aggregate) + { if (measure.Kind == MeasureKind.Ratio) { - /* Only the execution-weighted "avg per execution" ratios, which are SUM(delta)/SUM(delta) — both - operands re-aggregate additively on the CAGG. Weighted/Avg modes are Query Store / gauge only. */ + /* Only the execution-weighted "avg per execution" ratios — SUM(delta)/SUM(delta), both operands + re-aggregate additively on the CAGG. */ return measure.RatioMode == MeasureRatioMode.Sum; } @@ -60,22 +71,48 @@ or ComposeAggregate.Min or ComposeAggregate.Max; } + private static bool CanRemapQueryStore(ComposeMeasure measure, ComposeAggregate aggregate) + { + if (measure.Kind == MeasureKind.Ratio) + { + /* qs_avg_* — the execution-weighted mean, reconstructable from the reshaped weighted sums. */ + return measure.RatioMode == MeasureRatioMode.Weighted; + } + + return measure.Archetype switch + { + /* qs_executions: the CAGG kept a SUM (and sample_count) — so Sum and Avg reconstruct; there is no + per-row min/max column. */ + MeasureArchetype.Delta => aggregate is ComposeAggregate.Sum or ComposeAggregate.Avg, + /* qs_max_*: the CAGG kept only the MAX of the peak column. */ + MeasureArchetype.Gauge => aggregate is ComposeAggregate.Max, + _ => false, + }; + } + /// /// The native (double-precision, NOT yet unit-scaled — the caller applies the same unit conversion as the raw - /// path) aggregate expression reading the CAGG columns. Precondition: is true for - /// 's measure + aggregate. + /// path) aggregate expression reading the CAGG columns. Precondition: is true. /// public static string BuildCaggNativeExpr(PanelPlan plan) { ArgumentNullException.ThrowIfNull(plan); + + return string.Equals(plan.Measure.SourceTable, QueryStoreTable, StringComparison.Ordinal) + ? BuildQueryStoreExpr(plan) + : BuildDeltaExpr(plan); + } + + private static string BuildDeltaExpr(PanelPlan plan) + { var measure = plan.Measure; if (measure.Kind == MeasureKind.Ratio) { var numerator = MeasureCatalog.Measure(measure.NumeratorKey!)!; var denominator = MeasureCatalog.Measure(measure.DenominatorKey!)!; - return $"(CAST(SUM({F}.{SumColumn(numerator)}) AS double precision) " + - $"/ NULLIF(SUM({F}.{SumColumn(denominator)}), 0))"; + return $"(CAST(SUM({F}.{DeltaSumColumn(numerator)}) AS double precision) " + + $"/ NULLIF(SUM({F}.{DeltaSumColumn(denominator)}), 0))"; } var baseColumn = CaggBase(measure.DeltaColumn!); @@ -87,15 +124,55 @@ public static string BuildCaggNativeExpr(PanelPlan plan) /* AVG(delta) == SUM(delta)/COUNT(delta) == SUM(x_sum)/SUM(sample_count), exact (deltas never NULL). */ ComposeAggregate.Avg => $"(CAST(SUM({F}.{baseColumn}_sum) AS double precision) / NULLIF(SUM({F}.sample_count), 0))", - _ => throw new InvalidOperationException( - $"aggregate {plan.Aggregate} is not CAGG-remappable — the caller must gate on CanRemap first."), + _ => throw NotRemappable(plan.Aggregate), + }; + } + + private static string BuildQueryStoreExpr(PanelPlan plan) + { + var measure = plan.Measure; + + if (measure.Kind == MeasureKind.Ratio) + { + /* SUM(avg * execution_count) / SUM(execution_count) — the reshaped CAGG stores the numerator's + product-sum directly, and execution_count_sum is the denominator. */ + return $"(CAST(SUM({F}.{QsWeightedSumColumn(measure.WeightedValueColumn!)}) AS double precision) " + + $"/ NULLIF(SUM({F}.execution_count_sum), 0))"; + } + + if (measure.Archetype == MeasureArchetype.Gauge) + { + /* qs_max_* — MAX of the CAGG's per-bucket MAX column (Column + "_max"). */ + return $"CAST(MAX({F}.{measure.Column}_max) AS double precision)"; + } + + /* qs_executions (Delta over execution_count → execution_count_sum). */ + return plan.Aggregate switch + { + ComposeAggregate.Sum => $"CAST(SUM({F}.{measure.Column}_sum) AS double precision)", + ComposeAggregate.Avg => + $"(CAST(SUM({F}.{measure.Column}_sum) AS double precision) / NULLIF(SUM({F}.sample_count), 0))", + _ => throw NotRemappable(plan.Aggregate), }; } /// A Cumulative measure's CAGG SUM column: its delta column with delta_ stripped + _sum. - private static string SumColumn(ComposeMeasure measure) => CaggBase(measure.AggregationColumn!) + "_sum"; + private static string DeltaSumColumn(ComposeMeasure measure) => CaggBase(measure.AggregationColumn!) + "_sum"; /// The CAGG base name for a delta_* column (the reshape's sum(delta_x) AS x_sum naming). private static string CaggBase(string deltaColumn) => deltaColumn.StartsWith("delta_", StringComparison.Ordinal) ? deltaColumn["delta_".Length..] : deltaColumn; + + /// The reshaped QS CAGG's execution-weighted-sum column for a weighted measure's value column + /// (hand-named in the CAGG DDL, not derivable by a rule). + private static string QsWeightedSumColumn(string weightedValueColumn) => weightedValueColumn switch + { + "avg_duration_us" => "duration_us_weighted_sum", + "avg_cpu_time_us" => "cpu_us_weighted_sum", + _ => throw new InvalidOperationException( + $"no CAGG weighted-sum column for Query Store value column '{weightedValueColumn}'."), + }; + + private static InvalidOperationException NotRemappable(ComposeAggregate aggregate) => + new($"aggregate {aggregate} is not CAGG-remappable — the caller must gate on CanRemap first."); } From bd910ea5c05d67ab5a475368379c63c9e5dc750c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:03:30 -0400 Subject: [PATCH 028/296] Darling: query_store_stats_daily CAGG (QS routing past the 21-day hourly horizon) Removes the last routing deferral. The reshaped QS hourly CAGG has real writable-QS data now (validated exact against 159M HammerDB executions), so its daily tier can be built the same hierarchical way as query_stats_daily / procedure_stats_daily: - query_store_stats_daily: hierarchical from query_store_stats_hourly, grouped by the composer's QS dims (module_name / query_hash), SUM re-aggregating the hourly weighted sums (so the weighted mean composes as duration_us_weighted_sum / execution_count_sum across days) + MAX of the peaks. SAME column names as the hourly, so ComposeCaggValueMapper reads both with no change. Kept indefinitely (no retention policy), like the other dailies. - ComposeSourceRouter: query_store_stats gains its DailyView, so a >20d QS window routes to the daily instead of capping at the hourly's 21d retention. Pins: the QS daily CAGG shape; router now routes a 40d QS window to the daily; a compile test for the daily FROM + the weighted-mean remap. Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/ComposeSourceRouterTests.cs | 9 +++-- Darling/Darling.Tests/DarlingComposeTests.cs | 19 ++++++++--- .../TimescaleContinuousAggregateTests.cs | 19 +++++++++++ .../Compose/ComposeSourceRouter.cs | 2 +- .../TimescaleSupport.cs | 34 +++++++++++++++++-- 5 files changed, 70 insertions(+), 13 deletions(-) diff --git a/Darling/Darling.Tests/ComposeSourceRouterTests.cs b/Darling/Darling.Tests/ComposeSourceRouterTests.cs index cbedc2a13..39d626c4f 100644 --- a/Darling/Darling.Tests/ComposeSourceRouterTests.cs +++ b/Darling/Darling.Tests/ComposeSourceRouterTests.cs @@ -139,13 +139,12 @@ public void QueryStore_RoutesByComposerDims() } [Fact] - public void QueryStore_NoDailyCagg_OldWindowStaysHourly() + public void QueryStore_OldWindow_RoutesToDailyCagg() { - /* No query_store_stats_daily yet (deferred) → a 40-day window falls to the hourly CAGG (capped at its 21d), - NOT a non-existent daily. */ + /* query_store_stats_daily now exists → a 40-day window routes to it, not the 21d-capped hourly. */ var route = ComposeSourceRouter.Resolve(Plan("qs_executions"), Now, Now.AddDays(-40)); - Assert.Equal(ComposeSourceTier.Hourly, route.Tier); - Assert.Equal("query_store_stats_hourly", route.CaggRelation); + Assert.Equal(ComposeSourceTier.Daily, route.Tier); + Assert.Equal("query_store_stats_daily", route.CaggRelation); } [Fact] diff --git a/Darling/Darling.Tests/DarlingComposeTests.cs b/Darling/Darling.Tests/DarlingComposeTests.cs index b0914a624..6a030e672 100644 --- a/Darling/Darling.Tests/DarlingComposeTests.cs +++ b/Darling/Darling.Tests/DarlingComposeTests.cs @@ -499,17 +499,28 @@ public void Compile_OldWindow_SumRatio_RemapsBothOperands() } [Fact] - public void Compile_OldWindow_QueryStore_WeightedRatio_RoutesToHourlyCagg_RemapsWeightedMean() + public void Compile_OldWindow_QueryStore_WeightedRatio_RoutesToCagg_RemapsWeightedMean() { - /* QS has no daily CAGG, so even 40 days old routes to the hourly rollup; the weighted mean remaps to the - reshaped weighted-sum over execution_count_sum (exact, not an avg-of-avgs). */ + /* A 10-day window routes QS to the hourly CAGG; the weighted mean remaps to the reshaped weighted-sum over + execution_count_sum (exact, not an avg-of-avgs). A >21d window would route to query_store_stats_daily. */ var (compiled, error) = CompileAged( - "{\"source\":\"query_store_stats\",\"ratio\":\"qs_avg_duration_us\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 40); + "{\"source\":\"query_store_stats\",\"ratio\":\"qs_avg_duration_us\",\"timeBucket\":\"hour\",\"viz\":\"line\"}", daysOld: 10); Assert.True(error is null, error); Assert.Contains("FROM collect.query_store_stats_hourly AS f", compiled!.Sql, StringComparison.Ordinal); Assert.Contains("SUM(f.duration_us_weighted_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0)", compiled.Sql, StringComparison.Ordinal); } + [Fact] + public void Compile_VeryOldWindow_QueryStore_RoutesToDailyCagg() + { + /* A 40-day QS window now routes to query_store_stats_daily (same weighted-sum columns as the hourly). */ + var (compiled, error) = CompileAged( + "{\"source\":\"query_store_stats\",\"ratio\":\"qs_avg_duration_us\",\"timeBucket\":\"day\",\"viz\":\"line\"}", daysOld: 40); + Assert.True(error is null, error); + Assert.Contains("FROM collect.query_store_stats_daily AS f", compiled!.Sql, StringComparison.Ordinal); + Assert.Contains("SUM(f.duration_us_weighted_sum) AS double precision) / NULLIF(SUM(f.execution_count_sum), 0)", compiled.Sql, StringComparison.Ordinal); + } + [Fact] public void Compile_OldWindow_NonCaggTable_StaysRaw() { diff --git a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs index 23f71ed7f..d17cc3618 100644 --- a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs +++ b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs @@ -104,6 +104,25 @@ composes EXACTLY (avg*count = the interval total, summed = the true total) — n Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); } + [Fact] + public void QueryStoreStatsDaily_IsHierarchical_FromQsHourly_SameColumnsAsHourly() + { + var sql = TimescaleSupport.CreateQueryStoreStatsDailySql; + + Assert.Contains("CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_store_stats_daily", sql, StringComparison.Ordinal); + Assert.Contains("FROM collect.query_store_stats_hourly", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id, server_name, database_name, module_name, query_hash, time_bucket('1 day', bucket)", sql, StringComparison.Ordinal); + /* Same column NAMES as the hourly (so ComposeCaggValueMapper reads both unchanged): SUM the weighted sums, + MAX the peaks, SUM executions + sample_count. */ + Assert.Contains("sum(duration_us_weighted_sum) AS duration_us_weighted_sum", sql, StringComparison.Ordinal); + Assert.Contains("sum(cpu_us_weighted_sum) AS cpu_us_weighted_sum", sql, StringComparison.Ordinal); + Assert.Contains("sum(execution_count_sum) AS execution_count_sum", sql, StringComparison.Ordinal); + Assert.Contains("max(max_duration_us_max) AS max_duration_us_max", sql, StringComparison.Ordinal); + Assert.Contains("max(max_cpu_time_us_max) AS max_cpu_time_us_max", sql, StringComparison.Ordinal); + Assert.Contains("sum(sample_count) AS sample_count", sql, StringComparison.Ordinal); + Assert.Contains("WITH NO DATA", sql, StringComparison.Ordinal); + } + [Fact] public void QueryStatsDaily_IsHierarchical_SourcedFromHourlyCagg_GroupedByExplicitDayBucket() { diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs index a32a652a8..5ed0b01d5 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs @@ -77,7 +77,7 @@ public static class ComposeCaggCatalog new HashSet(StringComparer.Ordinal) { "database_name", "schema_name", "object_name" }), ["query_store_stats"] = new( - "query_store_stats", "query_store_stats_hourly", DailyView: null, + "query_store_stats", "query_store_stats_hourly", "query_store_stats_daily", new HashSet(StringComparer.Ordinal) { "database_name", "module_name", "query_hash" }), }; diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index 0abc8f3b7..dd3e9fe34 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -255,6 +255,11 @@ public static string AddCompressionPolicySql(string table) /// 's procedure_stats sibling (sourced from procedure_stats_hourly). public const string ProcedureStatsDailyView = "procedure_stats_daily"; + /// The Query Store DAILY continuous aggregate — hierarchical from , + /// same composer dims (module_name / query_hash) + weighted sums. Kept indefinitely; a QS window past the + /// hourly's 21d horizon routes here. + public const string QueryStoreStatsDailyView = "query_store_stats_daily"; + /// /// The query_stats hourly continuous aggregate. 1-hour buckets grouped by the SAME dimensions the composer's /// MeasureCatalog uses for query_stats (server_id / server_name / database_name / query_hash), so a @@ -401,6 +406,30 @@ FROM collect.query_stats_hourly sum(sample_count) AS sample_count FROM collect.procedure_stats_hourly GROUP BY server_id, server_name, database_name, schema_name, object_name, time_bucket('1 day', bucket) +WITH NO DATA"; + + /// The Query Store DAILY continuous aggregate — 's Query Store + /// sibling, hierarchical from and grouped by the composer's QS dims + /// (module_name / query_hash). SUM re-aggregates the hourly weighted sums (so the weighted mean composes as + /// duration_us_weighted_sum / execution_count_sum across days) and MAX the peaks. Same column NAMES as the + /// hourly, so ComposeCaggValueMapper reads both with no change. Explicit-time_bucket GROUP BY. + public const string CreateQueryStoreStatsDailySql = @"CREATE MATERIALIZED VIEW IF NOT EXISTS collect.query_store_stats_daily +WITH (timescaledb.continuous) AS +SELECT + server_id, + server_name, + database_name, + module_name, + query_hash, + time_bucket('1 day', bucket) AS bucket, + sum(execution_count_sum) AS execution_count_sum, + sum(duration_us_weighted_sum) AS duration_us_weighted_sum, + sum(cpu_us_weighted_sum) AS cpu_us_weighted_sum, + max(max_duration_us_max) AS max_duration_us_max, + max(max_cpu_time_us_max) AS max_cpu_time_us_max, + sum(sample_count) AS sample_count +FROM collect.query_store_stats_hourly +GROUP BY server_id, server_name, database_name, module_name, query_hash, time_bucket('1 day', bucket) WITH NO DATA"; /// @@ -504,8 +533,6 @@ public static async Task EnsureContinuousAggregatesAsync(NpgsqlConnection c // Hourly CAGGs FIRST (the two delta tables + query_store_stats), THEN the daily tier — the daily CAGGs are // hierarchical (sourced from the hourly CAGGs), so the hourly ones must be created earlier in this ordered // sweep. Daily policies use the 1-day end-offset/schedule; the hourly ones take the helper's defaults. - // (query_store_stats_daily is deliberately NOT here yet — added once query_store_stats_hourly has real data - // from a writable-QS primary to source from.) var aggregates = new[] { (CreateSql: CreateQueryStatsHourlySql, View: QueryStatsHourlyView, PolicySql: AddContinuousAggregatePolicySql(QueryStatsHourlyView)), @@ -513,6 +540,7 @@ public static async Task EnsureContinuousAggregatesAsync(NpgsqlConnection c (CreateSql: CreateQueryStoreStatsHourlySql, View: QueryStoreStatsHourlyView, PolicySql: AddContinuousAggregatePolicySql(QueryStoreStatsHourlyView)), (CreateSql: CreateQueryStatsDailySql, View: QueryStatsDailyView, PolicySql: AddContinuousAggregatePolicySql(QueryStatsDailyView, "1 day", "1 day")), (CreateSql: CreateProcedureStatsDailySql, View: ProcedureStatsDailyView, PolicySql: AddContinuousAggregatePolicySql(ProcedureStatsDailyView, "1 day", "1 day")), + (CreateSql: CreateQueryStoreStatsDailySql, View: QueryStoreStatsDailyView, PolicySql: AddContinuousAggregatePolicySql(QueryStoreStatsDailyView, "1 day", "1 day")), }; var ready = 0; @@ -541,7 +569,7 @@ public static async Task EnsureContinuousAggregatesAsync(NpgsqlConnection c } logger?.LogInformation( - "TimescaleDB: {Ready}/{Total} continuous aggregate(s) ready (3 hourly: query_stats, procedure_stats, query_store_stats; 2 daily: query_stats, procedure_stats)", + "TimescaleDB: {Ready}/{Total} continuous aggregate(s) ready (3 hourly: query_stats, procedure_stats, query_store_stats; 3 daily: query_stats, procedure_stats, query_store_stats)", ready, aggregates.Length); return ready; } From 9c0e8f6f232824a1792c85c81fb6a87049f0a797 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:05:21 -0400 Subject: [PATCH 029/296] CHANGELOG: [Unreleased] entry for #1626 (query_store_stats_daily); correct #1625 QS scope Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0e426ba4..3f9066d5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling Web: custom time span on the view range picker** ([#1619]) - the rendered custom-view range picker gains a "Custom..." option (a number + a hours/days unit, up to the 90-day window ceiling) so a view is no longer limited to the six presets; a non-preset window stays selectable so it survives the 60s refresh. - **Darling: hourly continuous aggregates for query_stats + procedure_stats (composer query acceleration)** ([#1621]) - the two collector tables that dominate the store (~90%: query_stats ~145 GB, procedure_stats ~49 GB) now have hourly TimescaleDB continuous aggregates (`query_stats_hourly` / `procedure_stats_hourly`) that pre-materialize the exact shape every Custom Views composer panel over them does (`date_trunc('hour', collection_time)` + `SUM(delta_*)` GROUP BY a dimension), grouped by the same `MeasureCatalog` dimensions with SUM/MIN/MAX per delta column + a sample_count (so avg composes correctly at query time). Created idempotently as runtime setup in the worker's TimescaleDB block (not a versioned migration), `WITH NO DATA` with real-time aggregation on and the conservative hourly refresh policy (start_offset 3d, end_offset 1h). Measured ~49x / ~32x row reduction on the field fleet. Query acceleration only - raw rows still exist for the ~2-day hot window (analyze_*_plan needs the plan XML), and this makes no retention decision. - **Darling: three-tier retention - downsample past the raw hot window, keep the rollups** ([#1623]) - builds the retention layer on the hourly CAGGs (#1621): instead of a flat "drop raw after N days," three tiers so nothing is fully lost, only coarsened. **Raw 4 days** (`add_retention_policy` on query_stats / procedure_stats / query_store_stats - one day past the hourly CAGG's own 3-day refresh window). **Hourly CAGGs 21 days** - adds `query_store_stats_hourly` (built now, ahead of a writable-Query-Store primary; empty on a read-only replica until one exists) plus 21-day retention on all three hourly CAGGs. **Daily CAGGs kept indefinitely** - new HIERARCHICAL `query_stats_daily` / `procedure_stats_daily` sourced from the hourly CAGGs (not raw; 2.28.1 supports CAGG-on-CAGG), 1-day cadence, no retention policy. Each tier's horizon stays past the next tier's 3-day refresh start so a drop never outruns the aggregate preserving it; `add_retention_policy` is a chunk-level drop, so no off-hours window needed. All idempotent runtime setup in the worker's TimescaleDB block. `query_store_stats_daily` deferred until QS-hourly has real data to source from. Cold-start caveat on existing stores: backfill the hourly CAGGs past the raw horizon before the 4-day raw drop's first run (fresh installs are safe automatically). -- **Darling Web: composed panels read the CAGG rollups for old windows instead of truncating at the raw horizon** ([#1625]) - the read side of the retention tiers (#1623): once the 4-day raw drop runs, a Custom Views panel looking back further than 4 days would show only 4 days (the older history lives in the hourly/daily CAGGs the composer never read). The compiler now routes by the window's oldest-point AGE - recent -> raw, hour-age -> the hourly CAGG (21d), day-age -> the daily CAGG (indefinite) - and rewrites the measure's aggregate to the CAGG's pre-aggregated columns (`SUM(delta_x)` -> `SUM(x_sum)`, `AVG` -> `SUM(x_sum)/SUM(sample_count)` exactly, Sum-ratios mapping both operands). Age-based (not display grain, not window span) so Ranked/Scalar panels and historical windows route correctly; the display grain clamps up to the tier's grain since a rollup can't render finer than it was materialized. Scoped to query_stats + procedure_stats; Query Store and query_stats `object_name` (a #1568 module join) fall back to raw for now. Every existing panel is byte-for-byte unchanged inside the raw horizon. This unblocks activating retention at the field box - the raw drop coarsens old windows instead of truncating them. +- **Darling Web: composed panels read the CAGG rollups for old windows instead of truncating at the raw horizon** ([#1625]) - the read side of the retention tiers (#1623): once the 4-day raw drop runs, a Custom Views panel looking back further than 4 days would show only 4 days (the older history lives in the hourly/daily CAGGs the composer never read). The compiler now routes by the window's oldest-point AGE - recent -> raw, hour-age -> the hourly CAGG (21d), day-age -> the daily CAGG (indefinite) - and rewrites the measure's aggregate to the CAGG's pre-aggregated columns (`SUM(delta_x)` -> `SUM(x_sum)`, `AVG` -> `SUM(x_sum)/SUM(sample_count)` exactly, Sum-ratios mapping both operands). Age-based (not display grain, not window span) so Ranked/Scalar panels and historical windows route correctly; the display grain clamps up to the tier's grain since a rollup can't render finer than it was materialized. Covers query_stats, procedure_stats AND Query Store (whose execution-weighted mean reconstructs exactly from #1624's reshaped weighted sums, validated on 159M live executions); only query_stats `object_name` (a #1568 module join) falls back to raw. Every existing panel is byte-for-byte unchanged inside the raw horizon. This unblocks activating retention at the field box - the raw drop coarsens old windows instead of truncating them. +- **Darling Web: Query Store panels route past the 21-day hourly horizon (query_store_stats_daily)** ([#1626]) - completes the CAGG read-routing (#1625): adds the hierarchical `query_store_stats_daily` (sourced from the reshaped `query_store_stats_hourly`, same composer dims + weighted sums, kept indefinitely), so a QS Custom Views panel looking back past the hourly CAGG's 21-day retention routes to the daily rollup instead of capping. The mapper reads the daily's identical columns unchanged. Removes the last read-routing gap; only query_stats `object_name` (a #1568 module join) still falls back to raw. ### Changed @@ -524,6 +525,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1623]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1623 [#1624]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1624 [#1625]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1625 +[#1626]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1626 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 From 65e2d0d9abef3e32d6d392314eee8917c56addce Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:20:33 -0400 Subject: [PATCH 030/296] Darling: retained sql_handle->module map (object_name routing, increment 1) The join source for object_name on old query_stats CAGG windows: DarlingModuleMap creates a collect.module_map table (runtime setup, not a migration) and upserts (server_name, sql_handle) -> (database_name, schema_name, object_name) from the last 2 days of procedure_stats, accumulating indefinitely (a handle keeps its last-known module after its raw drops at 4d). Seeded at startup, kept current by the daily maintenance sweep. Failure-isolated. 2 pins. Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/DarlingModuleMapTests.cs | 52 +++++++++ .../DarlingWorker.cs | 11 ++ .../DarlingModuleMap.cs | 105 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 Darling/Darling.Tests/DarlingModuleMapTests.cs create mode 100644 Darling/PerformanceMonitor.Darling.Storage/DarlingModuleMap.cs diff --git a/Darling/Darling.Tests/DarlingModuleMapTests.cs b/Darling/Darling.Tests/DarlingModuleMapTests.cs new file mode 100644 index 000000000..ca0da97b3 --- /dev/null +++ b/Darling/Darling.Tests/DarlingModuleMapTests.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; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the retained sql_handle→module map SQL (the join source for object_name on OLD query_stats CAGG windows). +/// Ungated — pure SQL-shape assertions. +/// +public sealed class DarlingModuleMapTests +{ + [Fact] + public void CreateTable_HasCompositePkAndAttributionColumns() + { + var sql = DarlingModuleMap.CreateTableSql; + + Assert.Contains("CREATE TABLE IF NOT EXISTS collect.module_map", sql, StringComparison.Ordinal); + /* (server_name, sql_handle) identity — a handle reused across servers attributes per server. */ + Assert.Contains("PRIMARY KEY (server_name, sql_handle)", sql, StringComparison.Ordinal); + foreach (var col in new[] { "server_name", "sql_handle", "database_name", "schema_name", "object_name", "last_seen" }) + { + Assert.Contains(col, sql, StringComparison.Ordinal); + } + } + + [Fact] + public void Refresh_UpsertsLatestPerHandle_FromRecentProcedureStats_Accumulating() + { + var sql = DarlingModuleMap.RefreshSql; + + Assert.Contains("INSERT INTO collect.module_map", sql, StringComparison.Ordinal); + Assert.Contains("FROM collect.procedure_stats", sql, StringComparison.Ordinal); + /* the latest row per (server, handle) */ + Assert.Contains("DISTINCT ON (server_name, sql_handle)", sql, StringComparison.Ordinal); + Assert.Contains("ORDER BY server_name, sql_handle, collection_time DESC", sql, StringComparison.Ordinal); + /* bounded read, comfortably inside procedure_stats' 4-day raw retention */ + Assert.Contains("collection_time >= now() - interval '2 days'", sql, StringComparison.Ordinal); + Assert.Contains("sql_handle IS NOT NULL", sql, StringComparison.Ordinal); + /* accumulate: upsert, and only ever advance — a stale run can't regress a fresher attribution */ + Assert.Contains("ON CONFLICT (server_name, sql_handle) DO UPDATE SET", sql, StringComparison.Ordinal); + Assert.Contains("WHERE module_map.last_seen IS NULL OR EXCLUDED.last_seen >= module_map.last_seen", sql, StringComparison.Ordinal); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs index d035155c4..c52ee7f30 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWorker.cs @@ -616,6 +616,12 @@ guard skips the conversion and this heals it (collection_log is outside the coll { await using var tuningConnection = await postgres.OpenConnectionAsync(stoppingToken); await PgTableTuning.ApplyAsync(tuningConnection, _logger, stoppingToken); + // The retained sql_handle->module map (#1568 object_name for OLD query_stats windows the CAGG serves, + // after procedure_stats raw drops at 4d): create it, then seed it from recent procedure_stats. + if (await DarlingModuleMap.EnsureTableAsync(tuningConnection, _logger, stoppingToken)) + { + await DarlingModuleMap.RefreshAsync(tuningConnection, _logger, stoppingToken); + } } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -983,6 +989,11 @@ 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); + + /* Keep the retained sql_handle->module map current (object_name attribution for old query_stats + CAGG windows). Rides the daily purge; failure-isolated inside RefreshAsync. */ + await using var moduleMapConnection = await postgres.OpenConnectionAsync(stoppingToken); + await DarlingModuleMap.RefreshAsync(moduleMapConnection, _logger, stoppingToken); } /* Stage 4 fleet-level self-alert: the store disk-pressure backstop. The daily purge is the ONLY diff --git a/Darling/PerformanceMonitor.Darling.Storage/DarlingModuleMap.cs b/Darling/PerformanceMonitor.Darling.Storage/DarlingModuleMap.cs new file mode 100644 index 000000000..c3c88d1b3 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Storage/DarlingModuleMap.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; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Npgsql; + +namespace PerformanceMonitor.Darling.Storage; + +/// +/// The retained (server_name, sql_handle) → module attribution table. query_stats' object_name is not +/// a query_stats column — it is stitched from procedure_stats via sql_handle at read time (#1568). That join +/// works against RAW procedure_stats, but raw is dropped at 4 days, so an OLD-window query_stats panel (served by +/// the query_stats CAGG) has no live procedure_stats to join to. This table is that join's retained source: it +/// ACCUMULATES the sql_handle → object_name mapping indefinitely (tiny — one row per distinct handle), upserted from +/// the last couple of days of procedure_stats, so the composer can attribute CAGG rows to their module for any age. +/// +/// Idempotent RUNTIME setup (a plain table created in the worker's setup, NOT a versioned migration — the +/// same posture as the CAGGs / , so it never bumps the schema version or gates the +/// viewer). Deliberately NOT a collector table and NOT a hypertable: it is a small mutable dimension, not +/// time-series, and NOT subject to the tiered retention. +/// +public static class DarlingModuleMap +{ + private const int SetupTimeoutSeconds = 300; + + /// The attribution table. (server_name, sql_handle) is the identity (a handle reused across + /// servers attributes per server, matching the #1568 join's partitioning). + public const string CreateTableSql = @"CREATE TABLE IF NOT EXISTS collect.module_map ( + server_name text NOT NULL, + sql_handle text NOT NULL, + database_name text, + schema_name text, + object_name text, + last_seen timestamp, + PRIMARY KEY (server_name, sql_handle) +)"; + + /// + /// Upserts the latest (server_name, sql_handle) → (database_name, schema_name, object_name) from the last + /// two days of procedure_stats — well inside its 4-day raw retention, so no handle is missed between runs. + /// ACCUMULATES: a handle that stops appearing keeps its last-known attribution forever (no delete). DISTINCT ON + /// keeps the most-recent row per handle; the ON CONFLICT only advances a row (never regresses last_seen), so a + /// stale run can't overwrite a fresher attribution. now() is fine here — this is runtime maintenance, not + /// the deterministic compiler. + /// + public const string RefreshSql = @"INSERT INTO collect.module_map (server_name, sql_handle, database_name, schema_name, object_name, last_seen) +SELECT DISTINCT ON (server_name, sql_handle) + server_name, sql_handle, database_name, schema_name, object_name, collection_time +FROM collect.procedure_stats +WHERE collection_time >= now() - interval '2 days' + AND sql_handle IS NOT NULL + AND sql_handle <> '' +ORDER BY server_name, sql_handle, collection_time DESC +ON CONFLICT (server_name, sql_handle) DO UPDATE SET + database_name = EXCLUDED.database_name, + schema_name = EXCLUDED.schema_name, + object_name = EXCLUDED.object_name, + last_seen = EXCLUDED.last_seen +WHERE module_map.last_seen IS NULL OR EXCLUDED.last_seen >= module_map.last_seen"; + + /// Creates the table if absent (idempotent). Returns true on success; a failure warns and leaves + /// object_name-on-query_stats routing to fall back to raw (never kills startup). + public static async Task EnsureTableAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + try + { + using var command = new NpgsqlCommand(CreateTableSql, connection) { CommandTimeout = SetupTimeoutSeconds }; + await command.ExecuteNonQueryAsync(cancellationToken); + return true; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("module_map table setup failed — object_name-on-query_stats routing falls back to raw: {Message}", ex.Message); + return false; + } + } + + /// Upserts the map from recent procedure_stats. Returns the number of rows upserted; a failure warns + /// and the existing map keeps serving (never throws). Called from the periodic maintenance sweep. + public static async Task RefreshAsync(NpgsqlConnection connection, ILogger? logger, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(connection); + try + { + using var command = new NpgsqlCommand(RefreshSql, connection) { CommandTimeout = SetupTimeoutSeconds }; + var rows = await command.ExecuteNonQueryAsync(cancellationToken); + logger?.LogInformation("module_map: upserted {Rows} sql_handle→module attribution(s) from recent procedure_stats.", rows); + return rows; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger?.LogWarning("module_map refresh failed — the existing map keeps serving: {Message}", ex.Message); + return 0; + } + } +} From 2f971ee4b033a8cd7761f126d5418ebd765a75f8 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:32:09 -0400 Subject: [PATCH 031/296] Darling: carry sql_handle in query_stats CAGGs (object_name routing groundwork) The query_stats hourly + daily continuous aggregates now GROUP BY sql_handle, so the composer can attribute a rolled-up query_stats panel to a module (object_name) by joining collect.module_map on sql_handle -- instead of truncating object_name panels to raw's 4-day window because object_name lived only in the read-time #1568 procedure_stats CTE. Adds a DropStale self-heal entry: an existing query_stats_hourly without a sql_handle column is dropped (CASCADE takes query_stats_daily) and rebuilt by the ensure sweep, so a field box upgrading in place reshapes without manual steps. Pins updated for both GROUP BY shapes (hourly + daily). Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/TimescaleContinuousAggregateTests.cs | 4 ++-- .../TimescaleSupport.cs | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs index 23f71ed7f..a5e4d428d 100644 --- a/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs +++ b/Darling/Darling.Tests/TimescaleContinuousAggregateTests.cs @@ -31,7 +31,7 @@ public void QueryStatsHourly_IsAContinuousAggregate_HourBucketed_GroupedByCompos Assert.Contains("time_bucket('1 hour', collection_time) AS bucket", sql, StringComparison.Ordinal); Assert.Contains("FROM collect.query_stats", sql, StringComparison.Ordinal); /* Grouped by the composer's query_stats dimensions, in order, so a panel points here with no remapping. */ - Assert.Contains("GROUP BY server_id, server_name, database_name, query_hash, bucket", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id, server_name, database_name, query_hash, sql_handle, bucket", sql, StringComparison.Ordinal); /* SUM/MIN/MAX on each per-interval delta (so avg composes at query time as sum/count) + a sample_count; deliberately NO materialized average (it would not re-aggregate correctly). */ @@ -116,7 +116,7 @@ public void QueryStatsDaily_IsHierarchical_SourcedFromHourlyCagg_GroupedByExplic Assert.Contains("time_bucket('1 day', bucket) AS bucket", sql, StringComparison.Ordinal); /* GROUP BY uses the explicit time_bucket EXPRESSION, never the bare `bucket` alias: a bare alias binds to the hourly source column under Postgres's input-column-wins rule and would group by hour, not day. */ - Assert.Contains("GROUP BY server_id, server_name, database_name, query_hash, time_bucket('1 day', bucket)", sql, StringComparison.Ordinal); + Assert.Contains("GROUP BY server_id, server_name, database_name, query_hash, sql_handle, time_bucket('1 day', bucket)", sql, StringComparison.Ordinal); /* Re-aggregates the hourly rollup: SUM of sums, MIN of mins, MAX of maxes, SUM of the sample_counts. */ Assert.Contains("sum(worker_time_sum) AS worker_time_sum", sql, StringComparison.Ordinal); Assert.Contains("min(worker_time_min) AS worker_time_min", sql, StringComparison.Ordinal); diff --git a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs index 0abc8f3b7..68c3b1176 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/TimescaleSupport.cs @@ -275,6 +275,7 @@ public static string AddCompressionPolicySql(string table) server_name, database_name, query_hash, + sql_handle, time_bucket('1 hour', collection_time) AS bucket, sum(delta_worker_time) AS worker_time_sum, min(delta_worker_time) AS worker_time_min, @@ -287,7 +288,7 @@ public static string AddCompressionPolicySql(string table) max(delta_execution_count) AS execution_count_max, count(*) AS sample_count FROM collect.query_stats -GROUP BY server_id, server_name, database_name, query_hash, bucket +GROUP BY server_id, server_name, database_name, query_hash, sql_handle, bucket WITH NO DATA"; /// The procedure_stats hourly continuous aggregate — 's @@ -362,6 +363,7 @@ FROM collect.query_store_stats server_name, database_name, query_hash, + sql_handle, time_bucket('1 day', bucket) AS bucket, sum(worker_time_sum) AS worker_time_sum, min(worker_time_min) AS worker_time_min, @@ -374,7 +376,7 @@ FROM collect.query_store_stats max(execution_count_max) AS execution_count_max, sum(sample_count) AS sample_count FROM collect.query_stats_hourly -GROUP BY server_id, server_name, database_name, query_hash, time_bucket('1 day', bucket) +GROUP BY server_id, server_name, database_name, query_hash, sql_handle, time_bucket('1 day', bucket) WITH NO DATA"; /// The procedure_stats DAILY continuous aggregate — 's sibling, @@ -442,6 +444,10 @@ public static async Task DropStaleContinuousAggregatesAsync(NpgsqlConnectio column. CASCADE also drops procedure_stats_daily, which the ensure sweep recreates. */ (View: "procedure_stats_hourly", StaleCheck: "SELECT (EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'procedure_stats_hourly') AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'procedure_stats_hourly' AND column_name = 'schema_name'))"), + /* query_stats_hourly / _daily gained sql_handle (object_name routing) → stale iff the view EXISTS but + has no sql_handle column. CASCADE drops query_stats_daily, which the ensure sweep recreates. */ + (View: "query_stats_hourly", + StaleCheck: "SELECT (EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'query_stats_hourly') AND NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'collect' AND table_name = 'query_stats_hourly' AND column_name = 'sql_handle'))"), }; var dropped = 0; From 391960959697c79d0a5ceb5849bc2226e6509773 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:32:19 -0400 Subject: [PATCH 032/296] Darling: route old-window object_name panels to the query_stats CAGG via module_map ComposeSourceRouter no longer gates ViaModuleJoin (object_name) to raw. object_name is now in the query_stats CAGG coverage set, served by a join to collect.module_map (the CAGG carries sql_handle after the reshape). The dimension-coverage loop stays the sole gate, so a dimension no CAGG can serve still falls to raw. ComposeCompiler emits the #1568 window-bounded procedure_stats CTE only on the raw path; a CAGG route LEFT JOINs the retained collect.module_map instead (same alias and join keys, so object_name resolves as m.object_name either way). Old windows now reach the daily rollup for object_name attribution instead of returning empty -- procedure_stats raw is dropped at 4 days, so the read-time CTE could never cover them. Router + compile pins updated. Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/ComposeSourceRouterTests.cs | 8 +++--- Darling/Darling.Tests/DarlingComposeTests.cs | 15 ++++++----- .../Compose/ComposeCompiler.cs | 23 ++++++++++++---- .../Compose/ComposeSourceRouter.cs | 26 +++++++++++-------- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/Darling/Darling.Tests/ComposeSourceRouterTests.cs b/Darling/Darling.Tests/ComposeSourceRouterTests.cs index cbedc2a13..a44901065 100644 --- a/Darling/Darling.Tests/ComposeSourceRouterTests.cs +++ b/Darling/Darling.Tests/ComposeSourceRouterTests.cs @@ -93,14 +93,16 @@ public void NoCaggTable_AlwaysRaw() } [Fact] - public void ObjectNameDimension_StaysRaw() + public void ObjectNameDimension_NowRoutes_ViaModuleMap() { - /* query_stats grouped by object_name is a #1568 module join, not a CAGG column → stays raw (truncates 4d). */ + /* query_stats object_name is a #1568 module join, but now coverable on the CAGG via module_map (the CAGG + carries sql_handle) → it routes; the compiler joins module_map for the attribution. */ var objectName = MeasureCatalog.Dimension("query_stats", "object_name")!; var plan = Plan("query_worker_us", groupBy: new[] { objectName }); Assert.True(plan.UsesModuleJoin); var route = ComposeSourceRouter.Resolve(plan, Now, Now.AddDays(-40)); - Assert.Equal(ComposeSourceTier.Raw, route.Tier); + Assert.Equal(ComposeSourceTier.Daily, route.Tier); + Assert.Equal("query_stats_daily", route.CaggRelation); } [Fact] diff --git a/Darling/Darling.Tests/DarlingComposeTests.cs b/Darling/Darling.Tests/DarlingComposeTests.cs index b0914a624..1395171a9 100644 --- a/Darling/Darling.Tests/DarlingComposeTests.cs +++ b/Darling/Darling.Tests/DarlingComposeTests.cs @@ -521,15 +521,18 @@ public void Compile_OldWindow_NonCaggTable_StaysRaw() } [Fact] - public void Compile_OldWindow_ObjectNameDimension_StaysRaw() + public void Compile_OldWindow_ObjectName_RoutesToCagg_JoinsModuleMap() { - /* object_name is a #1568 module join, not a CAGG column -> raw even at 40 days old. */ - var (compiled, _) = CompileAged( + /* object_name on query_stats now routes to the CAGG (40d -> daily) and joins the retained module_map for + attribution, instead of the window-bounded procedure_stats #1568 CTE. */ + var (compiled, error) = CompileAged( "{\"source\":\"query_stats\",\"measure\":\"query_worker_us\",\"aggregate\":\"sum\",\"topN\":10,\"groupBy\":[\"object_name\"],\"viz\":\"bar\"}", daysOld: 40, servers: new[] { "PROD-01" }); - Assert.Contains("FROM collect.query_stats AS f", compiled!.Sql, StringComparison.Ordinal); - Assert.DoesNotContain("query_stats_hourly", compiled.Sql, StringComparison.Ordinal); - Assert.DoesNotContain("query_stats_daily", compiled.Sql, StringComparison.Ordinal); + Assert.True(error is null, error); + Assert.Contains("FROM collect.query_stats_daily AS f", compiled!.Sql, StringComparison.Ordinal); + Assert.Contains("LEFT JOIN collect.module_map AS m ON m.sql_handle = f.sql_handle AND m.server_name = f.server_name", compiled.Sql, StringComparison.Ordinal); + Assert.Contains("m.object_name", compiled.Sql, StringComparison.Ordinal); /* attribution from the map */ + Assert.DoesNotContain("ROW_NUMBER()", compiled.Sql, StringComparison.Ordinal); /* not the raw #1568 CTE */ } [Fact] diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs index 3f11d8926..6653cff06 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeCompiler.cs @@ -136,8 +136,9 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, var timeColumn = route.IsCagg ? ComposeRoute.CaggTimeColumn : 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) + /* The #1568 module CTE (window-bounded from procedure_stats) — only on the RAW path. A CAGG route joins the + retained module_map instead (procedure_stats raw is dropped at 4d, so the CTE can't cover old windows). */ + if (plan.UsesModuleJoin && !route.IsCagg) { /* 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. */ @@ -188,9 +189,21 @@ public static (ComposeCompiled? Compiled, string? Error) Compile(PanelPlan plan, 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"); + /* Raw joins the window-bounded CTE (m); a CAGG route joins the retained collect.module_map directly + (its raw procedure_stats is gone at 4d, but the CAGG carries sql_handle). Same alias + join keys, so + object_name resolves as m.object_name either way. */ + if (route.IsCagg) + { + sql.Append("LEFT JOIN ").Append(PgSchemaGenerator.CollectSchema).Append(".module_map AS ").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"); + } + else + { + 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'); diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs index a32a652a8..2dca2d6ca 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs @@ -61,16 +61,20 @@ public bool Covers(string dimensionName) => /// CAGG reshape (query_store_stats regrouped to module_name/query_hash; procedure_stats carrying schema_name). /// Dimensions here are the CAGGs' GROUP BY columns (minus the universal server prefix); a panel routes only when /// its dimensions are covered (see ). query_stats' object_name is -/// deliberately absent: it is a #1568 module join (ViaModuleJoin), not a CAGG column, so those panels stay raw. +/// covered via the module_map join (the CAGG carries sql_handle; the compiler joins collect.module_map for it) — +/// it stays a ViaModuleJoin dimension so the RAW path still uses the live #1568 procedure_stats CTE. /// query_store_stats has no daily CAGG yet (deferred until a writable-QS primary supplies real data). /// public static class ComposeCaggCatalog { private static readonly Dictionary s_byTable = new(StringComparer.Ordinal) { + /* object_name is coverable via the module_map join (the query_stats CAGG carries sql_handle) — the + compiler joins collect.module_map for it on a CAGG route. It stays a ViaModuleJoin dimension, so the raw + path still uses the live #1568 procedure_stats CTE. */ ["query_stats"] = new( "query_stats", "query_stats_hourly", "query_stats_daily", - new HashSet(StringComparer.Ordinal) { "database_name", "query_hash" }), + new HashSet(StringComparer.Ordinal) { "database_name", "query_hash", "object_name" }), ["procedure_stats"] = new( "procedure_stats", "procedure_stats_hourly", "procedure_stats_daily", @@ -112,9 +116,9 @@ public static class ComposeSourceRouter /// /// The tier for given the window's oldest point () /// relative to . Falls through to — the compiler's - /// unchanged path — whenever the table has no CAGG, the panel uses the #1568 module join (object_name), any - /// grouped/filtered dimension is outside the CAGG's coverage, or the window is recent enough that raw still - /// holds it. + /// unchanged path — whenever the table has no CAGG, a grouped/filtered dimension is outside the CAGG's coverage + /// (object_name IS covered on query_stats, via the module_map join), or the window is recent enough that raw + /// still holds it. /// public static ComposeRoute Resolve(PanelPlan plan, DateTime nowUtc, DateTime windowStartUtc) { @@ -126,13 +130,13 @@ public static ComposeRoute Resolve(PanelPlan plan, DateTime nowUtc, DateTime win return ComposeRoute.Raw; } - /* object_name on query_stats is stitched from procedure_stats read-time (#1568) — not a CAGG column. */ - if (plan.UsesModuleJoin) - { - return ComposeRoute.Raw; - } + /* No blanket module-join gate: object_name (the only ViaModuleJoin dimension) is now coverable on the + query_stats CAGG via the module_map join (the CAGG carries sql_handle), and is in that CAGG's coverage + set below. The dimension-coverage loop is the sole gate — a dimension no CAGG can serve (directly or via + the map) still falls to raw. */ - /* Every grouped/filtered dimension must live in the CAGG's GROUP BY, else the rollup can't reproduce it. */ + /* Every grouped/filtered dimension must be served by the CAGG (a GROUP BY column, or object_name via the + module_map join), else the rollup can't reproduce it. */ foreach (var dimension in plan.GroupBy) { if (!cagg.Covers(dimension.Name)) From 1c5f28a7b70c2272767ad84449090a634350731c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:37:42 -0400 Subject: [PATCH 033/296] Darling: CHANGELOG for object_name CAGG routing (#1627) Adds the [Unreleased] entry for routing composed object_name panels on query_stats to the CAGG rollups via collect.module_map, and points the now-superseded "last read-routing gap" clause in the #1626 entry at this follow-up. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f9066d5b..b1fac048d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling: hourly continuous aggregates for query_stats + procedure_stats (composer query acceleration)** ([#1621]) - the two collector tables that dominate the store (~90%: query_stats ~145 GB, procedure_stats ~49 GB) now have hourly TimescaleDB continuous aggregates (`query_stats_hourly` / `procedure_stats_hourly`) that pre-materialize the exact shape every Custom Views composer panel over them does (`date_trunc('hour', collection_time)` + `SUM(delta_*)` GROUP BY a dimension), grouped by the same `MeasureCatalog` dimensions with SUM/MIN/MAX per delta column + a sample_count (so avg composes correctly at query time). Created idempotently as runtime setup in the worker's TimescaleDB block (not a versioned migration), `WITH NO DATA` with real-time aggregation on and the conservative hourly refresh policy (start_offset 3d, end_offset 1h). Measured ~49x / ~32x row reduction on the field fleet. Query acceleration only - raw rows still exist for the ~2-day hot window (analyze_*_plan needs the plan XML), and this makes no retention decision. - **Darling: three-tier retention - downsample past the raw hot window, keep the rollups** ([#1623]) - builds the retention layer on the hourly CAGGs (#1621): instead of a flat "drop raw after N days," three tiers so nothing is fully lost, only coarsened. **Raw 4 days** (`add_retention_policy` on query_stats / procedure_stats / query_store_stats - one day past the hourly CAGG's own 3-day refresh window). **Hourly CAGGs 21 days** - adds `query_store_stats_hourly` (built now, ahead of a writable-Query-Store primary; empty on a read-only replica until one exists) plus 21-day retention on all three hourly CAGGs. **Daily CAGGs kept indefinitely** - new HIERARCHICAL `query_stats_daily` / `procedure_stats_daily` sourced from the hourly CAGGs (not raw; 2.28.1 supports CAGG-on-CAGG), 1-day cadence, no retention policy. Each tier's horizon stays past the next tier's 3-day refresh start so a drop never outruns the aggregate preserving it; `add_retention_policy` is a chunk-level drop, so no off-hours window needed. All idempotent runtime setup in the worker's TimescaleDB block. `query_store_stats_daily` deferred until QS-hourly has real data to source from. Cold-start caveat on existing stores: backfill the hourly CAGGs past the raw horizon before the 4-day raw drop's first run (fresh installs are safe automatically). - **Darling Web: composed panels read the CAGG rollups for old windows instead of truncating at the raw horizon** ([#1625]) - the read side of the retention tiers (#1623): once the 4-day raw drop runs, a Custom Views panel looking back further than 4 days would show only 4 days (the older history lives in the hourly/daily CAGGs the composer never read). The compiler now routes by the window's oldest-point AGE - recent -> raw, hour-age -> the hourly CAGG (21d), day-age -> the daily CAGG (indefinite) - and rewrites the measure's aggregate to the CAGG's pre-aggregated columns (`SUM(delta_x)` -> `SUM(x_sum)`, `AVG` -> `SUM(x_sum)/SUM(sample_count)` exactly, Sum-ratios mapping both operands). Age-based (not display grain, not window span) so Ranked/Scalar panels and historical windows route correctly; the display grain clamps up to the tier's grain since a rollup can't render finer than it was materialized. Covers query_stats, procedure_stats AND Query Store (whose execution-weighted mean reconstructs exactly from #1624's reshaped weighted sums, validated on 159M live executions); only query_stats `object_name` (a #1568 module join) falls back to raw. Every existing panel is byte-for-byte unchanged inside the raw horizon. This unblocks activating retention at the field box - the raw drop coarsens old windows instead of truncating them. -- **Darling Web: Query Store panels route past the 21-day hourly horizon (query_store_stats_daily)** ([#1626]) - completes the CAGG read-routing (#1625): adds the hierarchical `query_store_stats_daily` (sourced from the reshaped `query_store_stats_hourly`, same composer dims + weighted sums, kept indefinitely), so a QS Custom Views panel looking back past the hourly CAGG's 21-day retention routes to the daily rollup instead of capping. The mapper reads the daily's identical columns unchanged. Removes the last read-routing gap; only query_stats `object_name` (a #1568 module join) still falls back to raw. +- **Darling Web: Query Store panels route past the 21-day hourly horizon (query_store_stats_daily)** ([#1626]) - completes the CAGG read-routing (#1625): adds the hierarchical `query_store_stats_daily` (sourced from the reshaped `query_store_stats_hourly`, same composer dims + weighted sums, kept indefinitely), so a QS Custom Views panel looking back past the hourly CAGG's 21-day retention routes to the daily rollup instead of capping. The mapper reads the daily's identical columns unchanged. Removes the last read-routing TIER gap; the one remaining dimensional exception, query_stats `object_name`, is closed by [#1627]. +- **Darling Web: composed `object_name` panels on query_stats route to the CAGG rollups too — the last read-routing exception, closed** ([#1627]) - #1625/#1626 left exactly one dimension on raw: a Custom Views panel grouping query_stats by `object_name`. object_name is not a query_stats column - it is stitched read-time from procedure_stats via `sql_handle` (the #1568 module join) - so those panels fell back to raw and truncated at the 4-day horizon while every other dimension reached the 21-day/indefinite rollups. Closed by carrying `sql_handle` into the query_stats hourly + daily CAGGs and adding a retained `collect.module_map` (a compact `sql_handle -> object_name/database/schema` map refreshed at startup and daily from procedure_stats, so the mapping survives the raw 4-day drop). On a CAGG route the compiler now LEFT JOINs `module_map` for the attribution (the RAW path still uses the live window-bounded #1568 procedure_stats CTE, since raw procedure_stats is retained), so `object_name` resolves as `m.object_name` either way and object_name panels reach the hourly (21d) and daily (indefinite) tiers exactly like query_hash. The CAGG reshape self-heals in place - an existing `query_stats_hourly` without `sql_handle` is dropped (CASCADE takes `query_stats_daily`) and rebuilt by the ensure sweep, so a field box upgrading over #1621/#1623 CAGGs reshapes with no manual steps. Nothing composable now falls back to raw for lack of CAGG coverage. ### Changed @@ -526,6 +527,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1624]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1624 [#1625]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1625 [#1626]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1626 +[#1627]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1627 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 From 8de81c376c0c35e51aeec0ab2831a6a6cda6d827 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:53:15 -0400 Subject: [PATCH 034/296] Darling: live-PG test for module_map accumulate-never-forget + advance-only Gated on DARLING_TEST_PG (runs in CI's darling-pg job): proves RefreshAsync against real Postgres upholds the two clauses object_name attribution on old CAGG windows rests on -- a handle keeps its mapping after its procedure_stats source ages past the 2-day read window (accumulate, never delete: the whole reason the table exists), and a stale run never regresses a fresher attribution (the ON CONFLICT advance-only guard). Mirrors the DarlingRetentionTests live-fixture harness (own-scoped fake server id/name, finally-cleanup), so the shared dev store can't race it. Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/DarlingModuleMapTests.cs | 116 +++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/Darling/Darling.Tests/DarlingModuleMapTests.cs b/Darling/Darling.Tests/DarlingModuleMapTests.cs index ca0da97b3..d37f71502 100644 --- a/Darling/Darling.Tests/DarlingModuleMapTests.cs +++ b/Darling/Darling.Tests/DarlingModuleMapTests.cs @@ -7,17 +7,35 @@ */ using System; +using System.Threading; +using System.Threading.Tasks; +using Npgsql; using PerformanceMonitor.Darling.Storage; using Xunit; namespace Darling.Tests; /// -/// Pins the retained sql_handle→module map SQL (the join source for object_name on OLD query_stats CAGG windows). -/// Ungated — pure SQL-shape assertions. +/// Pins the retained sql_handle→module map (the join source for object_name on OLD query_stats CAGG windows): the +/// pure SQL-shape assertions, plus — gated on DARLING_TEST_PG — the RefreshAsync behaviour against a dev Postgres. +/// The two load-bearing clauses the map's correctness rests on are ACCUMULATE-never-forget (a handle keeps its +/// attribution after its procedure_stats source ages past the 2-day read window — the whole reason the table exists, +/// since an old-window CAGG panel must attribute a handle whose raw procedure_stats is long dropped) and ADVANCE-only +/// (a stale run never regresses a fresher attribution). /// +/* Live-fixture tests share one Postgres store; the collection serializes them so cross-test row churn cannot race + another class's assertions. */ +[Collection("live-postgres")] public sealed class DarlingModuleMapTests { + /// Distinctive fake id/name — own-scoped cleanup never touches a real monitored server's rows. + private const int TestServerId = -616162; + private const string TestServerName = "modmap-e2e"; + + /// Distinct per inserted row (procedure_stats keys on collection_id + object identity), based high to + /// avoid colliding with a shared dev store's real collections. + private static long s_collectionId = 5_000_000L; + [Fact] public void CreateTable_HasCompositePkAndAttributionColumns() { @@ -49,4 +67,98 @@ public void Refresh_UpsertsLatestPerHandle_FromRecentProcedureStats_Accumulating Assert.Contains("ON CONFLICT (server_name, sql_handle) DO UPDATE SET", sql, StringComparison.Ordinal); Assert.Contains("WHERE module_map.last_seen IS NULL OR EXCLUDED.last_seen >= module_map.last_seen", sql, StringComparison.Ordinal); } + + [Fact] + public async Task EndToEnd_Refresh_AccumulatesAndNeverRegresses_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 module_map test."); + + var ct = TestContext.Current.CancellationToken; + using var connection = new NpgsqlConnection(connectionString); + await connection.OpenAsync(ct); + await PgMigrations.MigrateAsync(connection, ct); + + /* module_map is RUNTIME setup, not a migration — EnsureTableAsync creates it (and is itself under test). */ + Assert.True(await DarlingModuleMap.EnsureTableAsync(connection, null, ct)); + await DeleteTestRowsAsync(connection, ct); + try + { + var utcNow = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); + + /* Round 1: two procs collected recently → both attributed. */ + await InsertProcAsync(connection, utcNow.AddHours(-2), "0xMODMAP_A", "neword", ct); + await InsertProcAsync(connection, utcNow.AddHours(-2), "0xMODMAP_B", "payment", ct); + await DarlingModuleMap.RefreshAsync(connection, null, ct); + Assert.Equal("neword", await ObjectNameForAsync(connection, "0xMODMAP_A", ct)); + Assert.Equal("payment", await ObjectNameForAsync(connection, "0xMODMAP_B", ct)); + + /* Their procedure_stats source rows age out (raw is dropped at 4 days) and a THIRD proc arrives. + ACCUMULATE: A and B keep their attribution (the refresh never deletes), C is added — an old-window + query_stats CAGG panel must still attribute a handle whose raw procedure_stats is long gone. */ + await DeleteProcRowsAsync(connection, ct); + await InsertProcAsync(connection, utcNow.AddHours(-1), "0xMODMAP_C", "delivery", ct); + await DarlingModuleMap.RefreshAsync(connection, null, ct); + Assert.Equal("neword", await ObjectNameForAsync(connection, "0xMODMAP_A", ct)); + Assert.Equal("payment", await ObjectNameForAsync(connection, "0xMODMAP_B", ct)); + Assert.Equal("delivery", await ObjectNameForAsync(connection, "0xMODMAP_C", ct)); + + /* Advance-only, part 1: a proc dropped + recreated re-maps its handle when a NEWER row arrives. */ + await DeleteProcRowsAsync(connection, ct); + await InsertProcAsync(connection, utcNow, "0xMODMAP_A", "neword_v2", ct); + await DarlingModuleMap.RefreshAsync(connection, null, ct); + Assert.Equal("neword_v2", await ObjectNameForAsync(connection, "0xMODMAP_A", ct)); + + /* Advance-only, part 2: a STALE run (an OLDER procedure_stats row, still inside the 2-day window) must + NOT regress the fresher attribution — the ON CONFLICT WHERE guard. */ + await DeleteProcRowsAsync(connection, ct); + await InsertProcAsync(connection, utcNow.AddDays(-1), "0xMODMAP_A", "neword_stale", ct); + await DarlingModuleMap.RefreshAsync(connection, null, ct); + Assert.Equal("neword_v2", await ObjectNameForAsync(connection, "0xMODMAP_A", ct)); + } + finally + { + await DeleteTestRowsAsync(connection, ct); + } + } + + private static async Task InsertProcAsync(NpgsqlConnection c, DateTime t, string sqlHandle, string objectName, CancellationToken ct) => + await Exec(c, @"INSERT INTO collect.procedure_stats (collection_id, collection_time, server_id, server_name, database_name, schema_name, object_name, sql_handle) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8)", ct, + Interlocked.Increment(ref s_collectionId), DateTime.SpecifyKind(t, DateTimeKind.Unspecified), + TestServerId, TestServerName, "TestDb", "dbo", objectName, sqlHandle); + + private static async Task ObjectNameForAsync(NpgsqlConnection c, string sqlHandle, CancellationToken ct) + { + using var cmd = new NpgsqlCommand("SELECT object_name FROM collect.module_map WHERE server_name = $1 AND sql_handle = $2", c); + cmd.Parameters.AddWithValue(TestServerName); + cmd.Parameters.AddWithValue(sqlHandle); + return await cmd.ExecuteScalarAsync(ct) as string; + } + + private static async Task DeleteProcRowsAsync(NpgsqlConnection c, CancellationToken ct) + { + using var cmd = new NpgsqlCommand($"DELETE FROM collect.procedure_stats WHERE server_id = {TestServerId}", c); + await cmd.ExecuteNonQueryAsync(ct); + } + + private static async Task DeleteTestRowsAsync(NpgsqlConnection c, CancellationToken ct) + { + using var cmd = new NpgsqlCommand( + $"DELETE FROM collect.procedure_stats WHERE server_id = {TestServerId}; " + + $"DELETE FROM collect.module_map WHERE server_name = '{TestServerName}'", c); + await cmd.ExecuteNonQueryAsync(ct); + } + + private static async Task Exec(NpgsqlConnection connection, string sql, CancellationToken ct, params object?[] values) + { + using var cmd = new NpgsqlCommand(sql, connection); + foreach (var v in values) + { + cmd.Parameters.AddWithValue(v ?? (object)DBNull.Value); + } + + await cmd.ExecuteNonQueryAsync(ct); + } } From 28bea9dd7ba0eccb579a30e757e920f143246360 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 23 Jul 2026 18:14:20 -0400 Subject: [PATCH 035/296] =?UTF-8?q?Darling:=20fix=20stale=20ComposeCaggCat?= =?UTF-8?q?alog=20doc=20=E2=80=94=20QS=20now=20has=20a=20daily=20CAGG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class-level doc still said "query_store_stats has no daily CAGG yet (deferred until a writable-QS primary supplies real data)", but #1626 added query_store_stats_daily (present in the dictionary right below the comment). Update it to state all three rolled tables now tier raw -> hourly -> daily. The fix was written during #1627 but missed its commit. Comment-only, no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../Compose/ComposeSourceRouter.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs index 30c8bc2fe..23ce36321 100644 --- a/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs +++ b/Darling/PerformanceMonitor.Darling.Service/Compose/ComposeSourceRouter.cs @@ -63,7 +63,8 @@ public bool Covers(string dimensionName) => /// its dimensions are covered (see ). query_stats' object_name is /// covered via the module_map join (the CAGG carries sql_handle; the compiler joins collect.module_map for it) — /// it stays a ViaModuleJoin dimension so the RAW path still uses the live #1568 procedure_stats CTE. -/// query_store_stats has no daily CAGG yet (deferred until a writable-QS primary supplies real data). +/// All three rolled tables (query_stats / procedure_stats / query_store_stats) now carry both an hourly and a +/// daily CAGG, so every one tiers raw → hourly → daily. /// public static class ComposeCaggCatalog { From dba8f1da3159197bb49e31be0f852b21ab5ab79c Mon Sep 17 00:00:00 2001 From: Arun Gopinath Date: Fri, 24 Jul 2026 07:25:10 +0000 Subject: [PATCH 036/296] fix: fall back to base trace path when no rollover suffix (Linux) - SQL Server on Linux names the initial default-trace file without a rollover suffix (log.trc, not log_1.trc); the unconditional strip found no underscore, took the full path unchanged, and doubled the extension (log.trc.trc), so fn_trace_gettable errored on the nonexistent file (Msg 19049) and no default trace events were ever collected - guard the strip with a CASE that falls back to the base path when CHARINDEX finds no underscore, applied to the shared collector and the install SQL, covered with a new test - verified against a live SQL Server 2025 Linux container: broken expression raises Msg 19049 on the missing file, fixed expression returns rows --- ...faultTraceEventsCollectorDefinitionTests.cs | 13 +++++++++++++ .../DefaultTraceEventsCollector.cs | 18 +++++++++++++----- install/29_collect_default_trace.sql | 15 +++++++++++++-- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs b/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs index 7c432a798..e83c587af 100644 --- a/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs +++ b/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs @@ -78,6 +78,19 @@ public void BuildQuery_ReadsDefaultTraceViaFnTraceGettable_ServerSide() Assert.Contains("OPTION(RECOMPILE)", text, StringComparison.Ordinal); } + [Fact] + public void BuildQuery_RolloverPathStrip_FallsBackToBasePath_WhenNoUnderscore() + { + /* SQL Server on Linux names the initial trace file without a rollover suffix (log.trc, not + log_1.trc). An unconditional strip finds no underscore, so LEFT takes the full path and + the re-appended extension doubles it (log.trc.trc), and fn_trace_gettable errors on the + nonexistent file. The CASE must fall back to the base t.path when CHARINDEX finds no underscore. */ + var text = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.Contains("WHEN CHARINDEX(N'_', REVERSE(t.path)) > 0", text, StringComparison.Ordinal); + Assert.Contains("ELSE t.path", text, StringComparison.Ordinal); + } + [Fact] public void BuildQuery_CapturesTheCuratedEventSet() { diff --git a/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs b/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs index d12a1212c..8b9fa946c 100644 --- a/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs +++ b/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs @@ -114,9 +114,14 @@ public sealed class Row /* 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. */ + (strip _NN + re-append the extension) reads every retained file. The strip is guarded by a CHARINDEX + check: SQL Server on Linux names the initial trace file without a rollover suffix (log.trc, not + log_1.trc), so an unconditional strip finds no underscore, LEFT takes the full path unchanged, and + appending the extension back doubles it (log.trc.trc) - fn_trace_gettable then errors on the + nonexistent file (Msg 19049) and no events are collected. Falling back to t.path when CHARINDEX + returns 0 keeps the Windows behavior (which always has the _N suffix) unchanged. 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; @@ -145,8 +150,11 @@ tiny steady-state windows. */ 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), + CASE + WHEN CHARINDEX(N'_', REVERSE(t.path)) > 0 + THEN LEFT(t.path, LEN(t.path) - CHARINDEX(N'_', REVERSE(t.path))) + RIGHT(t.path, 4) + ELSE t.path + END, t.max_files ) AS ft JOIN sys.trace_events AS te diff --git a/install/29_collect_default_trace.sql b/install/29_collect_default_trace.sql index 1ab8a9af0..c754e8c66 100644 --- a/install/29_collect_default_trace.sql +++ b/install/29_collect_default_trace.sql @@ -358,8 +358,19 @@ BEGIN FROM sys.traces AS st CROSS APPLY sys.fn_trace_gettable ( - LEFT(st.path, LEN(st.path) - CHARINDEX('_', REVERSE(st.path))) + - RIGHT(st.path, 4), + /* + SQL Server on Linux names the initial trace file without a rollover suffix + (log.trc, not log_1.trc). An unconditional strip finds no underscore, LEFT + takes the full path unchanged, and appending the extension back doubles it + (log.trc.trc) - fn_trace_gettable then errors on the nonexistent file + (Msg 19049) and no events are collected. Fall back to st.path when + CHARINDEX returns 0. + */ + CASE + WHEN CHARINDEX(N'_', REVERSE(st.path)) > 0 + THEN LEFT(st.path, LEN(st.path) - CHARINDEX(N'_', REVERSE(st.path))) + RIGHT(st.path, 4) + ELSE st.path + END, st.max_files ) AS ft INNER JOIN sys.trace_events AS te From f6b6078ab285b869cd94530edc67fc44242295e9 Mon Sep 17 00:00:00 2001 From: Arun Gopinath Date: Fri, 24 Jul 2026 04:43:33 +0000 Subject: [PATCH 037/296] fix: only suppress other-process cpu when system_idle is 0 - the previous guard nulled other-process cpu for any linux host, over-suppressing a derivable value on version combos where system_idle is non-zero - narrow the check to @is_linux = 1 AND system_idle = 0, matching the actual condition where the figure is not derivable (install/18_collect_cpu_utilization_stats.sql) - applied consistently to the collector, install sql, table comment, dashboard noc health query, and covered with a new test --- .../CpuUtilizationCollectorDefinitionTests.cs | 13 ++++++++++++ .../CpuUtilizationCollector.cs | 21 +++++++++++-------- .../Services/DatabaseService.NocHealth.cs | 21 +++++++++++++------ install/02_create_tables.sql | 9 ++++---- install/18_collect_cpu_utilization_stats.sql | 18 +++++++++------- 5 files changed, 56 insertions(+), 26 deletions(-) diff --git a/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs index 0d8a26209..6da3591fc 100644 --- a/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs +++ b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs @@ -36,6 +36,19 @@ public void BuildQuery_RingBuffer_ForNonAzure_EvenWithWatermark() Assert.Empty(plan.Parameters); } + [Fact] + public void BuildQuery_RingBuffer_SuppressesOtherCpu_OnlyWhenSystemIdleIsZero() + { + /* Some Linux/SQL Server version combos report a non-zero SystemIdle, so + unconditionally nulling other-process CPU on @is_linux = 1 over-suppressed a + derivable value. The guard must require BOTH @is_linux = 1 AND system_idle = 0. */ + var plan = CpuUtilizationCollector.Instance.BuildQuery( + CollectorTestContext.Make(s_deltas, isAzureSqlDb: false)); + + Assert.Contains("WHEN @is_linux = 1 AND x.system_idle = 0", plan.Text, StringComparison.Ordinal); + Assert.DoesNotContain("WHEN @is_linux = 1\n", plan.Text, StringComparison.Ordinal); + } + [Fact] public void BuildQuery_Azure_NoWatermark_UsesTop60() { diff --git a/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs index 94c25afa8..cdc8c762d 100644 --- a/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs +++ b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs @@ -17,8 +17,8 @@ namespace PerformanceMonitor.Collectors; /// /// 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); +/// that are the parity contract: Linux stores NULL other-process CPU when SystemIdle reports +/// 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). /// @@ -70,11 +70,14 @@ @start_time datetime2(7), @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). */ +/* Detect SQL Server on Linux. SystemIdle reports 0 in the SCHEDULER_MONITOR + ring buffer on some Linux/SQL Server version combos, so 100 - SystemIdle - ProcessUtilization + fabricates a host figure that pins total CPU at 100% (Issue #1048). Prior to SQL Server + 2025 CU1 no DMV exposes true host CPU when that happens, so other_process is stored as NULL + there. sys.dm_os_linux_cpu_stats (2025 CU1+) exposes real host CPU jiffies but is a cumulative + counter requiring a two-sample delta, not a point-in-time snapshot like SCHEDULER_MONITOR, so + it isn't used here. 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;', @@ -85,7 +88,7 @@ SELECT TOP (60) sqlserver_cpu_utilization = x.process_utilization, other_process_cpu_utilization = CASE - WHEN @is_linux = 1 + WHEN @is_linux = 1 AND x.system_idle = 0 THEN NULL WHEN (100 - x.system_idle - x.process_utilization) < 0 THEN 0 @@ -161,7 +164,7 @@ public override async ValueTask> ReadAsync(DbDataReader reader, Collec rows.Add(new Row( sampleTime, reader.IsDBNull(1) ? 0 : reader.GetInt32(1), - /* NULL = host/other CPU not derivable (SQL on Linux, Issue #1048) */ + /* NULL = host/other CPU not derivable (SystemIdle reported 0, Issue #1048) */ reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2))); } diff --git a/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs b/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs index ac8cb1f3d..152418671 100644 --- a/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs @@ -475,12 +475,16 @@ end_time DESC DECLARE @is_linux bit = 0; - /* SystemIdle is always 0 in the SCHEDULER_MONITOR ring buffer on SQL Server - on Linux, so 100 - SystemIdle - ProcessUtilization fabricates a host figure - that pins total CPU at 100% forever (Issue #1048). No DMV exposes true host - CPU on Linux, so report other/host CPU as NULL there and let the alert engine - fall back to the SQL-only figure. sys.dm_os_host_info is 2017+; referenced via - sp_executesql so SQL 2016 (no Linux build) never binds it (@is_linux stays 0). */ + /* SystemIdle reports 0 in the SCHEDULER_MONITOR ring buffer on some Linux/SQL + Server version combos, so 100 - SystemIdle - ProcessUtilization fabricates a + host figure that pins total CPU at 100% forever (Issue #1048). Prior to SQL + Server 2025 CU1 no DMV exposes true host CPU when that happens, so report + other/host CPU as NULL and let the alert engine fall back to the SQL-only + figure. sys.dm_os_linux_cpu_stats (2025 CU1+) exposes real host CPU jiffies + but is a cumulative counter requiring a two-sample delta, not a point-in-time + snapshot like SCHEDULER_MONITOR, so it isn't used here. sys.dm_os_host_info is + 2017+; referenced via sp_executesql so SQL 2016 (no Linux build) never binds it + (@is_linux stays 0). */ IF OBJECT_ID(N'sys.dm_os_host_info', N'V') IS NOT NULL BEGIN EXEC sys.sp_executesql @@ -499,6 +503,11 @@ SELECT TOP (1) other_cpu_percent = CASE WHEN @is_linux = 1 + AND x.rb.value + ( + '(./Record/SchedulerMonitorEvent/SystemHealth/SystemIdle)[1]', + 'integer' + ) = 0 THEN NULL ELSE 100 - x.rb.value diff --git a/install/02_create_tables.sql b/install/02_create_tables.sql index 35d2e8b3f..bbf048870 100644 --- a/install/02_create_tables.sql +++ b/install/02_create_tables.sql @@ -942,10 +942,11 @@ BEGIN sample_time datetime2(7) NOT NULL, sqlserver_cpu_utilization integer NOT NULL, /* - Nullable: NULL means host/other-process CPU is not derivable. On SQL Server - on Linux the SCHEDULER_MONITOR ring buffer reports SystemIdle = 0, so the - collector cannot compute a real host figure and stores NULL here rather than - a false 100% (Issue #1048). NULL propagates to total_cpu_utilization below. + Nullable: NULL means host/other-process CPU is not derivable. On some + Linux/SQL Server version combos the SCHEDULER_MONITOR ring buffer reports + SystemIdle = 0, so the collector cannot compute a real host figure and + stores NULL here rather than a false 100% (Issue #1048). NULL propagates + to total_cpu_utilization below. */ other_process_cpu_utilization integer NULL, total_cpu_utilization AS (sqlserver_cpu_utilization + other_process_cpu_utilization) PERSISTED, diff --git a/install/18_collect_cpu_utilization_stats.sql b/install/18_collect_cpu_utilization_stats.sql index 1c3081b62..4c0556d9e 100644 --- a/install/18_collect_cpu_utilization_stats.sql +++ b/install/18_collect_cpu_utilization_stats.sql @@ -56,11 +56,15 @@ BEGIN @error_message nvarchar(4000); /* - Detect SQL Server on Linux. On Linux the SCHEDULER_MONITOR ring buffer - reports SystemIdle = 0, so 100 - SystemIdle - ProcessUtilization fabricates - a host figure that pins total CPU at 100% forever (Issue #1048). There is no - DMV that exposes true host CPU on Linux, so on Linux we store NULL for - other_process_cpu_utilization instead of a false value. + Detect SQL Server on Linux. On some Linux/SQL Server version combos the + SCHEDULER_MONITOR ring buffer reports SystemIdle = 0, so 100 - SystemIdle - + ProcessUtilization fabricates a host figure that pins total CPU at 100% + forever (Issue #1048). Prior to SQL Server 2025 CU1 there is no DMV that + exposes true host CPU when that happens, so we store NULL for + other_process_cpu_utilization instead of a false value. sys.dm_os_linux_cpu_stats + (2025 CU1+) exposes real host CPU jiffies but is a cumulative counter + requiring a two-sample delta, not a point-in-time snapshot like + SCHEDULER_MONITOR, so it isn't used here. sys.dm_os_host_info exists only on SQL Server 2017+. It is referenced through sp_executesql so SQL Server 2016 (which has no Linux build) never binds it and @@ -154,8 +158,8 @@ BEGIN x.process_utilization, other_process_cpu_utilization = CASE - WHEN @is_linux = 1 - THEN NULL /*SystemIdle is always 0 on Linux; host CPU is not derivable (Issue #1048)*/ + WHEN @is_linux = 1 AND x.system_idle = 0 + THEN NULL /*SystemIdle reports 0 on some Linux/SQL Server version combos; host CPU is not derivable when that happens (Issue #1048)*/ WHEN (100 - x.system_idle - x.process_utilization) < 0 THEN 0 ELSE 100 - x.system_idle - x.process_utilization From 6517c90cba2987242c5116380e4a9befbc9e99e4 Mon Sep 17 00:00:00 2001 From: Arun Gopinath Date: Fri, 24 Jul 2026 10:40:45 +0000 Subject: [PATCH 038/296] fix report.trace_flag_changes bit conversion error on toggle - previous_status/status are bit columns compared against nvarchar literals N'OFF'/N'ON'; SQL Server implicitly converts the literal to bit, and 'OFF' fails that conversion (Msg 245) - view only breaks once a trace flag has a second history row (the toggle), so the initial-insert case never triggered it - compare against 0/1 instead, matching the underlying bit type --- install/47_create_reporting_views.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install/47_create_reporting_views.sql b/install/47_create_reporting_views.sql index 11fb55821..c9a6023f8 100644 --- a/install/47_create_reporting_views.sql +++ b/install/47_create_reporting_views.sql @@ -1133,9 +1133,9 @@ SELECT END, change_description = CASE - WHEN rc.previous_status = N'OFF' AND rc.status = N'ON' + WHEN rc.previous_status = 0 AND rc.status = 1 THEN N'Trace flag ' + CONVERT(nvarchar(10), rc.trace_flag) + N' ENABLED' - WHEN rc.previous_status = N'ON' AND rc.status = N'OFF' + WHEN rc.previous_status = 1 AND rc.status = 0 THEN N'Trace flag ' + CONVERT(nvarchar(10), rc.trace_flag) + N' DISABLED' ELSE N'Status unchanged' END, From 49c994b0f38209e40f064fe0b457e04bc7f46c22 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 08:48:26 -0400 Subject: [PATCH 039/296] Fix Azure SQL DB collection when only a DATABASE-level firewall rule allows the client (#1631) Reported against 3.2.0 on an Elastic Pool database: every database-scoped collector failed with 40615 ("Cannot open server ... Client with IP address ... is not allowed to access the server") and the long-query XE session could not enumerate databases, even though the database itself was fully reachable from SSMS. This is a regression of the #857 single-database fallback, introduced by #1506. That fix correctly stopped reading 40615 as a statement about a login's RIGHTS to read master -- it is a reachability error, and misreading it had permanently wedged a user whose public IP rotated daily. But it also dropped 40615 from the single-database FALLBACK path, on the stated premise that "falling back to a user database is futile because the same rule blocks that connection too." That premise is wrong. Azure SQL Database evaluates DATABASE-level IP firewall rules BEFORE server-level ones: a client whose IP matches a database-level rule (sp_set_database_firewall_rule) is granted a connection to that database with no server-level rule permitting it, while master -- where server-level rules live -- still requires one. "Blocked at the server, allowed at the database" is a real, supported configuration that Microsoft explicitly recommends, and it is exactly the #857 case. The two questions are now separate in the shared SqlErrorClassification: IsMasterAccessDenied -- does this login lack rights to master? (unchanged; 40615 still correctly excluded, so every #1506 invariant holds) ShouldFallBackToSingleDatabase -- should database-scoped collection degrade? (rights set + 40615) Both SKUs' Azure database-enumeration catch sites ask the second question, which is what they actually needed. Acting on it is safe because #1506's other fix survives untouched: the verdict is a revocable throttle, not a latch -- it expires after 15 minutes, is discarded when a server returns from an outage, and is skipped entirely when there is no target database to fall back to. A client genuinely firewalled out with no database-level rule just fails its fallback attempt with the same 40615, logs an ordinary collector error, and recovers on the next successful connect. Also removes an orphaned doc block in DarlingCollectorRunner left behind when #1506 deleted Darling's duplicated error list. Pinned from both suites, independently: 40615 is still not a rights denial AND does trigger the fallback; the rights set is asserted to be a strict subset of the fallback set (which also guards the static-initializer order the fallback set depends on); and disjointness is now asserted against the broader fallback set. Lite.Tests 1441 passed / 0 failed. Darling.Tests 2953 passed / 0 failed / 147 gated-live skipped. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 + .../Darling.Tests/AzureMasterFallbackTests.cs | 72 +++++++++++++++--- .../DarlingCollectorRunner.cs | 22 +++--- Lite.Tests/AzureMasterFallbackTests.cs | 73 ++++++++++++++++--- Lite/Services/RemoteCollectorService.cs | 17 +++-- .../SqlErrorClassification.cs | 63 ++++++++++++++-- 6 files changed, 207 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1fac048d..cf16beb68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Lite + Darling: Azure SQL DB collection works again when the client is firewalled out at the logical server but allowed at the DATABASE level** ([#1634]) - reported against 3.2.0 by a user monitoring an Elastic Pool database ([#1631]): every database-scoped collector failed with `40615 - Cannot open server ... Client with IP address ... is not allowed to access the server`, and the long-query XE session could not enumerate databases, even though the database itself was fully reachable from SSMS. **This is a regression of the [#857] single-database fallback, introduced by [#1506].** That fix correctly stopped reading 40615 ("client IP not allowed at the logical server") as a statement about a login's RIGHTS to read `master` - it is a reachability error, and misreading it had permanently wedged a different user whose public IP rotated daily. But it also dropped 40615 from the single-database FALLBACK path, on the stated premise that "falling back to a user database is futile because the same rule blocks that connection too." That premise is wrong: Azure SQL Database evaluates **database-level** IP firewall rules BEFORE server-level ones, and a client whose IP matches a database-level rule (`sp_set_database_firewall_rule`) is granted a connection to that database with **no** server-level rule permitting it - while `master`, where server-level rules live, still requires one. "Blocked at the server, allowed at the database" is therefore a real, supported configuration that Microsoft explicitly recommends ("use database-level IP firewall rules whenever possible"), and it is precisely the [#857] case. The two questions - *"does this login lack rights to master?"* and *"should database-scoped collection degrade to single-database?"* - are now separated in the shared `SqlErrorClassification`: the rights list is unchanged (40615 still correctly excluded, so every [#1506] invariant holds), and a new fallback list is that set plus 40615, which is what both SKUs' Azure database-enumeration catch sites actually ask. Acting on it is safe because [#1506]'s other fix survives untouched: the resulting verdict is a revocable throttle, not a latch - it expires after 15 minutes, is discarded when a server returns from an outage, and is skipped entirely when there is no target database to fall back to. A client genuinely firewalled out with no database-level rule therefore just fails its fallback attempt with the same 40615, logs an ordinary collector error, and recovers on the next successful connect. Pinned from both suites, independently: 40615 is still not a rights denial, AND it does trigger the fallback; the rights set remains a strict subset of the fallback set; and the disjointness invariant is now asserted against the broader fallback set (40613 stays transient-and-retryable, never a reason to degrade). Lite + Darling. - **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. - **Darling Web: a custom view no longer resets the picked time range / server / filters every 60 seconds** ([#1619]) - the background refresh that keeps the dashboard live rebuilt each view from its declared default, snapping any range/server/filter change back within a minute. A per-view scope memory now survives the re-render, so a picked scope sticks until you change it or hard-reload. - **Darling: the Custom Views composer and analyze_*_plan no longer time out on large stores** ([#1620]) - the columns the composer filters/groups by (`procedure_stats.object_name`, `query_stats.query_hash`, `query_store_stats.query_hash`) and the single-row `analyze_*_plan` lookups (`sql_handle` / `query_id`+`plan_id`) had no supporting index, so on a large store they fell to a Seq Scan and hit the 15 s statement_timeout. The service now applies (idempotently, at startup, NOT a versioned migration) six indexes - three COVERING (the aggregate columns the composer SUM/AVGs are `INCLUDE`d, for an Index Only Scan) plus three lookup indexes - and a per-table `autovacuum_vacuum_insert_scale_factor = 0.02` override so the visibility map stays current on the pure-insert hypertable chunks (the default 0.2 left the day's hot chunk stale before the daily TimescaleDB rollover, degrading the Index Only Scan back to heap fetches). EXPLAIN-verified on a 137 GB field store: two panels that timed out at 15 s now run in 139 ms / 514 ms with 0 heap fetches. Results-invariant perf tuning, so it deliberately does not bump the schema version or gate the viewer. @@ -528,6 +529,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1625]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1625 [#1626]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1626 [#1627]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1627 +[#1631]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1631 +[#1634]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1634 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 diff --git a/Darling/Darling.Tests/AzureMasterFallbackTests.cs b/Darling/Darling.Tests/AzureMasterFallbackTests.cs index 7822797cb..b34fb40eb 100644 --- a/Darling/Darling.Tests/AzureMasterFallbackTests.cs +++ b/Darling/Darling.Tests/AzureMasterFallbackTests.cs @@ -24,11 +24,16 @@ namespace Darling.Tests; /// Both SKUs now classify through the shared , so the list cannot /// be edited in one and not the other. These tests exist so the invariant is enforced from Darling's /// suite too — Lite's copy of it protects Lite, and protecting only Lite is how this happened. +/// +/// Also #1631, the over-correction: #1506 removed 40615 from the rights list (right) AND from the +/// single-database fallback path (wrong). Azure evaluates DATABASE-level firewall rules first, so a +/// user database can be fully reachable while master is firewalled off — the #857 case. "Not a rights +/// denial" and "no fallback" are separate questions, pinned independently below. /// public class AzureMasterFallbackTests { /// - /// The bug. 40615 is a firewall rejection at the logical server and 40613 is "retry later" — + /// The #1506 half. 40615 is a firewall rejection at the logical server and 40613 is "retry later" — /// neither says anything about whether this login may read master. /// [Theory] @@ -36,7 +41,7 @@ public class AzureMasterFallbackTests [InlineData(40613)] public void Reachability_Errors_Are_Not_Master_Access_Denied(int errorNumber) { - Assert.False(DarlingCollectorRunner.IsMasterAccessDeniedError(errorNumber)); + Assert.False(SqlErrorClassification.IsMasterAccessDenied(errorNumber)); } /// @@ -51,24 +56,73 @@ public void Reachability_Errors_Are_Not_Master_Access_Denied(int errorNumber) [InlineData(18456)] public void Permission_Errors_Are_Still_Master_Access_Denied(int errorNumber) { - Assert.True(DarlingCollectorRunner.IsMasterAccessDeniedError(errorNumber)); + Assert.True(SqlErrorClassification.IsMasterAccessDenied(errorNumber)); + } + + /// + /// #1631. A 40615 at the logical server is compatible with a database-level firewall rule that + /// leaves the user database reachable, so the single-database fallback must still fire — even + /// though 40615 is (correctly) not a rights denial. + /// + [Fact] + public void Firewall_Rejection_At_The_Server_Still_Falls_Back_To_The_Database() + { + Assert.True(DarlingCollectorRunner.ShouldFallBackToSingleDatabaseError(40615)); + } + + /// + /// Every rights denial still implies the fallback — the fallback set is a superset, never a + /// replacement, of the #857 list. 40613 stays out: transient, so retry rather than degrade. + /// + [Theory] + [InlineData(229, true)] + [InlineData(230, true)] + [InlineData(916, true)] + [InlineData(4060, true)] + [InlineData(18456, true)] + [InlineData(40613, false)] + public void Fallback_Set_Is_A_Superset_Of_The_Rights_Set(int errorNumber, bool expected) + { + Assert.Equal(expected, DarlingCollectorRunner.ShouldFallBackToSingleDatabaseError(errorNumber)); + } + + /// + /// The structural invariant behind the two lists, asserted on the SETS rather than on examples: a + /// rights denial must always imply the fallback, so the rights set can only ever be a subset. + /// + /// This also guards a real footgun — the fallback set is constructed FROM the rights set, so it + /// depends on static-initializer order. Moving either field above the other would leave the fallback + /// set holding only 40615 and silently disable the #857 fallback for every permission error. + /// + [Fact] + public void Every_Rights_Denial_Is_Also_A_Fallback_Trigger() + { + var missing = SqlErrorClassification.MasterAccessDeniedErrorNumbers + .Where(e => !SqlErrorClassification.ShouldFallBackToSingleDatabase(e)) + .ToList(); + + Assert.True( + missing.Count == 0, + $"Error(s) {string.Join(", ", missing)} deny access to master but do not trigger the " + + "single-database fallback — #857 collection would fail outright for them."); } /// - /// No error may mean both "retry, this will pass" and "give up, this is permanent". 40613 meant - /// both, which is the whole bug. + /// No error may mean both "retry, this will pass" and "give up, degrade to single-database". 40613 + /// meant both, which is the whole #1506 bug. Asserted against the FALLBACK set — the broader of the + /// two give-up sets, so this covers the rights set implicitly. /// [Fact] public void No_Error_Is_Both_Transient_And_Permanently_Fatal() { var contradictory = SqlErrorClassification.TransientErrorNumbers - .Where(SqlErrorClassification.IsMasterAccessDenied) + .Where(SqlErrorClassification.ShouldFallBackToSingleDatabase) .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."); + "reason to degrade to single-database collection. Pick one — see #1506."); } /// @@ -88,8 +142,8 @@ public void Darling_Classifies_Identically_To_The_Shared_Source_Of_Truth() foreach (var errorNumber in everyErrorWeReasonAbout) { Assert.Equal( - SqlErrorClassification.IsMasterAccessDenied(errorNumber), - DarlingCollectorRunner.IsMasterAccessDeniedError(errorNumber)); + SqlErrorClassification.ShouldFallBackToSingleDatabase(errorNumber), + DarlingCollectorRunner.ShouldFallBackToSingleDatabaseError(errorNumber)); } } } diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs index f7d471471..19b6a3f83 100644 --- a/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs +++ b/Darling/PerformanceMonitor.Darling.Service/DarlingCollectorRunner.cs @@ -683,7 +683,7 @@ of failure without ever attempting to recover. */ _azureMasterInaccessibleSince.TryRemove(server.ServerId, out _); return databases; } - catch (SqlException ex) when (IsMasterAccessDeniedError(ex.Number)) + catch (SqlException ex) when (ShouldFallBackToSingleDatabaseError(ex.Number)) { _azureMasterInaccessibleSince[server.ServerId] = DateTime.UtcNow; @@ -770,17 +770,17 @@ private static List SingleDbOrEmpty(string? 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 master enumeration failed in a way that means database-scoped collectors should fall back + /// to the connection's own catalog (#857). Deliberately broader than "this login cannot read master": + /// a 40615 firewall rejection at the logical server says nothing about the login's rights, but the + /// fallback still works, because Azure evaluates DATABASE-level firewall rules first and a user + /// database can be reachable while master is not (#1631). The list — and the reason a reachability + /// error must never be read as a rights verdict (#1506) — is owned by + /// , shared with Lite so the two cannot drift. This bug reached + /// Darling because the list was duplicated here. /// - /// - /// 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); + internal static bool ShouldFallBackToSingleDatabaseError(int errorNumber) => + SqlErrorClassification.ShouldFallBackToSingleDatabase(errorNumber); private static SqlCommand CreateCollectorCommand(CollectorQuery plan, SqlConnection connection, int commandTimeoutSeconds) { diff --git a/Lite.Tests/AzureMasterFallbackTests.cs b/Lite.Tests/AzureMasterFallbackTests.cs index 77fd187fe..0b1bb3702 100644 --- a/Lite.Tests/AzureMasterFallbackTests.cs +++ b/Lite.Tests/AzureMasterFallbackTests.cs @@ -8,6 +8,7 @@ using System; using System.Linq; +using PerformanceMonitor.Common; using PerformanceMonitorLite.Models; using PerformanceMonitorLite.Services; using Xunit; @@ -29,6 +30,12 @@ namespace PerformanceMonitorLite.Tests; /// The fixes pinned here: reachability errors are not permission errors; the verdict expires; and a /// server coming back from an outage discards it immediately. /// +/// Also #1631, the over-correction. #1506 removed 40615 from the rights list (right) AND from the +/// single-database fallback path (wrong): a client can be firewalled out at the logical server while +/// a DATABASE-level rule keeps its user database fully reachable, which is the #857 case and the +/// configuration Microsoft recommends. "Not a rights denial" and "no fallback" are separate +/// questions, and these tests now pin both independently. +/// /// Only in-memory state is touched, so the service is constructed with null dependencies — the same /// approach as . /// @@ -43,17 +50,20 @@ private static ServerConnection Server(string name = "azure-logical-server") => new() { ServerName = name, DisplayName = name }; /// - /// 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. + /// The #1506 half. 40615 is "client with IP address ... is not allowed to access the server", a + /// firewall rejection at the logical server, and 40613 is "not currently available, please retry + /// later" — neither is a statement about this login's RIGHTS, so neither may form a rights verdict. + /// + /// Note this is now only half the story: 40615 not being a rights denial does NOT mean the + /// single-database fallback is wrong for it — see the #1631 pins below, which is the distinction + /// whose absence caused that regression. /// [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)); + Assert.False(SqlErrorClassification.IsMasterAccessDenied(errorNumber)); } /// @@ -68,24 +78,67 @@ public void Reachability_Errors_Are_Not_Master_Access_Denied(int errorNumber) [InlineData(18456)] // Login failed for user public void Permission_Errors_Are_Still_Master_Access_Denied(int errorNumber) { - Assert.True(RemoteCollectorService.IsMasterAccessDeniedError(errorNumber)); + Assert.True(SqlErrorClassification.IsMasterAccessDenied(errorNumber)); + } + + /// + /// #1631. Azure SQL DB evaluates DATABASE-level IP firewall rules BEFORE server-level ones, and a + /// client matching a database-level rule is granted a connection to that database even with no + /// server-level rule permitting it — while master, where server-level rules live, still needs one. + /// So "40615 opening master, user database perfectly reachable" is a real, Microsoft-recommended + /// configuration, and the single-database fallback is exactly right for it. + /// + /// #1506 correctly stopped reading 40615 as a rights denial but also dropped it from the fallback + /// path, on the mistaken premise that the same rule blocks the user database too. Every + /// database-scoped collector then failed outright against a reachable database. + /// + [Fact] + public void Firewall_Rejection_At_The_Server_Still_Falls_Back_To_The_Database() + { + Assert.True(RemoteCollectorService.ShouldFallBackToSingleDatabaseError(40615)); + } + + /// + /// Every rights denial still implies the fallback — the fallback set is a superset, never a + /// replacement, of the #857 list. + /// + [Theory] + [InlineData(229)] + [InlineData(230)] + [InlineData(916)] + [InlineData(4060)] + [InlineData(18456)] + public void Permission_Errors_Also_Fall_Back(int errorNumber) + { + Assert.True(RemoteCollectorService.ShouldFallBackToSingleDatabaseError(errorNumber)); + } + + /// + /// 40613 stays out of the fallback path: it is transient, so retrying — not degrading to + /// single-database collection — is the correct response. + /// + [Fact] + public void Transient_Unavailability_Does_Not_Trigger_The_Fallback() + { + Assert.False(RemoteCollectorService.ShouldFallBackToSingleDatabaseError(40613)); } /// - /// 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. + /// The invariant that would have caught #1506 at authoring time: no error number may be both + /// "retry, this is temporary" and "stop, degrade to single-database". 40613 was on both lists. + /// Asserted against the FALLBACK set, which is the broader of the two give-up sets. /// [Fact] public void No_Error_Is_Both_Transient_And_Permanently_Fatal() { var contradictory = RetryHelper.TransientErrorNumbers - .Where(RemoteCollectorService.IsMasterAccessDeniedError) + .Where(RemoteCollectorService.ShouldFallBackToSingleDatabaseError) .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."); + "reason to degrade to single-database collection. Pick one — see #1506."); } /// diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index 4f46fc4cf..9f7f25cca 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -812,7 +812,7 @@ to be reached. $"enumerate databases on {server.DisplayName}", cancellationToken: cancellationToken); } - catch (SqlException ex) when (IsMasterAccessDeniedError(ex.Number)) + catch (SqlException ex) when (ShouldFallBackToSingleDatabaseError(ex.Number)) { MarkMasterInaccessible(serverId); @@ -961,13 +961,16 @@ private static List SingleDbOrEmpty(string? targetDb) } /// - /// 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 - /// Darling so the two cannot drift. + /// Whether master enumeration failed in a way that means database-scoped collectors should fall back + /// to the connection's own catalog (#857). Deliberately broader than "this login cannot read master": + /// a 40615 firewall rejection at the logical server says nothing about the login's rights, but the + /// fallback still works, because Azure evaluates DATABASE-level firewall rules first and a user + /// database can be reachable while master is not (#1631). The list — and the reason a reachability + /// error must never be read as a rights verdict (#1506) — is owned by + /// , shared with Darling so the two cannot drift. /// - internal static bool IsMasterAccessDeniedError(int errorNumber) => - SqlErrorClassification.IsMasterAccessDenied(errorNumber); + internal static bool ShouldFallBackToSingleDatabaseError(int errorNumber) => + SqlErrorClassification.ShouldFallBackToSingleDatabase(errorNumber); /// /// Opens a SQL connection to a specific database on an Azure SQL DB logical server. diff --git a/PerformanceMonitor.Common/SqlErrorClassification.cs b/PerformanceMonitor.Common/SqlErrorClassification.cs index 57094f066..0dd4671ee 100644 --- a/PerformanceMonitor.Common/SqlErrorClassification.cs +++ b/PerformanceMonitor.Common/SqlErrorClassification.cs @@ -13,12 +13,16 @@ namespace PerformanceMonitor.Common; /// /// 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: +/// Three questions are asked about errors across the products, and they must never disagree: /// -/// "Is this temporary — should we retry?" -/// "Is this permanent — should we give up?" +/// "Is this temporary — should we retry?" +/// "Does this login lack rights to master?" +/// "Should database-scoped collection go single-DB?" /// -/// The two sets MUST be disjoint. Issue #1506 is what happens when they aren't: error 40613 +/// The last two are deliberately NOT the same question, which is the #1631 lesson: an error can make +/// the single-database fallback the right move without saying anything about the login's rights. +/// +/// The retryable set MUST be disjoint from both. Issue #1506 is what happens when it isn'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. @@ -64,13 +68,16 @@ public static class SqlErrorClassification /// 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. + /// at the logical server, which says nothing about this login's rights. /// /// 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. /// + /// 40615 nonetheless SHOULD still trigger the single-database fallback — that is a separate + /// question, answered by . Removing it from the + /// fallback path entirely (rather than only from this rights list) is what regressed #1631. + /// /// 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. /// @@ -83,6 +90,41 @@ public static class SqlErrorClassification 18456 // Login failed for user }; + /// + /// Errors after which database-scoped collection on Azure SQL DB should stop trying to enumerate + /// databases from master and fall back to the connection's own catalog. + /// + /// This is a superset of — every rights denial implies + /// the fallback — plus the one REACHABILITY error where the fallback genuinely works: + /// + /// 40615 — "client with IP address ... is not allowed to access the server". Azure SQL DB has two + /// independent IP firewall layers, and the DATABASE-level rules are evaluated FIRST: a + /// client whose IP matches a database-level rule is granted a connection to that database + /// even with no server-level rule permitting it, while master (where server-level + /// rules live) still requires a server-level rule. So "blocked at the server, allowed at + /// the database" is a real, fully supported configuration — Microsoft in fact recommends + /// database-level rules "whenever possible" — and it is exactly what #857 shipped for. + /// + /// #1506 removed 40615 from the rights list for good reason, but ALSO dropped it from the fallback + /// path on the mistaken premise that "the same rule blocks that connection too". It does not, and + /// #1631 was the result: every database-scoped collector failed outright on a server whose user + /// database was perfectly reachable. + /// + /// This is safe to act on because the resulting verdict is a revocable throttle, never a latch: it + /// expires (15 minutes), is cleared when a server returns from an outage, and is skipped entirely + /// when there is no target database to fall back to. A client that is merely firewalled out at the + /// server with no database-level rule therefore just fails its fallback attempt with the same 40615, + /// logs an ordinary collector error, and recovers on the next successful connect — the #1506 + /// scenario, without the permanent wedge. + /// + /// 40613 stays out: it is transient, and retrying is the correct response. + /// + public static readonly IReadOnlySet SingleDatabaseFallbackErrorNumbers = new HashSet( + MasterAccessDeniedErrorNumbers) + { + 40615 // Azure SQL - client IP not allowed at the logical server (database-level rule may still allow) + }; + /// /// True when the error is temporary and the operation is worth retrying. /// @@ -94,4 +136,13 @@ public static class SqlErrorClassification /// . /// public static bool IsMasterAccessDenied(int errorNumber) => MasterAccessDeniedErrorNumbers.Contains(errorNumber); + + /// + /// True when database-scoped collection should fall back to the connection's own catalog instead of + /// enumerating from master. Broader than on purpose — see + /// the remarks on . This is the question the Azure + /// database-enumeration catch sites in both SKUs actually need answered. + /// + public static bool ShouldFallBackToSingleDatabase(int errorNumber) => + SingleDatabaseFallbackErrorNumbers.Contains(errorNumber); } From f1692f4851d2c93cde2b4d9fbbb8100676ced7dd Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:25:59 -0400 Subject: [PATCH 040/296] Default trace: only strip rollover suffixes in the filename (#1636) Follow-up to #1633: the _N rollover strip keyed on the LAST underscore anywhere in the path, so a trace in an underscore-named directory with an unsuffixed file was cut at the directory underscore (/var/opt/my_sql/log/log.trc -> /var/opt/my.trc -> Msg 19049, no events collected). An underscore now only counts as a rollover suffix when it sits after the last path separator, both families, since the collector runs against Windows and Linux targets. Applied to the shared collector and install/29; truth-tabled live on SQL 2022 across nine path shapes. Also carries the review follow-ups from the #1630/#1633 external PRs: - CHANGELOG entries for #1630, #1633, and this fix. - The three CPU comment sites now cite the actual ring-buffer fix (SQL Server 2025 CU1, KB5078298 fix 4796293) instead of implying 2025 RTM, per MS Learn verification. - The #1630 test's DoesNotContain guard normalizes line endings first, so it cannot pass vacuously on a CRLF checkout. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 ++++++++ .../CpuUtilizationCollectorDefinitionTests.cs | 5 ++++- ...faultTraceEventsCollectorDefinitionTests.cs | 18 ++++++++++++++++++ .../CpuUtilizationCollector.cs | 13 +++++++------ .../DefaultTraceEventsCollector.cs | 16 ++++++++++------ .../Services/DatabaseService.NocHealth.cs | 17 +++++++++-------- install/18_collect_cpu_utilization_stats.sql | 14 ++++++++------ install/29_collect_default_trace.sql | 16 ++++++++++------ 8 files changed, 74 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf16beb68..1de7065cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Lite + Darling: Linux hosts on SQL Server 2025 CU1+ get real other-process CPU again** ([#1630]) - the [#1048] guard NULLed `other_process_cpu_utilization` on EVERY Linux host, because the SCHEDULER_MONITOR ring buffer always reported `SystemIdle = 0` on Linux. SQL Server 2025 CU1 fixes the ring-buffer metrics (KB5078298), so the blanket guard was discarding a derivable value there forever. The guard now keys on the actual condition (`@is_linux = 1 AND system_idle = 0`) instead of the platform, so older Linux builds keep their honest NULL and 2025 CU1+ derives the real figure - version-proof by construction. Community fix by @argpna ([#1629]); applied to the shared collector (Lite + Darling) and the deprecated Dashboard's collection proc + NOC health query. +- **Lite + Darling: default trace collection works on SQL Server on Linux** ([#1633]) - the rollover-file path normalization assumed the trace file always carries a `_N` suffix (true on Windows); the initial file on Linux is plain `log.trc`, so the strip re-appended the extension onto the full path (`log.trc.trc`), `sys.fn_trace_gettable` raised Msg 19049 on the nonexistent file, and NO default-trace events were ever collected on Linux. The strip now falls back to the base path when there is no suffix to remove. Community fix by @argpna ([#1632]), live-verified against a 2025 Linux container; shared collector + the deprecated Dashboard's proc. +- **Default trace: the rollover strip no longer mangles paths whose DIRECTORY contains an underscore** ([#1636]) - follow-up to [#1633]: the strip keyed on the LAST underscore anywhere in the path, so a relocated trace in an underscore-named directory with an unsuffixed file (`/var/opt/my_sql/log/log.trc`) was cut at the directory underscore (`/var/opt/my.trc` - Msg 19049 again). An underscore now only counts as a rollover suffix when it sits after the last path separator (both separator families, since targets are Windows and Linux); truth-tabled live against SQL 2022 across nine path shapes. - **Lite + Darling: Azure SQL DB collection works again when the client is firewalled out at the logical server but allowed at the DATABASE level** ([#1634]) - reported against 3.2.0 by a user monitoring an Elastic Pool database ([#1631]): every database-scoped collector failed with `40615 - Cannot open server ... Client with IP address ... is not allowed to access the server`, and the long-query XE session could not enumerate databases, even though the database itself was fully reachable from SSMS. **This is a regression of the [#857] single-database fallback, introduced by [#1506].** That fix correctly stopped reading 40615 ("client IP not allowed at the logical server") as a statement about a login's RIGHTS to read `master` - it is a reachability error, and misreading it had permanently wedged a different user whose public IP rotated daily. But it also dropped 40615 from the single-database FALLBACK path, on the stated premise that "falling back to a user database is futile because the same rule blocks that connection too." That premise is wrong: Azure SQL Database evaluates **database-level** IP firewall rules BEFORE server-level ones, and a client whose IP matches a database-level rule (`sp_set_database_firewall_rule`) is granted a connection to that database with **no** server-level rule permitting it - while `master`, where server-level rules live, still requires one. "Blocked at the server, allowed at the database" is therefore a real, supported configuration that Microsoft explicitly recommends ("use database-level IP firewall rules whenever possible"), and it is precisely the [#857] case. The two questions - *"does this login lack rights to master?"* and *"should database-scoped collection degrade to single-database?"* - are now separated in the shared `SqlErrorClassification`: the rights list is unchanged (40615 still correctly excluded, so every [#1506] invariant holds), and a new fallback list is that set plus 40615, which is what both SKUs' Azure database-enumeration catch sites actually ask. Acting on it is safe because [#1506]'s other fix survives untouched: the resulting verdict is a revocable throttle, not a latch - it expires after 15 minutes, is discarded when a server returns from an outage, and is skipped entirely when there is no target database to fall back to. A client genuinely firewalled out with no database-level rule therefore just fails its fallback attempt with the same 40615, logs an ordinary collector error, and recovers on the next successful connect. Pinned from both suites, independently: 40615 is still not a rights denial, AND it does trigger the fallback; the rights set remains a strict subset of the fallback set; and the disjointness invariant is now asserted against the broader fallback set (40613 stays transient-and-retryable, never a reason to degrade). Lite + Darling. - **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. - **Darling Web: a custom view no longer resets the picked time range / server / filters every 60 seconds** ([#1619]) - the background refresh that keeps the dashboard live rebuilt each view from its declared default, snapping any range/server/filter change back within a minute. A per-view scope memory now survives the re-render, so a picked scope sticks until you change it or hard-reload. @@ -530,7 +533,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1626]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1626 [#1627]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1627 [#1631]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1631 +[#1629]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1629 +[#1630]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1630 +[#1632]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1632 +[#1633]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1633 [#1634]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1634 +[#1636]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1636 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 diff --git a/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs index 6da3591fc..d3ab65900 100644 --- a/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs +++ b/Lite.Tests/CpuUtilizationCollectorDefinitionTests.cs @@ -46,7 +46,10 @@ public void BuildQuery_RingBuffer_SuppressesOtherCpu_OnlyWhenSystemIdleIsZero() CollectorTestContext.Make(s_deltas, isAzureSqlDb: false)); Assert.Contains("WHEN @is_linux = 1 AND x.system_idle = 0", plan.Text, StringComparison.Ordinal); - Assert.DoesNotContain("WHEN @is_linux = 1\n", plan.Text, StringComparison.Ordinal); + + /* Normalize line endings first: on a CRLF checkout the template contains "= 1\r\n", which + the "\n"-suffixed needle would never match, making this guard vacuously pass. */ + Assert.DoesNotContain("WHEN @is_linux = 1\n", plan.Text.Replace("\r\n", "\n"), StringComparison.Ordinal); } [Fact] diff --git a/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs b/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs index e83c587af..502431836 100644 --- a/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs +++ b/Lite.Tests/DefaultTraceEventsCollectorDefinitionTests.cs @@ -91,6 +91,24 @@ nonexistent file. The CASE must fall back to the base t.path when CHARINDEX find Assert.Contains("ELSE t.path", text, StringComparison.Ordinal); } + [Fact] + public void BuildQuery_RolloverPathStrip_OnlyStripsUnderscoresInTheFilename() + { + /* A trace DIRECTORY containing an underscore with an unsuffixed file (#1636: + /var/opt/my_sql/log/log.trc) must NOT strip — the last underscore has to sit AFTER the + last path separator (either family, since targets are Windows and Linux) to count as a + rollover suffix. Otherwise LEFT cuts at the directory underscore and produces a + nonexistent path (/var/opt/my.trc → Msg 19049). */ + var text = DefaultTraceEventsCollector.Instance.BuildQuery(MakeContext()).Text; + + Assert.Contains( + @"AND (CHARINDEX(N'\', REVERSE(t.path)) = 0 OR CHARINDEX(N'_', REVERSE(t.path)) < CHARINDEX(N'\', REVERSE(t.path)))", + text, StringComparison.Ordinal); + Assert.Contains( + "AND (CHARINDEX(N'/', REVERSE(t.path)) = 0 OR CHARINDEX(N'_', REVERSE(t.path)) < CHARINDEX(N'/', REVERSE(t.path)))", + text, StringComparison.Ordinal); + } + [Fact] public void BuildQuery_CapturesTheCuratedEventSet() { diff --git a/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs index cdc8c762d..a04a2c66b 100644 --- a/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs +++ b/PerformanceMonitor.Collectors/CpuUtilizationCollector.cs @@ -72,12 +72,13 @@ @start_time datetime2(7), /* Detect SQL Server on Linux. SystemIdle reports 0 in the SCHEDULER_MONITOR ring buffer on some Linux/SQL Server version combos, so 100 - SystemIdle - ProcessUtilization - fabricates a host figure that pins total CPU at 100% (Issue #1048). Prior to SQL Server - 2025 CU1 no DMV exposes true host CPU when that happens, so other_process is stored as NULL - there. sys.dm_os_linux_cpu_stats (2025 CU1+) exposes real host CPU jiffies but is a cumulative - counter requiring a two-sample delta, not a point-in-time snapshot like SCHEDULER_MONITOR, so - it isn't used here. sys.dm_os_host_info is 2017+; referenced via sp_executesql so SQL 2016 - never binds it (@is_linux = 0). */ + fabricates a host figure that pins total CPU at 100% (Issue #1048). The ring-buffer metrics + fix shipped in SQL Server 2025 CU1 (KB5078298 fix 4796293), so real SystemIdle values start + there, not at 2025 RTM. Prior to that no DMV exposes true host CPU when SystemIdle is 0, so + other_process is stored as NULL. sys.dm_os_linux_cpu_stats (2025 CU1+) exposes real host CPU + time but is a cumulative counter requiring a two-sample delta, not a point-in-time snapshot + like SCHEDULER_MONITOR, so it isn't used here. 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;', diff --git a/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs b/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs index 8b9fa946c..115af272c 100644 --- a/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs +++ b/PerformanceMonitor.Collectors/DefaultTraceEventsCollector.cs @@ -114,12 +114,14 @@ public sealed class Row /* 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. The strip is guarded by a CHARINDEX - check: SQL Server on Linux names the initial trace file without a rollover suffix (log.trc, not - log_1.trc), so an unconditional strip finds no underscore, LEFT takes the full path unchanged, and - appending the extension back doubles it (log.trc.trc) - fn_trace_gettable then errors on the - nonexistent file (Msg 19049) and no events are collected. Falling back to t.path when CHARINDEX - returns 0 keeps the Windows behavior (which always has the _N suffix) unchanged. READ UNCOMMITTED + (strip _NN + re-append the extension) reads every retained file. The strip only fires when the + LAST underscore sits in the FILENAME, i.e. after the last path separator (either family — the + collector runs against Windows and Linux targets): SQL Server on Linux names the initial trace + file without a rollover suffix (log.trc, not log_1.trc), so an unguarded strip mangles the path + (log.trc.trc — #1633), and a trace DIRECTORY containing an underscore would mangle it a second + way (/var/opt/my_sql/log/log.trc -> /var/opt/my.trc — #1636); fn_trace_gettable then errors on + the nonexistent file (Msg 19049) and no events are collected. Falling back to t.path in both + cases keeps the Windows behavior (which always has the _N suffix) unchanged. 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 = @" @@ -152,6 +154,8 @@ CROSS APPLY sys.fn_trace_gettable ( CASE WHEN CHARINDEX(N'_', REVERSE(t.path)) > 0 + AND (CHARINDEX(N'\', REVERSE(t.path)) = 0 OR CHARINDEX(N'_', REVERSE(t.path)) < CHARINDEX(N'\', REVERSE(t.path))) + AND (CHARINDEX(N'/', REVERSE(t.path)) = 0 OR CHARINDEX(N'_', REVERSE(t.path)) < CHARINDEX(N'/', REVERSE(t.path))) THEN LEFT(t.path, LEN(t.path) - CHARINDEX(N'_', REVERSE(t.path))) + RIGHT(t.path, 4) ELSE t.path END, diff --git a/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs b/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs index 152418671..e1a06f29e 100644 --- a/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs +++ b/deprecated/Dashboard/Services/DatabaseService.NocHealth.cs @@ -477,14 +477,15 @@ end_time DESC /* SystemIdle reports 0 in the SCHEDULER_MONITOR ring buffer on some Linux/SQL Server version combos, so 100 - SystemIdle - ProcessUtilization fabricates a - host figure that pins total CPU at 100% forever (Issue #1048). Prior to SQL - Server 2025 CU1 no DMV exposes true host CPU when that happens, so report - other/host CPU as NULL and let the alert engine fall back to the SQL-only - figure. sys.dm_os_linux_cpu_stats (2025 CU1+) exposes real host CPU jiffies - but is a cumulative counter requiring a two-sample delta, not a point-in-time - snapshot like SCHEDULER_MONITOR, so it isn't used here. sys.dm_os_host_info is - 2017+; referenced via sp_executesql so SQL 2016 (no Linux build) never binds it - (@is_linux stays 0). */ + host figure that pins total CPU at 100% forever (Issue #1048). The ring-buffer + metrics fix shipped in SQL Server 2025 CU1 (KB5078298 fix 4796293), so real + SystemIdle values start there, not at 2025 RTM. Prior to that no DMV exposes + true host CPU when SystemIdle is 0, so report other/host CPU as NULL and let + the alert engine fall back to the SQL-only figure. sys.dm_os_linux_cpu_stats + (2025 CU1+) exposes real host CPU time but is a cumulative counter requiring a + two-sample delta, not a point-in-time snapshot like SCHEDULER_MONITOR, so it + isn't used here. sys.dm_os_host_info is 2017+; referenced via sp_executesql so + SQL 2016 (no Linux build) never binds it (@is_linux stays 0). */ IF OBJECT_ID(N'sys.dm_os_host_info', N'V') IS NOT NULL BEGIN EXEC sys.sp_executesql diff --git a/install/18_collect_cpu_utilization_stats.sql b/install/18_collect_cpu_utilization_stats.sql index 4c0556d9e..653bf6d95 100644 --- a/install/18_collect_cpu_utilization_stats.sql +++ b/install/18_collect_cpu_utilization_stats.sql @@ -59,12 +59,14 @@ BEGIN Detect SQL Server on Linux. On some Linux/SQL Server version combos the SCHEDULER_MONITOR ring buffer reports SystemIdle = 0, so 100 - SystemIdle - ProcessUtilization fabricates a host figure that pins total CPU at 100% - forever (Issue #1048). Prior to SQL Server 2025 CU1 there is no DMV that - exposes true host CPU when that happens, so we store NULL for - other_process_cpu_utilization instead of a false value. sys.dm_os_linux_cpu_stats - (2025 CU1+) exposes real host CPU jiffies but is a cumulative counter - requiring a two-sample delta, not a point-in-time snapshot like - SCHEDULER_MONITOR, so it isn't used here. + forever (Issue #1048). The ring-buffer metrics fix shipped in SQL Server + 2025 CU1 (KB5078298 fix 4796293), so real SystemIdle values start there, + not at 2025 RTM. Prior to that there is no DMV that exposes true host CPU + when SystemIdle is 0, so we store NULL for other_process_cpu_utilization + instead of a false value. sys.dm_os_linux_cpu_stats (2025 CU1+) exposes + real host CPU time but is a cumulative counter requiring a two-sample + delta, not a point-in-time snapshot like SCHEDULER_MONITOR, so it isn't + used here. sys.dm_os_host_info exists only on SQL Server 2017+. It is referenced through sp_executesql so SQL Server 2016 (which has no Linux build) never binds it and diff --git a/install/29_collect_default_trace.sql b/install/29_collect_default_trace.sql index c754e8c66..a5ad506f7 100644 --- a/install/29_collect_default_trace.sql +++ b/install/29_collect_default_trace.sql @@ -359,15 +359,19 @@ BEGIN CROSS APPLY sys.fn_trace_gettable ( /* - SQL Server on Linux names the initial trace file without a rollover suffix - (log.trc, not log_1.trc). An unconditional strip finds no underscore, LEFT - takes the full path unchanged, and appending the extension back doubles it - (log.trc.trc) - fn_trace_gettable then errors on the nonexistent file - (Msg 19049) and no events are collected. Fall back to st.path when - CHARINDEX returns 0. + Strip the _N rollover suffix only when the LAST underscore sits in the + FILENAME, i.e. after the last path separator (either family). SQL Server + on Linux names the initial trace file without a rollover suffix (log.trc, + not log_1.trc), so an unguarded strip mangles the path (log.trc.trc, + issue 1633), and a trace DIRECTORY containing an underscore would mangle + it a second way (/var/opt/my_sql/log/log.trc -> /var/opt/my.trc, issue + 1636) - fn_trace_gettable then errors on the nonexistent file (Msg 19049) + and no events are collected. Fall back to st.path in both cases. */ CASE WHEN CHARINDEX(N'_', REVERSE(st.path)) > 0 + AND (CHARINDEX(N'\', REVERSE(st.path)) = 0 OR CHARINDEX(N'_', REVERSE(st.path)) < CHARINDEX(N'\', REVERSE(st.path))) + AND (CHARINDEX(N'/', REVERSE(st.path)) = 0 OR CHARINDEX(N'_', REVERSE(st.path)) < CHARINDEX(N'/', REVERSE(st.path))) THEN LEFT(st.path, LEN(st.path) - CHARINDEX(N'_', REVERSE(st.path))) + RIGHT(st.path, 4) ELSE st.path END, From 0ea22faa96989aaba3f85be3ea11dd2d60aa853b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:31:41 -0400 Subject: [PATCH 041/296] CHANGELOG: entry for #1637 (trace_flag_changes bit conversion) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de7065cc..f9511c4eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Lite + Darling: Linux hosts on SQL Server 2025 CU1+ get real other-process CPU again** ([#1630]) - the [#1048] guard NULLed `other_process_cpu_utilization` on EVERY Linux host, because the SCHEDULER_MONITOR ring buffer always reported `SystemIdle = 0` on Linux. SQL Server 2025 CU1 fixes the ring-buffer metrics (KB5078298), so the blanket guard was discarding a derivable value there forever. The guard now keys on the actual condition (`@is_linux = 1 AND system_idle = 0`) instead of the platform, so older Linux builds keep their honest NULL and 2025 CU1+ derives the real figure - version-proof by construction. Community fix by @argpna ([#1629]); applied to the shared collector (Lite + Darling) and the deprecated Dashboard's collection proc + NOC health query. - **Lite + Darling: default trace collection works on SQL Server on Linux** ([#1633]) - the rollover-file path normalization assumed the trace file always carries a `_N` suffix (true on Windows); the initial file on Linux is plain `log.trc`, so the strip re-appended the extension onto the full path (`log.trc.trc`), `sys.fn_trace_gettable` raised Msg 19049 on the nonexistent file, and NO default-trace events were ever collected on Linux. The strip now falls back to the base path when there is no suffix to remove. Community fix by @argpna ([#1632]), live-verified against a 2025 Linux container; shared collector + the deprecated Dashboard's proc. +- **Full Dashboard (deprecated): the `report.trace_flag_changes` view no longer fails with Msg 245 on a trace-flag toggle** ([#1637]) - the view compared the bit-typed `previous_status`/`status` columns against `N'OFF'`/`N'ON'` literals; bit outranks nvarchar in type precedence, so SQL Server converted the literal to bit and the view failed the moment a toggle row flowed through it. It now compares `0`/`1`. Community fix by @argpna ([#1635]). - **Default trace: the rollover strip no longer mangles paths whose DIRECTORY contains an underscore** ([#1636]) - follow-up to [#1633]: the strip keyed on the LAST underscore anywhere in the path, so a relocated trace in an underscore-named directory with an unsuffixed file (`/var/opt/my_sql/log/log.trc`) was cut at the directory underscore (`/var/opt/my.trc` - Msg 19049 again). An underscore now only counts as a rollover suffix when it sits after the last path separator (both separator families, since targets are Windows and Linux); truth-tabled live against SQL 2022 across nine path shapes. - **Lite + Darling: Azure SQL DB collection works again when the client is firewalled out at the logical server but allowed at the DATABASE level** ([#1634]) - reported against 3.2.0 by a user monitoring an Elastic Pool database ([#1631]): every database-scoped collector failed with `40615 - Cannot open server ... Client with IP address ... is not allowed to access the server`, and the long-query XE session could not enumerate databases, even though the database itself was fully reachable from SSMS. **This is a regression of the [#857] single-database fallback, introduced by [#1506].** That fix correctly stopped reading 40615 ("client IP not allowed at the logical server") as a statement about a login's RIGHTS to read `master` - it is a reachability error, and misreading it had permanently wedged a different user whose public IP rotated daily. But it also dropped 40615 from the single-database FALLBACK path, on the stated premise that "falling back to a user database is futile because the same rule blocks that connection too." That premise is wrong: Azure SQL Database evaluates **database-level** IP firewall rules BEFORE server-level ones, and a client whose IP matches a database-level rule (`sp_set_database_firewall_rule`) is granted a connection to that database with **no** server-level rule permitting it - while `master`, where server-level rules live, still requires one. "Blocked at the server, allowed at the database" is therefore a real, supported configuration that Microsoft explicitly recommends ("use database-level IP firewall rules whenever possible"), and it is precisely the [#857] case. The two questions - *"does this login lack rights to master?"* and *"should database-scoped collection degrade to single-database?"* - are now separated in the shared `SqlErrorClassification`: the rights list is unchanged (40615 still correctly excluded, so every [#1506] invariant holds), and a new fallback list is that set plus 40615, which is what both SKUs' Azure database-enumeration catch sites actually ask. Acting on it is safe because [#1506]'s other fix survives untouched: the resulting verdict is a revocable throttle, not a latch - it expires after 15 minutes, is discarded when a server returns from an outage, and is skipped entirely when there is no target database to fall back to. A client genuinely firewalled out with no database-level rule therefore just fails its fallback attempt with the same 40615, logs an ordinary collector error, and recovers on the next successful connect. Pinned from both suites, independently: 40615 is still not a rights denial, AND it does trigger the fallback; the rights set remains a strict subset of the fallback set; and the disjointness invariant is now asserted against the broader fallback set (40613 stays transient-and-retryable, never a reason to degrade). Lite + Darling. - **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. @@ -538,7 +539,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1632]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1632 [#1633]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1633 [#1634]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1634 +[#1635]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1635 [#1636]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1636 +[#1637]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1637 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 From a3295ea8bc25f2cfab067ed6bbace135d15ee281 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:37:15 -0400 Subject: [PATCH 042/296] CHANGELOG: remove leftover conflict marker Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f15b10afb..1fd1f6701 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -538,7 +538,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1634]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1634 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 ->>>>>>> origin/dev [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 [#1604]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1604 [#1602]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1602 From e6fcc7f392d1bbab1d994d9ce0dc75369cde31a3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:00:41 -0400 Subject: [PATCH 043/296] Darling: close the BYO provision-roles.sql viewer column-ACL drift (#1639) The fail-closed viewer column ACL (#1262) is authored once in C# (DarlingManagedRoles.ViewerRestrictedConfigTables) and applied two ways. Managed mode GENERATES the REVOKE-then-GRANT-columns carve from that list on every service start, so it is correct by construction. BYO mode has no service to generate anything: the operator runs Darling/tools/provision-roles.sql by hand, and its three GRANT SELECT (...) lists are a hand copy of the same columns. Nothing pinned the two together and they drifted: config_monitored_servers MISSING alert_delivery_mode_override (#1551) config_notification MISSING generic_body_template (#1506) config_notification MISSING generic_proxy (#1506) config_command in sync Because the carve is fail-CLOSED, an unlisted column is invisible, so on a BYO store a read-only viewer seat took a bare SQLSTATE 42501 on the two projections written specifically FOR it: MonitoredServersSelectSql (the Manage Servers list + sidebar reconcile) and NotificationSelectNoSecretSql (the Settings prefill, where one throw blanks every later section). Managed deployments were never affected. Only the three drifted columns are added - no grant is widened. The real defect is the unguarded hand mirror, so add an UNGATED ProvisionRolesAclDriftTests: it parses the SHIPPED .sql (copied beside the test binary by the csproj, so there is no stale second copy and no hardcoded repo path), extracts every GRANT SELECT (columns) ON schema.table TO role, and asserts set-equality against the C# list in both directions, plus the paired REVOKE and its ordering, that no secret column is ever granted, and that the script still provisions admin+viewer only (BYO deliberately has no mcp role). A meta-test mutates the real file three ways and asserts the same comparison reports each, so a parser that silently matched nothing cannot pass as "no drift". Darling.Tests: 2958 passed, 0 failed, 147 gated-live skipped (DARLING_TEST_PG cleared). Verified by hand that removing either restored column reintroduces a failing test. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + Darling/Darling.Tests/Darling.Tests.csproj | 5 + .../ProvisionRolesAclDriftTests.cs | 311 ++++++++++++++++++ Darling/tools/provision-roles.sql | 16 +- 4 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 Darling/Darling.Tests/ProvisionRolesAclDriftTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index cf16beb68..7d9af36a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Darling: a bring-your-own-Postgres `viewer` seat gets `permission denied` on the Manage Servers list and the Settings notification prefill** ([#1639]) - the fail-closed viewer column ACL (#1262) is authored ONCE in C# (`DarlingManagedRoles.ViewerRestrictedConfigTables`) and applied two ways: managed mode GENERATES the `REVOKE`-then-`GRANT SELECT (columns)` carve from that list on every service start, so it is correct by construction, while BYO mode has no service to generate anything - the operator runs `Darling/tools/provision-roles.sql` by hand, and that file's three `GRANT SELECT (...)` lists are a HAND COPY of the same columns. Nothing pinned the two together, and they drifted across three releases: `config_notification.generic_body_template` / `generic_proxy` arrived with the generic webhook channel ([#1506]) and `config_monitored_servers.alert_delivery_mode_override` was added to the C# list by [#1551] - where the gated-live ratchet caught the MANAGED half of exactly this bug and the BYO hand mirror was the half that got missed - and in neither case did the .sql follow. Because the carve is fail-CLOSED by design - `viewer`'s table-wide SELECT is revoked and only the enumerated columns re-granted - an unlisted column is INVISIBLE, so on a BYO store a read-only seat took a bare SQLSTATE `42501` on the two projections written specifically FOR it: `MonitoredServersSelectSql` (the Manage Servers list, which also drives the sidebar reconcile) and `NotificationSelectNoSecretSql` (the Settings prefill, where one such throw blanks every later section). Quiet in exactly the wrong direction - the columns exist, the script runs clean, `psql` as the owner sees everything, and only a read from the least-privilege role ever notices. Managed deployments were never affected. The three lists now match the C# source of truth exactly (nothing else was added - this closes existing drift, it does not widen the grant), and the real defect - a hand mirror with no guard - is closed by a new UNGATED `ProvisionRolesAclDriftTests`: it parses the SHIPPED .sql (copied beside the test binary, so there is no second copy to go stale and no repo path to hardcode), extracts every `GRANT SELECT (columns) ON schema.table TO role`, and asserts set-equality against the C# list in BOTH directions, plus the paired `REVOKE` and its ordering (without the REVOKE first the carve is a no-op and the secrets stay readable), that no secret column appears in any grant, and that the script still provisions admin+viewer only - never a half-provisioned `mcp` role, which BYO deliberately does not create. The guard is itself guarded: a meta-test mutates the real file three ways (drop a column, add one, delete a whole carve) and asserts the same comparison reports each, so a parser that silently matched nothing can never pass as "no drift". Verified by hand as well - removing either restored column reintroduces a failing test. - **Lite + Darling: Azure SQL DB collection works again when the client is firewalled out at the logical server but allowed at the DATABASE level** ([#1634]) - reported against 3.2.0 by a user monitoring an Elastic Pool database ([#1631]): every database-scoped collector failed with `40615 - Cannot open server ... Client with IP address ... is not allowed to access the server`, and the long-query XE session could not enumerate databases, even though the database itself was fully reachable from SSMS. **This is a regression of the [#857] single-database fallback, introduced by [#1506].** That fix correctly stopped reading 40615 ("client IP not allowed at the logical server") as a statement about a login's RIGHTS to read `master` - it is a reachability error, and misreading it had permanently wedged a different user whose public IP rotated daily. But it also dropped 40615 from the single-database FALLBACK path, on the stated premise that "falling back to a user database is futile because the same rule blocks that connection too." That premise is wrong: Azure SQL Database evaluates **database-level** IP firewall rules BEFORE server-level ones, and a client whose IP matches a database-level rule (`sp_set_database_firewall_rule`) is granted a connection to that database with **no** server-level rule permitting it - while `master`, where server-level rules live, still requires one. "Blocked at the server, allowed at the database" is therefore a real, supported configuration that Microsoft explicitly recommends ("use database-level IP firewall rules whenever possible"), and it is precisely the [#857] case. The two questions - *"does this login lack rights to master?"* and *"should database-scoped collection degrade to single-database?"* - are now separated in the shared `SqlErrorClassification`: the rights list is unchanged (40615 still correctly excluded, so every [#1506] invariant holds), and a new fallback list is that set plus 40615, which is what both SKUs' Azure database-enumeration catch sites actually ask. Acting on it is safe because [#1506]'s other fix survives untouched: the resulting verdict is a revocable throttle, not a latch - it expires after 15 minutes, is discarded when a server returns from an outage, and is skipped entirely when there is no target database to fall back to. A client genuinely firewalled out with no database-level rule therefore just fails its fallback attempt with the same 40615, logs an ordinary collector error, and recovers on the next successful connect. Pinned from both suites, independently: 40615 is still not a rights denial, AND it does trigger the fallback; the rights set remains a strict subset of the fallback set; and the disjointness invariant is now asserted against the broader fallback set (40613 stays transient-and-retryable, never a reason to degrade). Lite + Darling. - **Darling: collectors no longer fail with `22021: invalid byte sequence for encoding "UTF8": 0x00`** ([#1614]) - SQL Server NVARCHAR allows embedded NUL characters and query text from `sys.dm_exec_sql_text` sometimes carries them, but Postgres `text` columns reject the byte, so one NUL-laden cached query failed the entire `query_stats` COPY batch every cycle (`DBCC FREEPROCCACHE` never helped because the app re-caches the same query). The Postgres row writer now strips NULs from every collected string - query text, plan XML, deadlock and blocked-process XML included - at the single COPY choke point. - **Darling Web: a custom view no longer resets the picked time range / server / filters every 60 seconds** ([#1619]) - the background refresh that keeps the dashboard live rebuilt each view from its declared default, snapping any range/server/filter change back within a minute. A per-view scope memory now survives the re-render, so a picked scope sticks until you change it or hard-reload. @@ -531,6 +532,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1627]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1627 [#1631]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1631 [#1634]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1634 +[#1639]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1639 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 diff --git a/Darling/Darling.Tests/Darling.Tests.csproj b/Darling/Darling.Tests/Darling.Tests.csproj index d8443ba4f..6df95e7a5 100644 --- a/Darling/Darling.Tests/Darling.Tests.csproj +++ b/Darling/Darling.Tests/Darling.Tests.csproj @@ -33,5 +33,10 @@ + + diff --git a/Darling/Darling.Tests/ProvisionRolesAclDriftTests.cs b/Darling/Darling.Tests/ProvisionRolesAclDriftTests.cs new file mode 100644 index 000000000..c423f2ecf --- /dev/null +++ b/Darling/Darling.Tests/ProvisionRolesAclDriftTests.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.IO; +using System.Linq; +using System.Text.RegularExpressions; +using PerformanceMonitor.Darling.Service; +using PerformanceMonitor.Darling.Storage; +using Xunit; + +namespace Darling.Tests; + +/// +/// Drift guard between the SHIPPED bring-your-own-Postgres provisioning script +/// (Darling/tools/provision-roles.sql) and the C# list it hand-mirrors, +/// . +/// +/// Why this exists: the fail-closed viewer column ACL (#1262) is authored ONCE in C# and applied +/// two ways. Managed mode generates it from the C# list at every service start +/// (), so it is correct by construction. BYO mode has +/// no service to generate anything — the operator runs the .sql by hand, and that file's +/// GRANT SELECT (…) lists are a HAND COPY of the same columns. Nothing tied the two together, so they +/// drifted, silently, across three releases: the C# list gained +/// config_monitored_servers.alert_delivery_mode_override and +/// config_notification.generic_body_template / generic_proxy, the .sql was never updated, and a +/// BYO viewer seat SQLSTATE-42501'd on the two projections written FOR it — +/// ViewerDataService.MonitoredServersSelectSql (the Manage Servers list + sidebar reconcile) and +/// NotificationSelectNoSecretSql (the Settings prefill) — reads a managed seat served fine (#1639). The +/// failure mode is quiet in exactly the wrong direction: the columns still exist, the script still runs clean, +/// and only a read from the least-privilege role ever notices. +/// +/// The existing coverage could not catch it. asserts the +/// GENERATED DDL against the same C# list (both sides of that comparison move together), and +/// proves the carve against a real store — but as the gated-live +/// suite, using the C#-generated DDL, never the shipped .sql. This test reads the REAL file (copied beside the +/// test binary by the csproj, so there is no second copy to go stale and no repo-root path walking), parses +/// out every GRANT SELECT (columns) ON schema.table TO role, and asserts SET-EQUALITY against the C# +/// list — so a column added to either side without the other now fails an UNGATED test on every build, +/// database or not. +/// +/// The guard is itself guarded: runs the +/// identical comparison against a mutated copy of the file and asserts it reports the difference, so a parser +/// that silently matched nothing (a reformatted GRANT, a renamed file) can never pass as "no drift". +/// +public sealed class ProvisionRolesAclDriftTests +{ + /// + /// GRANT SELECT (col, col, …) ON schema.table TO role; — the column-level form. A grant naming + /// MORE than one role (TO admin, viewer) deliberately does NOT match: the trailing ; is + /// required, so a widened grant drops out of the parse and fails the table-set assertion rather than + /// being read as a viewer-only carve. + /// + private static readonly Regex ColumnGrant = new( + @"GRANT\s+SELECT\s*\(\s*(?[^)]+?)\s*\)\s*ON\s+(?[A-Za-z_][A-Za-z0-9_]*)\.(?[A-Za-z_][A-Za-z0-9_]*)\s+TO\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*;", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// REVOKE SELECT ON schema.table FROM role; — the carve's fail-closed half. + private static readonly Regex TableRevoke = new( + @"REVOKE\s+SELECT\s+ON\s+(?[A-Za-z_][A-Za-z0-9_]*)\.(?
[A-Za-z_][A-Za-z0-9_]*)\s+FROM\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*;", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private const string ViewerRole = DarlingManagedPostgres.ViewerRoleName; + private const string AdminRole = DarlingManagedPostgres.AdminRoleName; + private const string McpRole = DarlingManagedPostgres.McpRoleName; + private const string ConfigSchema = PgSchemaGenerator.ConfigSchema; + + [Fact] + public void ProvisionRolesSql_ViewerColumnGrants_MatchTheCSharpAclExactly() + { + var granted = ParseColumnGrants(StripComments(ReadProvisionRolesSql()), ViewerRole); + + /* Every secret-bearing config table the C# carves must be carved in the shipped script too — no + missing table (its secret columns would stay readable through the blanket schema grant) and no + extra table (a stale carve on a table the C# no longer restricts). */ + Assert.Equal( + DarlingManagedRoles.ViewerRestrictedConfigTables.Select(a => a.Table).OrderBy(t => t, StringComparer.Ordinal), + granted.Keys.OrderBy(t => t, StringComparer.Ordinal)); + + foreach (var acl in DarlingManagedRoles.ViewerRestrictedConfigTables) + { + var sqlColumns = granted[acl.Table]; + + /* A column listed twice would pass a set comparison while reading as an editing mistake. */ + Assert.Equal(sqlColumns.Count, sqlColumns.Distinct(StringComparer.Ordinal).Count()); + + /* THE assertion: set-equality in BOTH directions. A column in C# but not the .sql is the #1639 + bug (a BYO viewer seat is denied a read a managed seat serves); a column in the .sql but not + C# is the opposite — a BYO viewer reading something managed mode deliberately withholds. */ + Assert.Equal( + acl.NonSecretColumns.OrderBy(c => c, StringComparer.Ordinal), + sqlColumns.OrderBy(c => c, StringComparer.Ordinal)); + } + } + + [Fact] + public void ProvisionRolesSql_EveryCarvedTable_HasThePairedRevoke() + { + var sql = StripComments(ReadProvisionRolesSql()); + + /* The carve is blanket-grant, then REVOKE, then GRANT-columns. Without the REVOKE the blanket schema + grant still stands and the column GRANT is a no-op ADDITION — the script would LOOK carved while + viewer read every secret column. That makes the statement ORDER load-bearing, so pin it. */ + var blanketGrantAt = sql.IndexOf($"GRANT SELECT ON ALL TABLES IN SCHEMA {ConfigSchema}", StringComparison.Ordinal); + Assert.True(blanketGrantAt >= 0, "provision-roles.sql must still grant the blanket config SELECT the carve narrows."); + + var revoked = TableRevoke.Matches(sql) + .Where(m => string.Equals(m.Groups["role"].Value, ViewerRole, StringComparison.Ordinal)) + .Select(m => m.Groups["table"].Value) + .ToHashSet(StringComparer.Ordinal); + + foreach (var acl in DarlingManagedRoles.ViewerRestrictedConfigTables) + { + Assert.Contains(acl.Table, revoked); + + /* The REVOKE must come AFTER the blanket grant (else it revokes nothing) and BEFORE the column + GRANT (else it strips the very carve it was meant to enable). */ + var revokeAt = sql.IndexOf($"REVOKE SELECT ON {ConfigSchema}.{acl.Table} FROM {ViewerRole};", StringComparison.Ordinal); + var grantAt = sql.IndexOf($"ON {ConfigSchema}.{acl.Table} TO {ViewerRole};", StringComparison.Ordinal); + Assert.True(revokeAt > blanketGrantAt && grantAt > revokeAt, + $"provision-roles.sql must REVOKE viewer's table-wide SELECT on {acl.Table} after the blanket config grant and before re-granting the non-secret columns."); + + /* The carve is viewer-only: admin writes these tables and keeps its table-wide SELECT. */ + Assert.DoesNotContain($"REVOKE SELECT ON {ConfigSchema}.{acl.Table} FROM {AdminRole}", sql, StringComparison.Ordinal); + } + } + + [Fact] + public void ProvisionRolesSql_GrantsNoSecretColumn_ToAnyRole() + { + var sql = StripComments(ReadProvisionRolesSql()); + + /* Belt over the per-table set comparison: no credential blob, webhook URL, or Authorization-header + column may appear in ANY column grant in the shipped script, whatever role it names. */ + var everyGrantedColumn = ColumnGrant.Matches(sql) + .SelectMany(m => SplitColumns(m.Groups["columns"].Value)) + .ToHashSet(StringComparer.Ordinal); + + foreach (var secret in DarlingManagedRoles.ViewerRestrictedConfigTables.SelectMany(a => a.SecretColumns)) + { + Assert.DoesNotContain(secret, everyGrantedColumn); + } + } + + [Fact] + public void ProvisionRolesSql_ProvisionsAdminAndViewerOnly_NeverTheMcpRole() + { + var sql = StripComments(ReadProvisionRolesSql()); + + /* Managed mode applies the SAME carve to a third role, mcp (BuildViewerColumnAclSql(config, "mcp")), + and the shipped script deliberately does NOT mirror that: the network MCP endpoint is + managed-mode-only, so a BYO operator's own PostgreSQL governs any MCP exposure it wants (see the + DarlingManagedRoles summary and the BYO section of Darling/README.md). Pin the asymmetry as + DELIBERATE, so a future reader closing "drift" does not add a half-provisioned mcp role: the only + correct fix if BYO ever gains one is to add its roles AND its carve together. */ + Assert.DoesNotMatch(new Regex($@"\b{McpRole}\b", RegexOptions.IgnoreCase), sql); + + /* Correspondingly, every column-level grant in the script names the viewer role. */ + foreach (Match match in ColumnGrant.Matches(sql)) + { + Assert.Equal(ViewerRole, match.Groups["role"].Value); + Assert.Equal(ConfigSchema, match.Groups["schema"].Value); + } + } + + [Fact] + public void ParsedViewerAcl_Comparison_FailsOnAnInjectedDrift() + { + /* Guard the guard. A drift test that cannot fail is worthless, and the ways this one could silently + stop working (a reformatted GRANT, a renamed/moved file, a regex that matches nothing) all look + identical to "no drift". Mutate the real file's text three ways and assert the SAME comparison the + tests above run reports each one. */ + var real = StripComments(ReadProvisionRolesSql()); + var acl = DarlingManagedRoles.ViewerRestrictedConfigTables[0]; + var droppedColumn = acl.NonSecretColumns[^1]; + + Assert.True(MatchesCSharpAcl(real), "The unmutated provision-roles.sql must match the C# ACL."); + + /* 1. The #1639 shape: the C# list gains a column the .sql never got. */ + Assert.False( + MatchesCSharpAcl(MutateGrantColumns(real, acl.Table, + columns => columns.Where(c => !string.Equals(c, droppedColumn, StringComparison.Ordinal)))), + $"Removing {acl.Table}.{droppedColumn} from the .sql must be detected as drift."); + + /* 2. The reverse: the .sql grants a column the C# list does not classify as non-secret. */ + Assert.False( + MatchesCSharpAcl(MutateGrantColumns(real, acl.Table, columns => columns.Append("not_a_real_column"))), + "A column granted in the .sql but absent from the C# list must be detected as drift."); + + /* 3. A whole carve deleted (the table falls back to the blanket schema grant — secrets exposed). */ + Assert.False(MatchesCSharpAcl(ColumnGrant.Replace(real, m => + string.Equals(m.Groups["table"].Value, acl.Table, StringComparison.Ordinal) ? string.Empty : m.Value)), + $"Deleting the {acl.Table} column grant entirely must be detected as drift."); + } + + /* ─────────────────────────── parsing helpers ─────────────────────────── */ + + /// + /// The shipped script, copied beside the test binary by the csproj (Link="Fixtures\provision-roles.sql") + /// so the test parses the REAL file with no hardcoded repo path and no second copy that could go stale. + /// A missing file FAILS rather than skipping — a guard that quietly disappears is the thing being guarded against. + /// + private static string ReadProvisionRolesSql() + { + var path = Path.Combine(AppContext.BaseDirectory, "Fixtures", "provision-roles.sql"); + Assert.True(File.Exists(path), + $"provision-roles.sql was not copied to the test output ({path}) — check the None/Link item in Darling.Tests.csproj."); + return File.ReadAllText(path); + } + + /// + /// Blanks -- line comments so a GRANT quoted in prose is never parsed as DDL, quote-aware so a + /// -- inside a string literal (the RAISE EXCEPTION messages) is left alone. The script uses only + /// line comments; the assertion below fails loud if a block comment is ever introduced, rather than + /// letting this under-strip. + /// + private static string StripComments(string sql) + { + Assert.DoesNotContain("/*", sql, StringComparison.Ordinal); + + var kept = new List(); + foreach (var line in sql.Split('\n')) + { + var inQuote = false; + var cut = -1; + for (var i = 0; i < line.Length; i++) + { + if (line[i] == '\'') + { + inQuote = !inQuote; + } + else if (!inQuote && line[i] == '-' && i + 1 < line.Length && line[i + 1] == '-') + { + cut = i; + break; + } + } + + kept.Add(cut < 0 ? line : line[..cut]); + } + + return string.Join("\n", kept); + } + + /// + /// Every column-level SELECT grant to , keyed by table. Repeated grants on one + /// table are UNIONed because that is what Postgres does with them (grants are additive), so splitting a + /// list across two statements reads the same to this test as writing it as one. + /// + private static Dictionary> ParseColumnGrants(string sql, string role) => + ColumnGrant.Matches(sql) + .Where(m => string.Equals(m.Groups["role"].Value, role, StringComparison.Ordinal)) + .GroupBy(m => m.Groups["table"].Value, StringComparer.Ordinal) + .ToDictionary( + g => g.Key, + g => (IReadOnlyList)g.SelectMany(m => SplitColumns(m.Groups["columns"].Value)).ToList(), + StringComparer.Ordinal); + + private static List SplitColumns(string columnList) => + columnList.Split(',') + .Select(c => c.Trim()) + .Where(c => c.Length > 0) + .ToList(); + + /// + /// The one comparison the real assertions and the meta-test share: does this .sql text's viewer carve + /// match table-for-table, column-for-column? + /// + private static bool MatchesCSharpAcl(string sql) + { + var granted = ParseColumnGrants(sql, ViewerRole); + if (granted.Count != DarlingManagedRoles.ViewerRestrictedConfigTables.Count) + { + return false; + } + + foreach (var acl in DarlingManagedRoles.ViewerRestrictedConfigTables) + { + if (!granted.TryGetValue(acl.Table, out var sqlColumns) || + !sqlColumns.ToHashSet(StringComparer.Ordinal).SetEquals(acl.NonSecretColumns)) + { + return false; + } + } + + return true; + } + + /// + /// Rewrites one table's GRANT column list — the mutation engine for the meta-test. Removing a column is + /// the exact shape of the #1639 drift; adding one is its mirror image. + /// + private static string MutateGrantColumns( + string sql, string table, Func, IEnumerable> mutate) => + ColumnGrant.Replace(sql, m => + { + if (!string.Equals(m.Groups["table"].Value, table, StringComparison.Ordinal)) + { + return m.Value; + } + + var columns = mutate(SplitColumns(m.Groups["columns"].Value)); + return $"GRANT SELECT ({string.Join(", ", columns)}) ON {ConfigSchema}.{table} TO {m.Groups["role"].Value};"; + }); +} diff --git a/Darling/tools/provision-roles.sql b/Darling/tools/provision-roles.sql index 99a6cc3c1..ebe68fa0b 100644 --- a/Darling/tools/provision-roles.sql +++ b/Darling/tools/provision-roles.sql @@ -83,17 +83,24 @@ 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 +-- config_notification's SMTP password/username + the Teams/Slack/generic webhook URLs and the generic +-- channel's headers (a webhook URL is a bearer secret, and generic_headers carries the Authorization +-- token itself). 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). +-- +-- SOURCE OF TRUTH: DarlingManagedRoles.ViewerRestrictedConfigTables (the C# list managed mode builds its +-- identical carve from). These three GRANT lists are a HAND MIRROR of it, and hand mirrors drift -- they +-- did, silently, for three releases (see #1639). The ungated ProvisionRolesAclDriftTests parses THIS FILE +-- and asserts set-equality against that C# list, so adding a column to one side without the other now +-- fails the build. Keep the columns in the C# list's order so the two read as the same list. 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) + is_enabled, created_at, modified_at, alert_delivery_mode_override) 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, @@ -101,7 +108,8 @@ GRANT SELECT (command_id, created_at, requested_by, command_type, target_server_ 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) + email_cooldown_minutes, teams_proxy, slack_proxy, modified_at, + generic_body_template, generic_proxy) ON config.config_notification TO viewer; -- 3. config writes -- admin only. From 0263fdbe30b9f1a99aa7b95f3000e358f3f7b9ac Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:16:16 -0400 Subject: [PATCH 044/296] Darling: V32 server-tags schema + the viewer's schema-probe arm Groundwork for fleet tagging. Two new config-plane tables mirroring the V31 custom_views precedent: config.server_tags (a tag tree via a parent_id self-reference) and config.server_tag_map (many-to-many assignment, composite PK so a server can carry any number of tags). Membership deliberately lives in its own table rather than as a column on config_monitored_servers, which would have required all four of: an edit to the fail-closed ViewerRestrictedConfigTables column ACL (an unlisted column is invisible, breaking the read-only viewer's entire sidebar read); a matching hand edit to BYO provision-roles.sql; a silent widening of the network-reachable mcp role, whose write grant on that table is TABLE-level; and a full service reload per tag write, since trg_bump_monitored_servers is statement-level. Per-parent name uniqueness is a unique index over COALESCE(parent_id, 0), not a plain UNIQUE (parent_id, name) -- Postgres treats NULLs as distinct, so the plain form would permit any number of root tags sharing a name. No FK on server_tag_map.server_id: the sidebar falls back to the observed servers registry on a pre-seed store, so it can legitimately show ids absent from config_monitored_servers. Also adds the schema-probe arm the version gate needs. The gate is a SENTINEL probe, not a version read, so bumping StorageVersion without adding a matching EXISTS sentinel and map arm would leave a fully-migrated store reading as the previous version -- the viewer would refuse to connect, permanently, and deploying the service first would not help. Darling.Tests 2959 passed / 0 failed / 147 gated-live skipped. Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/DarlingCustomViewsTests.cs | 10 ++- .../DarlingObservabilityTests.cs | 7 +- .../Darling.Tests/ViewerDataServiceTests.cs | 46 +++++++------ .../PgMigrations.cs | 67 +++++++++++++++++++ .../StorageVersion.cs | 2 +- .../ViewerDataService.cs | 18 ++++- 6 files changed, 118 insertions(+), 32 deletions(-) diff --git a/Darling/Darling.Tests/DarlingCustomViewsTests.cs b/Darling/Darling.Tests/DarlingCustomViewsTests.cs index 295dc7c5d..58b374ef2 100644 --- a/Darling/Darling.Tests/DarlingCustomViewsTests.cs +++ b/Darling/Darling.Tests/DarlingCustomViewsTests.cs @@ -52,10 +52,14 @@ public void V31_IsRegistered_ConfigQualified_JsonbIdentity_NoBumpTrigger() } [Fact] - public void StorageVersion_IsV31_AndTracksTheLastMigration() + public void V31_IsTheCustomViewsMigration_AndStorageVersionTracksTheLastOne() { - Assert.Equal(31, StorageVersion.SchemaVersion); - Assert.Equal(31, PgMigrations.Scripts[^1].Version); + /* This file's concern is that V31 IS custom-views; newer migrations simply append after it. + The global "StorageVersion == the last script" invariant is asserted version-agnostically so a + future migration does not have to churn this test (ScaffoldTests pins the same rule, and + DarlingObservabilityTests carries the full index->version ladder). */ + Assert.Equal(31, PgMigrations.Scripts[30].Version); + Assert.Equal(StorageVersion.SchemaVersion, PgMigrations.Scripts[^1].Version); } /* ── ValidateDefinition: the authority ── */ diff --git a/Darling/Darling.Tests/DarlingObservabilityTests.cs b/Darling/Darling.Tests/DarlingObservabilityTests.cs index 5951344a5..63ee1b4e4 100644 --- a/Darling/Darling.Tests/DarlingObservabilityTests.cs +++ b/Darling/Darling.Tests/DarlingObservabilityTests.cs @@ -32,9 +32,9 @@ public sealed class DarlingObservabilityTests private const int TestServerId = -424242; [Fact] - public void MigrationScripts_ThirtyOneVersions_V30WebDashboardConfig_V31CustomViews() + public void MigrationScripts_ThirtyTwoVersions_V31CustomViews_V32ServerTags() { - Assert.Equal(31, PgMigrations.Scripts.Count); + Assert.Equal(32, PgMigrations.Scripts.Count); Assert.Equal(1, PgMigrations.Scripts[0].Version); Assert.Equal(2, PgMigrations.Scripts[1].Version); Assert.Equal(3, PgMigrations.Scripts[2].Version); @@ -66,7 +66,8 @@ public void MigrationScripts_ThirtyOneVersions_V30WebDashboardConfig_V31CustomVi Assert.Equal(29, PgMigrations.Scripts[28].Version); Assert.Equal(30, PgMigrations.Scripts[29].Version); Assert.Equal(31, PgMigrations.Scripts[30].Version); - Assert.Equal(31, StorageVersion.SchemaVersion); + Assert.Equal(32, PgMigrations.Scripts[31].Version); + Assert.Equal(32, StorageVersion.SchemaVersion); /* V26 (#1506) adds the generic webhook channel's four columns to the V17 control-plane table. Schema-qualified config.* and IF NOT EXISTS, per the file's additive-ALTER idiom. */ diff --git a/Darling/Darling.Tests/ViewerDataServiceTests.cs b/Darling/Darling.Tests/ViewerDataServiceTests.cs index 51465d580..c1ccc355a 100644 --- a/Darling/Darling.Tests/ViewerDataServiceTests.cs +++ b/Darling/Darling.Tests/ViewerDataServiceTests.cs @@ -404,27 +404,28 @@ public void StoreSchemaProbeSql_ProbesInformationSchema_ForTheV17ToV25Sentinels( } [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 + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, 32)] // fully migrated V32 (config.server_tags, fleet tags) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, false, 31)] // V31: config.custom_views present, server_tags not yet (#1563) + [InlineData(true, true, true, true, true, true, true, true, true, true, true, true, true, true, false, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, false, 20)] // pre-V21: no default_trace_events + [InlineData(true, true, true, false, 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, false, 18)]// pre-V19: no analysis_state + [InlineData(true, false, 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, 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) + 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, bool hasServerTags, int expected) { Assert.Equal(expected, ViewerDataService.MapProbedSchemaVersion( - hasConfigControlPlane, hasAlertDeliveryOverride, hasAnalysisState, hasAlertTuningKnobs, hasDefaultTraceEvents, hasIndexObjectStatsLatestIndex, hasCollectionLogHypertableOrPlainPg, hasJobHistory, hasAgentStatus, hasGenericWebhook, hasDeadlocksDatabaseName, hasQueryStoreReplicaRole, hasLongQueryCompletions, hasWebDashboardConfig, hasCustomViews)); + hasConfigControlPlane, hasAlertDeliveryOverride, hasAnalysisState, hasAlertTuningKnobs, hasDefaultTraceEvents, hasIndexObjectStatsLatestIndex, hasCollectionLogHypertableOrPlainPg, hasJobHistory, hasAgentStatus, hasGenericWebhook, hasDeadlocksDatabaseName, hasQueryStoreReplicaRole, hasLongQueryCompletions, hasWebDashboardConfig, hasCustomViews, hasServerTags)); } [Fact] @@ -434,7 +435,7 @@ public void MapProbedSchemaVersion_V23CompositeIsGatedBehindV22_NotAStandaloneTo 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)); + Assert.Equal(21, ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, false, true, false, false, false, false, false, false, false, false, false)); } [Fact] @@ -444,11 +445,12 @@ public void RequiredStoreSchemaVersion_TracksTheBuildSchemaVersion_AndTheProbeCo 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. */ + future migration bumps StorageVersion, this fails until a matching sentinel + map arm is added — + the guard against the probe silently under-reporting a newer store as skewed, which would make + the connect-time gate refuse to open the viewer against a perfectly healthy store. */ Assert.Equal( ViewerDataService.RequiredStoreSchemaVersion, - ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true)); + ViewerDataService.MapProbedSchemaVersion(true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true)); } } diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs index 15eb31a5d..3444e098d 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs @@ -75,6 +75,7 @@ public Migration(int version, string name, string sql) new Migration(29, "long-query-completions-collector", V29Sql), new Migration(30, "web-dashboard-config", V30Sql), new Migration(31, "custom-views-table", V31Sql), + new Migration(32, "server-tags", V32Sql), }; /// @@ -403,6 +404,72 @@ updated_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC'), updated_by text );"; + /// + /// V32 — the Viewer's fleet TAGS, the user-authored visual organization of a large server list + /// (~100 servers at the motivating field site). Two NEW config-plane tables, added additively exactly + /// like V31: server_tags is the tag tree, server_tag_map is the many-to-many assignment. + /// + /// Tags, not exclusive groups. A server may carry any number of tags, so the composite + /// PK (server_id, tag_id) on the map is the whole membership rule — deliberately NOT a unique + /// constraint on server_id, which is what an exclusive-group model would have needed. + /// + /// Membership lives in its own table, never as a column on + /// config_monitored_servers. A column there would have required all four of: an edit to the + /// fail-closed ViewerRestrictedConfigTables column ACL (an unlisted column is invisible, which + /// breaks the read-only viewer's ENTIRE sidebar read, not just the new field); a matching hand edit to + /// BYO tools/provision-roles.sql; a silent widening of the network-reachable mcp role, + /// whose INSERT/UPDATE/DELETE on that table is TABLE-level and so would cover a new column it cannot + /// even SELECT (a blind write); and — because trg_bump_monitored_servers is a STATEMENT-level + /// trigger — a full service ReloadFromStoreAsync per tag write, up to one per server on a bulk + /// tag of 100. A side table avoids all four. It also survives the server-identity move + /// (server_id is derived from host+database+read_only_intent, so editing any of those + /// upserts-new-then-deletes-old), which a column on the server row would not. + /// + /// Nesting. parent_id is a self-reference; NULL = a root tag. Depth is capped + /// app-side at 4 levels — Postgres cannot express that without a trigger, and the tag table is tiny + /// (dozens of rows), so the Viewer loads it whole, builds the tree in memory, and checks both the cap + /// and cycle-freedom there. No recursive CTE, no ltree extension, no closure table. + /// ON DELETE CASCADE on the self-reference means deleting a tag removes its whole subtree and + /// (via the map's own cascade) those assignments — the folder mental model. It can never reach + /// config_monitored_servers: there is no FK to it, so no server row, credential blob, or + /// collected history is touched. The Viewer still warns with the descendant + assignment counts. + /// + /// Uniqueness is per-parent so prod/primaries and staging/primaries coexist. + /// It is a UNIQUE INDEX over COALESCE(parent_id, 0) rather than a plain UNIQUE + /// (parent_id, name), because Postgres treats NULLs as DISTINCT — two ROOT tags both named + /// 'Prod' would otherwise both be accepted. Identity starts at 1, so 0 is never a real id. + /// lower(name) makes it case-insensitive, matching how the rest of the product treats + /// operator-entered names. + /// + /// NO config_bump_version trigger, same reasoning as V31: tags feed the VIEWER's + /// sidebar, never the collector/service loop, so there is nothing for the service to reload and a + /// beacon bump would only cost a needless fleet reconcile. id is + /// GENERATED ALWAYS AS IDENTITY so INSERTs need no sequence USAGE grant, and both tables ride + /// the existing ALTER DEFAULT PRIVILEGES ... IN SCHEMA config — no explicit grant is added, and + /// no ViewerRestrictedConfigTables carve is needed because neither table has a secret + /// column. + /// + private const string V32Sql = @" +CREATE TABLE IF NOT EXISTS config.server_tags ( + id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL, + parent_id integer REFERENCES config.server_tags(id) ON DELETE CASCADE, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamp NOT NULL DEFAULT (now() AT TIME ZONE 'UTC') +); + +CREATE UNIQUE INDEX IF NOT EXISTS ux_server_tags_parent_name + ON config.server_tags (COALESCE(parent_id, 0), lower(name)); + +CREATE TABLE IF NOT EXISTS config.server_tag_map ( + server_id integer NOT NULL, + tag_id integer NOT NULL REFERENCES config.server_tags(id) ON DELETE CASCADE, + PRIMARY KEY (server_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS idx_server_tag_map_tag + ON config.server_tag_map (tag_id);"; + /// /// V9 — the FinOps copy-parity fields that were user-input config or previously live-only: /// server_properties gains the three inventory columns the shared ServerPropertiesCollector now diff --git a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs index eaaa28829..a75c5ea2a 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/StorageVersion.cs @@ -16,5 +16,5 @@ namespace PerformanceMonitor.Darling.Storage; /// public static class StorageVersion { - public const int SchemaVersion = 31; + public const int SchemaVersion = 32; } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs index d02431ec9..94941ad8a 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.cs @@ -392,7 +392,8 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') 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')"; + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'custom_views'), + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'server_tags')"; /// The store schema version this viewer build requires — the highest migration it knows /// (). The connect-time gate blocks a store below this. @@ -413,7 +414,7 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') 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 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), reader.GetBoolean(15)); } return null; @@ -438,8 +439,19 @@ OR NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') /// is unit-tested without a live store; a schema bump past 29 trips the pinning test that keeps this in step /// with . /// - 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) + 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, bool hasServerTags) { + /* V32 (fleet tags): engine-agnostic table-existence sentinel, newest-first arm. + config.server_tags exists only at V32 or later. Adding a migration WITHOUT adding an arm here + is the trap this ladder exists to prevent: the probe would cap at the previous version, so a + fully-migrated store would still read as older than RequiredStoreSchemaVersion and the + connect-time gate would refuse to open the viewer — permanently, and deploying the service + first would not help. */ + if (hasServerTags) + { + return 32; + } + /* V31 (#1563 custom views): engine-agnostic table-existence sentinel, newest-first arm. config.custom_views exists only at V31 or later. */ if (hasCustomViews) From ed40ec2bdde19d7b5e21af1b288377712e948a5e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:18:49 -0400 Subject: [PATCH 045/296] Darling viewer: separate the fleet from the bound list (FleetView) Groundwork for fleet tagging, and a latent-bug fix in its own right. ServerList.ItemsSource WAS the fleet: ten call sites read it back with `as IEnumerable` to answer "what servers exist?" -- the alert-badge painter, the Overview builder, the --open-server deep link, the Overview card and Needs-Attention click handlers, the schedule editor, the favourites re-sort. That is correct only while the bound list is the COMPLETE list. The moment the sidebar shows a subset (a tag filter, a search) or a mixed row type (tag headers above servers), every one of those consumers silently answers the wrong question: badges stop painting for filtered-out servers, a card click for a hidden server resolves to nothing, and the schedule editor receives a truncated fleet. None of it throws. FleetView owns the authoritative set (All) and the projection the sidebar binds (Visible), WPF-free so the rules are unit-testable. All ten consumers now read _fleet.All; no `ServerList.ItemsSource is/as` read remains in the viewer. Two latent bugs fixed on the way, both of which would only have surfaced once filtering landed: - `ServerList.SelectedIndex = 0` replaced by ResolveSelection, which resolves by SERVER ID. With tag rows in the list, row 0 is a header, so the index fallback would have selected a non-server on every load and SyncAggregateServerSelectors would have silently stopped running. - ReapplyFavoritesToServerList assigned a freshly sorted list straight to ItemsSource behind the model's back -- exactly how the bound list and the fleet drift apart. It now re-sorts through the model and rebinds once. Filtering has a single seam (FleetView.Rebuild), so tags/search/database filters land there without touching a consumer again. Behaviour is unchanged: with no filters, Visible is the whole fleet. Darling.Tests 2966 passed / 0 failed / 147 gated-live skipped (7 new). Co-Authored-By: Claude Opus 4.8 --- Darling/Darling.Tests/FleetViewTests.cs | 119 ++++++++++++++++++ .../PgMigrations.cs | 14 ++- .../FleetView.cs | 110 ++++++++++++++++ .../MainWindow.AlertBadges.cs | 11 +- .../MainWindow.PlanViewer.cs | 2 +- .../MainWindow.ServerManagement.cs | 23 ++-- .../MainWindow.xaml.cs | 52 ++++---- 7 files changed, 282 insertions(+), 49 deletions(-) create mode 100644 Darling/Darling.Tests/FleetViewTests.cs create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/FleetView.cs diff --git a/Darling/Darling.Tests/FleetViewTests.cs b/Darling/Darling.Tests/FleetViewTests.cs new file mode 100644 index 000000000..e3372c383 --- /dev/null +++ b/Darling/Darling.Tests/FleetViewTests.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.Linq; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins , the seam that separates "the whole fleet" from "what the sidebar is +/// currently showing". +/// +/// Before it, ServerList.ItemsSource WAS the fleet, and ten call sites read it back to answer +/// "what servers exist?" — the alert-badge painter, the Overview builder, the deep-link resolver, the +/// card click handlers, the schedule editor. That is correct only while the bound list is complete. Once +/// the sidebar shows a filtered subset (or gains tag rows), every one of those consumers silently answers +/// the wrong question: badges stop painting for hidden servers, a card click resolves to nothing, the +/// schedule editor receives a truncated fleet. Nothing throws — it just quietly misbehaves, which is why +/// the rules live here, WPF-free and asserted, rather than in a window. +/// +public sealed class FleetViewTests +{ + private static DarlingServer Server(int id, string name) => + new(id, name, name, isEnabled: true, sqlMajorVersion: 16); + + private static FleetView FleetOf(params DarlingServer[] servers) + { + var fleet = new FleetView(); + fleet.SetAll(servers); + return fleet; + } + + [Fact] + public void SetAll_PublishesTheWholeFleetAndProjectsIt() + { + var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02")); + + Assert.Equal(new[] { 1, 2 }, fleet.All.Select(s => s.ServerId)); + Assert.Equal(2, fleet.TotalCount); + + /* Step 1 is deliberately behaviour-neutral: no filters exist yet, so the projection is identity + and nothing may report itself as filtered. */ + Assert.Equal(new[] { 1, 2 }, fleet.Visible.Select(s => s.ServerId)); + Assert.Equal(2, fleet.VisibleCount); + Assert.False(fleet.IsFiltered); + } + + [Fact] + public void SetAll_RaisesProjectionChanged_SoTheWindowRebindsOnce() + { + var fleet = new FleetView(); + var raised = 0; + fleet.ProjectionChanged += (_, _) => raised++; + + fleet.SetAll(new[] { Server(1, "SQL01") }); + + Assert.Equal(1, raised); + } + + [Fact] + public void Find_ResolvesAgainstTheWholeFleet_NotTheProjection() + { + var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02")); + + /* The load-bearing rule: a deep link, an Overview card click, or an acknowledge must reach a + server even when the sidebar is not showing it. Asserted through the public surface every + consumer uses, so a future filter cannot quietly narrow it. */ + Assert.Equal("SQL02", fleet.Find(2)!.ServerName); + Assert.Null(fleet.Find(999)); + } + + [Fact] + public void ResolveSelection_KeepsThePreviousServer_WhenItSurvives() + { + var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02"), Server(3, "SQL03")); + + /* Resolved by id, not index, so a re-sort (favourites) or a rename does not move the selection. */ + Assert.Equal(3, fleet.ResolveSelection(previousServerId: 3)!.ServerId); + } + + [Fact] + public void ResolveSelection_FallsBackToTheFirstVisibleServer() + { + var fleet = FleetOf(Server(7, "SQL07"), Server(8, "SQL08")); + + /* A server that no longer exists (removed) falls back to the first VISIBLE server — never to + index 0 of the bound list, which becomes a tag header once tag rows share it. */ + Assert.Equal(7, fleet.ResolveSelection(previousServerId: 999)!.ServerId); + Assert.Equal(7, fleet.ResolveSelection(previousServerId: null)!.ServerId); + } + + [Fact] + public void ResolveSelection_IsNullWhenThereIsNothingToSelect() + { + var fleet = FleetOf(); + + Assert.Null(fleet.ResolveSelection(previousServerId: null)); + Assert.Null(fleet.ResolveSelection(previousServerId: 1)); + } + + [Fact] + public void SetAll_ReplacesRatherThanAccumulates() + { + var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02")); + + fleet.SetAll(new[] { Server(3, "SQL03") }); + + /* A reload after a remove must not leave the deleted servers resolvable — otherwise a stale id + keeps opening tabs for a server that is gone. */ + Assert.Equal(new[] { 3 }, fleet.All.Select(s => s.ServerId)); + Assert.Null(fleet.Find(1)); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs index 3444e098d..91b3d64cb 100644 --- a/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs +++ b/Darling/PerformanceMonitor.Darling.Storage/PgMigrations.cs @@ -444,10 +444,16 @@ updated_by text /// NO config_bump_version trigger, same reasoning as V31: tags feed the VIEWER's /// sidebar, never the collector/service loop, so there is nothing for the service to reload and a /// beacon bump would only cost a needless fleet reconcile. id is - /// GENERATED ALWAYS AS IDENTITY so INSERTs need no sequence USAGE grant, and both tables ride - /// the existing ALTER DEFAULT PRIVILEGES ... IN SCHEMA config — no explicit grant is added, and - /// no ViewerRestrictedConfigTables carve is needed because neither table has a secret - /// column. + /// GENERATED ALWAYS AS IDENTITY so INSERTs need no sequence USAGE grant. No explicit grant is + /// added: both tables are picked up by the blanket GRANT ... ON ALL TABLES IN SCHEMA config + /// statements that provisioning re-runs on EVERY service start. Note it is those, not + /// ALTER DEFAULT PRIVILEGES, that cover a table introduced by a migration — ADP only applies to + /// objects created after it runs, and provisioning runs AFTER the migration pass. No + /// ViewerRestrictedConfigTables carve is needed either, because neither table has a secret + /// column — but note the corollary: a table in config is readable by the network-reachable + /// mcp role by default, so if tag names may carry customer identifiers the REVOKE must be + /// emitted inside provisioning AFTER that blanket grant (and mirrored into BYO + /// tools/provision-roles.sql), or the next service start silently re-grants it. /// private const string V32Sql = @" CREATE TABLE IF NOT EXISTS config.server_tags ( diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FleetView.cs b/Darling/PerformanceMonitor.Darling.Viewer/FleetView.cs new file mode 100644 index 000000000..5190bb2da --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FleetView.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.Collections.Generic; +using System.Linq; + +namespace PerformanceMonitor.Darling.Viewer; + +/// +/// Owns the fleet: the AUTHORITATIVE full server set () and the subset currently shown +/// in the sidebar (). +/// +/// Why this exists. Before this class, ServerList.ItemsSource WAS the fleet: ten call +/// sites read it back with as IEnumerable<DarlingServer> to answer "what servers exist?" — +/// the alert-badge painter, the Overview builder, the deep-link resolver, the card click handlers, the +/// schedule editor. That is fine only while the bound list is the complete list. The moment the sidebar +/// shows a SUBSET (a tag filter, a search) or a MIXED row type (tag headers above servers), every one of +/// those consumers silently answers the wrong question — badges stop painting for filtered-out servers, +/// the schedule editor receives a truncated fleet, and a card click for a hidden server resolves to +/// nothing. None of that throws; it just quietly misbehaves. +/// +/// So the two ideas are separated permanently: ask "does this server exist / give +/// me the whole fleet", and bind to the list. Free of any WPF dependency, so the +/// projection and lookup rules are unit-testable without a window. +/// +/// Step 1 is deliberately behaviour-neutral: is every server, in the +/// order handed in. Filtering (tags, search, database) slots into alone — the one +/// seam — so no consumer has to change again when it lands. +/// +internal sealed class FleetView +{ + private List _all = new(); + private List _visible = new(); + + /// Every known server, regardless of what the sidebar is currently showing. This is what a + /// consumer asking "what is in the fleet?" must read — never the bound list. + public IReadOnlyList All => _all; + + /// The servers the sidebar shows, after filtering. Bound to the list control. + public IReadOnlyList Visible => _visible; + + /// Total fleet size — the M in "showing N of M". + public int TotalCount => _all.Count; + + /// How many servers survive the current filter — the N in "showing N of M". + public int VisibleCount => _visible.Count; + + /// True when a filter is hiding at least one server, so the UI must say so rather than + /// letting a short list read as the whole fleet. + public bool IsFiltered => _visible.Count != _all.Count; + + /// Raised after is recomputed, so the window can rebind once. + public event EventHandler? ProjectionChanged; + + /// Replaces the fleet (a load, or a refresh that changed membership) and reprojects. + public void SetAll(IEnumerable servers) + { + ArgumentNullException.ThrowIfNull(servers); + + _all = servers.ToList(); + Rebuild(); + } + + /// + /// Recomputes from . The SINGLE place filtering will be + /// applied, so adding tag/search/database filters never touches a consumer or a call site again. + /// + public void Rebuild() + { + /* Step 1: identity projection — no filters exist yet, so Visible is the whole fleet in the order + it was handed in (already favourites-first sorted by the caller). */ + _visible = new List(_all); + + ProjectionChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// The server with this id, or null. Resolves against ON PURPOSE: a deep link, an + /// Overview card click, or a selection restore must still find a server that the current filter + /// happens to be hiding — otherwise the action silently does nothing, which is exactly the class of + /// bug this type exists to prevent. + /// + public DarlingServer? Find(int serverId) => _all.Find(s => s.ServerId == serverId); + + /// + /// The server to select after a rebuild: the previously-selected one if it still exists (by id, so a + /// renamed or re-sorted server still resolves), else the first VISIBLE server, else null when the + /// filter hides everything. Callers must not fall back to index 0 of the bound list — once tag rows + /// share that list, row 0 is a header, not a server. + /// + public DarlingServer? ResolveSelection(int? previousServerId) + { + if (previousServerId is int previous) + { + var kept = _visible.Find(s => s.ServerId == previous); + if (kept is not null) + { + return kept; + } + } + + return _visible.Count > 0 ? _visible[0] : null; + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.AlertBadges.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.AlertBadges.cs index c6e2203d1..f88039cdf 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.AlertBadges.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.AlertBadges.cs @@ -46,8 +46,9 @@ private void UpdateServerAttention(IReadOnlyList rows) { var states = ServerAttentionDeriver.Derive(rows); - var servers = (ServerList.ItemsSource as IEnumerable)?.ToList() - ?? new List(); + /* The WHOLE fleet, not the bound list: a badge must keep updating for a server the sidebar filter + is currently hiding, or hiding a tag would silently stop surfacing its alerts. */ + var servers = _fleet.All; foreach (var server in servers) { @@ -171,10 +172,8 @@ private void AcknowledgeServer(int serverId) { _alertStateService.AcknowledgeAlert(serverId); - if (ServerList.ItemsSource is IEnumerable servers) - { - servers.FirstOrDefault(s => s.ServerId == serverId)?.SetAttention(0, false, null); - } + /* Resolved against the whole fleet so acknowledging works for a filtered-out server too. */ + _fleet.Find(serverId)?.SetAttention(0, false, null); if (_openServerTabs.TryGet(serverId, out var tab) && tab.Header is StackPanel header) { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs index 810e5a193..7fd27edc9 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.PlanViewer.cs @@ -87,7 +87,7 @@ private void MainWindowPlanViewerClose_Click(object sender, RoutedEventArgs e) /* 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 servers && servers.Any()) + if (_fleet.All.Count > 0) { foreach (TabItem item in MainTabs.Items) { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs index 72af79cb4..a89534d20 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs @@ -66,22 +66,19 @@ private static List SortWithFavorites(List servers /// Re-stamps favorites on the currently-shown rows and re-sorts, preserving the selection. private void ReapplyFavoritesToServerList() { - if (ServerList.ItemsSource is not IEnumerable current) - { - return; - } - - foreach (var s in current) + /* Favourite pins are a property of the SERVER, so stamp them across the whole fleet — not just + whatever the sidebar currently shows. */ + foreach (var s in _fleet.All) { 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; - } + /* Re-sort through the model and rebind its projection once, rather than assigning a fresh list to + ItemsSource behind the model's back (which is how the bound list and the fleet drifted apart). */ + var selected = (ServerList.SelectedItem as DarlingServer)?.ServerId; + _fleet.SetAll(SortWithFavorites(_fleet.All.ToList())); + ServerList.ItemsSource = _fleet.Visible; + ServerList.SelectedItem = _fleet.ResolveSelection(selected); } /// @@ -108,7 +105,7 @@ private async Task RefreshServerStatusAsync() } var nowUtc = DateTime.UtcNow; - var servers = (ServerList.ItemsSource as IEnumerable)?.ToList() ?? new List(); + var servers = _fleet.All; DateTime? newest = null; diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs index cb5a20951..4cab5073f 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs @@ -68,6 +68,11 @@ public partial class MainWindow : Window /// private readonly OpenServerTabRegistry _openServerTabs = new(); + /// The fleet: the authoritative full server set plus the filtered subset the sidebar shows. + /// Consumers asking "what servers exist?" must read _fleet.All, never the bound list — see + /// for why reading ItemsSource back is the bug this replaces. + private readonly FleetView _fleet = new(); + /// /// 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 's @@ -258,9 +263,10 @@ never double-seeds the service's darling.json seed nor resurrects a removed serv 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 loaded) + if (openServer is not null) { - var match = loaded.FirstOrDefault(s => + /* Against the whole fleet: a deep link must open a server the current filter is hiding. */ + var match = _fleet.All.FirstOrDefault(s => string.Equals(s.ServerName, openServer, StringComparison.OrdinalIgnoreCase)); if (match is not null) { @@ -640,8 +646,9 @@ private async Task LoadServersAsync(bool preserveSelection = false) 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}"; + _fleet.SetAll(servers); + ServerList.ItemsSource = _fleet.Visible; + ServerCountText.Text = $"Servers: {_fleet.TotalCount}"; /* 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. @@ -670,18 +677,12 @@ private async Task LoadServersAsync(bool preserveSelection = false) { /* 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; - } + back to the first server; the initial load selects the first. + + Resolved by SERVER, never by index: once tag rows share this list, row 0 is a header + rather than a server, so a SelectedIndex = 0 fallback would select a non-server and the + aggregate-tab sync would silently never run. */ + ServerList.SelectedItem = _fleet.ResolveSelection(preserveSelection ? previousSelection : null); } else { @@ -964,7 +965,8 @@ private async Task LoadOverviewAsync() return; } - if (ServerList.ItemsSource is not IEnumerable servers) + var servers = _fleet.All; + if (servers.Count == 0) { OverviewItemsControl.ItemsSource = null; FleetRollupContainer.Visibility = Visibility.Collapsed; @@ -1044,10 +1046,10 @@ private void ApplyFleetRollup(FleetRollup rollup) private void OverviewCard_MouseLeftButtonDown(object sender, MouseButtonEventArgs e) { if (e.ClickCount == 2 - && sender is FrameworkElement { DataContext: ServerSummaryItem summary } - && ServerList.ItemsSource is IEnumerable servers) + && sender is FrameworkElement { DataContext: ServerSummaryItem summary }) { - var server = servers.FirstOrDefault(s => s.ServerId == summary.ServerId); + /* Resolved against the whole fleet, so a card click still opens a filtered-out server. */ + var server = _fleet.Find(summary.ServerId); if (server is not null) { OpenServerTab(server); @@ -1062,10 +1064,9 @@ private void OverviewCard_MouseLeftButtonDown(object sender, MouseButtonEventArg /// private void FleetWorstServer_Click(object sender, MouseButtonEventArgs e) { - if (sender is FrameworkElement { DataContext: FleetRankedServer ranked } - && ServerList.ItemsSource is IEnumerable servers) + if (sender is FrameworkElement { DataContext: FleetRankedServer ranked }) { - var server = servers.FirstOrDefault(s => s.ServerId == ranked.ServerId); + var server = _fleet.Find(ranked.ServerId); if (server is not null) { OpenServerTab(server); @@ -1328,8 +1329,9 @@ private async void OpenInActiveQueries_Click(object sender, RoutedEventArgs e) 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) ?? Array.Empty(); - var settings = new SettingsWindow(_preferences, _appSettingsStore, _dataService, servers) { Owner = this }; + /* The schedule editor edits CONFIG, so it must see every server — a filtered sidebar must never + mean you cannot edit the schedule of a server it happens to be hiding. */ + var settings = new SettingsWindow(_preferences, _appSettingsStore, _dataService, _fleet.All) { Owner = this }; if (settings.ShowDialog() == true && settings.Result is not null) { _preferences = settings.Result; From 9f5f7f8930f109e590c924fd45b9bb1dedd33204 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:22:05 -0400 Subject: [PATCH 046/296] Darling viewer: fleet-tag data layer (read/write) + schema pins The tag store surface for V32: flat reads of config.server_tags and config.server_tag_map (the tree is assembled in memory -- the table is dozens of rows, so no recursive CTE, no ltree, no closure table), plus create / rename / reparent / delete and bulk assign / unassign. Direct writes, not the config_command queue: the viewer's default seat is admin and it already writes config_monitored_servers directly. The queue exists for imperative actions only the SERVICE can perform, and tags are store state the service never reads -- routing them through it would add an enqueue-and-poll round trip per edit for nothing. Writes go through ExecuteWriteAsync so a read-only seat degrades to ViewerReadOnlyException instead of a raw 42501. The file documents why the fix for such a 42501 is the UI gate and never a config_command grant: the enqueuer picks the command type freely and the executor runs as superuser. Bulk assign is ONE statement (unnest + ON CONFLICT DO NOTHING) -- tagging 100 servers must not be 100 round trips. ClearServerTagsAsync exists because server_id is a deterministic hash of host+database+read-only-intent, so a removed-then- re-added server gets the SAME id and orphaned map rows would silently resurrect its old tags. Pins cover the parts that would fail quietly: the COALESCE(parent_id, 0) unique index (a plain UNIQUE would not dedupe ROOT tags at all, since Postgres treats NULLs as distinct), that the tag cascade can never reach config_monitored_servers, that the map carries no server FK (the sidebar can show ids absent from the config table on a pre-seed store), that there is no reload-beacon trigger, and that bulk assign stays a single statement. Darling.Tests 2974 passed / 0 failed / 147 gated-live skipped (8 new). Co-Authored-By: Claude Opus 4.8 --- .../Darling.Tests/DarlingServerTagsTests.cs | 125 +++++++++++ .../ViewerDataService.ServerTags.cs | 208 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 Darling/Darling.Tests/DarlingServerTagsTests.cs create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerTags.cs diff --git a/Darling/Darling.Tests/DarlingServerTagsTests.cs b/Darling/Darling.Tests/DarlingServerTagsTests.cs new file mode 100644 index 000000000..4d2a855c2 --- /dev/null +++ b/Darling/Darling.Tests/DarlingServerTagsTests.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.Linq; +using PerformanceMonitor.Darling.Storage; +using PerformanceMonitor.Darling.Viewer; +using Xunit; + +namespace Darling.Tests; + +/// +/// Pins the V32 fleet-tag schema and the viewer's tag SQL. Ungated — pure shape assertions. +/// +public sealed class DarlingServerTagsTests +{ + private static string V32Sql => + PgMigrations.Scripts.Single(s => s.Version == 32).Sql; + + [Fact] + public void V32_IsRegisteredLast_AndSchemaQualified_AndIdempotent() + { + var v32 = PgMigrations.Scripts.Single(s => s.Version == 32); + + Assert.Equal("server-tags", v32.Name); + Assert.Equal(32, PgMigrations.Scripts[^1].Version); + Assert.Equal(StorageVersion.SchemaVersion, v32.Version); + + /* config.-QUALIFIED: the migrate session runs under search_path = collect, config, public, so an + unqualified CREATE TABLE would land in collect — the wrong schema AND the wrong ACL. */ + Assert.Contains("CREATE TABLE IF NOT EXISTS config.server_tags", V32Sql, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE IF NOT EXISTS config.server_tag_map", V32Sql, StringComparison.Ordinal); + Assert.DoesNotContain("CREATE TABLE IF NOT EXISTS server_tags", V32Sql, StringComparison.Ordinal); + + /* Idempotent idioms throughout — the runner skips applied versions, but the repo treats + IF NOT EXISTS as mandatory anyway. */ + Assert.Contains("CREATE UNIQUE INDEX IF NOT EXISTS", V32Sql, StringComparison.Ordinal); + Assert.Contains("CREATE INDEX IF NOT EXISTS", V32Sql, StringComparison.Ordinal); + } + + [Fact] + public void V32_RootTagNamesAreDeduped_ViaCoalesceNotAPlainUnique() + { + /* The subtle one. A plain UNIQUE (parent_id, lower(name)) would NOT dedupe ROOT tags at all: + parent_id is NULL for roots and Postgres treats NULLs as DISTINCT, so any number of root tags + named 'prod' would insert. COALESCE(parent_id, 0) makes roots comparable; identity starts at 1, + so 0 is never a real id. */ + Assert.Contains("COALESCE(parent_id, 0)", V32Sql, StringComparison.Ordinal); + Assert.Contains("lower(name)", V32Sql, StringComparison.Ordinal); + } + + [Fact] + public void V32_TagTreeCascades_ButNeverReachesTheServerRegistry() + { + /* Deleting a tag removes its subtree and those assignments. It must never be able to reach + config_monitored_servers: there is no FK to it, so no server row, credential blob, or collected + history can be cascaded away by a tag delete. */ + Assert.Contains("REFERENCES config.server_tags(id) ON DELETE CASCADE", V32Sql, StringComparison.Ordinal); + Assert.DoesNotContain("config_monitored_servers", V32Sql, StringComparison.Ordinal); + } + + [Fact] + public void V32_MapIsManyToMany_AndCarriesNoServerFk() + { + /* Composite PK = a server may carry any number of tags (tags, not exclusive groups). */ + Assert.Contains("PRIMARY KEY (server_id, tag_id)", V32Sql, StringComparison.Ordinal); + + /* Deliberately NO FK on server_id: GetManagedServersAsync falls back to the observed servers + registry on a pre-seed store, so the sidebar can legitimately show ids absent from + config_monitored_servers, and an FK would reject tagging them. The cleanup is owned explicitly + by the remove-server path instead. */ + Assert.DoesNotContain("server_id integer NOT NULL REFERENCES", V32Sql, StringComparison.Ordinal); + } + + [Fact] + public void V32_HasNoReloadBeaconTrigger() + { + /* The service consumes nothing about tags — they feed the viewer's sidebar. A config_bump_version + trigger would force a full fleet reconcile on every tag edit (V31 custom_views precedent). */ + Assert.DoesNotContain("config_bump_version", V32Sql, StringComparison.Ordinal); + Assert.DoesNotContain("CREATE TRIGGER", V32Sql, StringComparison.Ordinal); + } + + [Fact] + public void BulkAssign_IsASingleStatement_NotARoundTripPerServer() + { + /* Tagging 100 servers must be one statement. A row-per-server loop is the difference between an + instant bulk tag and 100 round trips. */ + Assert.Contains("unnest($1)", ViewerDataService.ServerTagAssignSql, StringComparison.Ordinal); + Assert.Contains("ON CONFLICT DO NOTHING", ViewerDataService.ServerTagAssignSql, StringComparison.Ordinal); + Assert.Contains("= ANY($1)", ViewerDataService.ServerTagUnassignSql, StringComparison.Ordinal); + } + + [Fact] + public void RemovingAServer_HasAWayToClearItsTags_SoAReAddCannotResurrectThem() + { + /* server_id is a deterministic hash of host+database+read-only-intent, so a removed-then-re-added + server gets the SAME id. Orphaned map rows would silently restore its old tags. */ + Assert.Contains("DELETE FROM server_tag_map WHERE server_id = $1", ViewerDataService.ServerTagClearForServerSql, StringComparison.Ordinal); + } + + [Fact] + public void TagSql_UsesBareTableNames_MatchingEveryOtherConfigWrite() + { + /* Bare names resolve through the connection search_path to config, the same as + config_monitored_servers. Schema-qualifying here would diverge from every sibling write. */ + foreach (var sql in new[] + { + ViewerDataService.ServerTagsSelectSql, + ViewerDataService.ServerTagMapSelectSql, + ViewerDataService.ServerTagInsertSql, + ViewerDataService.ServerTagRenameSql, + ViewerDataService.ServerTagReparentSql, + ViewerDataService.ServerTagDeleteSql, + }) + { + Assert.DoesNotContain("config.server_tag", sql, StringComparison.Ordinal); + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerTags.cs b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerTags.cs new file mode 100644 index 000000000..0fe28e130 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ViewerDataService.ServerTags.cs @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part 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; +using Npgsql; + +namespace PerformanceMonitor.Darling.Viewer; + +/// One tag row. ParentId null = a root tag. +public sealed record DarlingTag(int Id, string Name, int? ParentId, int SortOrder); + +/// One server-to-tag assignment. Many-to-many: a server carries any number of tags. +public sealed record DarlingTagAssignment(int ServerId, int TagId); + +/// +/// The fleet-tag store surface (V32 config.server_tags + config.server_tag_map): the +/// viewer's user-authored visual organization of a large server list. +/// +/// Direct writes, not the command queue. The viewer's default seat is admin, which +/// holds write on the config tables, and it already writes config_monitored_servers directly. The +/// config_command queue exists for imperative actions only the SERVICE can perform — reaching a +/// monitored SQL Server, flipping service state — and tags are store state the service never reads, so +/// routing them through the queue would buy nothing and cost an enqueue-and-poll round trip per edit. +/// Every write goes through , so a read-only viewer seat degrades +/// to rather than a raw 42501. +/// +/// Never grant the queue as a workaround. If a read-only seat trips 42501 here, the fix is +/// the UI gate, NOT GRANT INSERT ON config.config_command — the enqueuer picks the command type +/// freely and the executor runs as superuser, so that grant is full service control. +/// +/// Bare table names resolve through the connection's search_path to the config schema, the +/// same as every other config write in this service. The tree itself is assembled IN MEMORY from these +/// flat rows (the table is dozens of rows) — no recursive CTE, no ltree, no closure table — and +/// the depth cap and cycle checks live there too. +/// +public sealed partial class ViewerDataService +{ + /// Every tag, flat. The caller builds the tree. Ordered by name so sibling order is stable + /// without a reordering UI. + public const string ServerTagsSelectSql = + "SELECT id, name, parent_id, sort_order FROM server_tags ORDER BY name"; + + /// Every server-to-tag assignment, flat. + public const string ServerTagMapSelectSql = + "SELECT server_id, tag_id FROM server_tag_map"; + + /// Creates a tag. $1 name, $2 parent_id (NULL = root). Returns the new id. A duplicate name + /// under the same parent violates the per-parent unique index and surfaces as 23505. + public const string ServerTagInsertSql = + "INSERT INTO server_tags (name, parent_id) VALUES ($1, $2) RETURNING id"; + + /// Renames a tag. $1 id, $2 name. + public const string ServerTagRenameSql = + "UPDATE server_tags SET name = $2 WHERE id = $1"; + + /// Reparents a tag. $1 id, $2 new parent_id (NULL = promote to root). The caller must have + /// already rejected cycles and depth-cap violations against its in-memory tree. + public const string ServerTagReparentSql = + "UPDATE server_tags SET parent_id = $2 WHERE id = $1"; + + /// Deletes a tag. $1 id. The self-referencing FK cascades to descendants and the map cascades + /// to their assignments — it can never reach config_monitored_servers, so no server, credential + /// or collected history is touched. Callers still refuse the delete when the tag has children, and + /// offer to move them first, rather than silently removing a subtree. + public const string ServerTagDeleteSql = + "DELETE FROM server_tags WHERE id = $1"; + + /// Whether a tag has child tags. $1 id. Read immediately before a delete, because the tag + /// tables are SHARED and this viewer's snapshot may be up to one refresh interval stale. + public const string ServerTagHasChildrenSql = + "SELECT EXISTS (SELECT 1 FROM server_tags WHERE parent_id = $1)"; + + /// Assigns a tag to many servers in ONE statement. $1 server ids, $2 tag id. Bulk by design: + /// tagging 100 servers row-by-row would be 100 round trips. Already-assigned servers are no-ops. + public const string ServerTagAssignSql = + "INSERT INTO server_tag_map (server_id, tag_id) SELECT unnest($1), $2 ON CONFLICT DO NOTHING"; + + /// Removes a tag from many servers in one statement. $1 server ids, $2 tag id. + public const string ServerTagUnassignSql = + "DELETE FROM server_tag_map WHERE server_id = ANY($1) AND tag_id = $2"; + + /// Drops every tag assignment for a server. $1 server_id. Called when a server is REMOVED: + /// server_id is a deterministic hash of host+database+read-only-intent, so leaving orphaned map + /// rows behind would make a removed-then-re-added server silently RESURRECT its old tags. + public const string ServerTagClearForServerSql = + "DELETE FROM server_tag_map WHERE server_id = $1"; + + /// Reads every tag, flat, for the in-memory tree build. + public async Task> GetServerTagsAsync(CancellationToken cancellationToken = default) + { + var tags = new List(); + await using var command = _dataSource.CreateCommand(ServerTagsSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + tags.Add(new DarlingTag( + reader.GetInt32(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetInt32(2), + reader.GetInt32(3))); + } + + return tags; + } + + /// Reads every server-to-tag assignment, flat. + public async Task> GetServerTagAssignmentsAsync(CancellationToken cancellationToken = default) + { + var assignments = new List(); + await using var command = _dataSource.CreateCommand(ServerTagMapSelectSql); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + while (await reader.ReadAsync(cancellationToken)) + { + assignments.Add(new DarlingTagAssignment(reader.GetInt32(0), reader.GetInt32(1))); + } + + return assignments; + } + + /// Creates a tag and returns its id. + public async Task CreateServerTagAsync(string name, int? parentId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagInsertSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = name }); + command.Parameters.Add(parentId is int parent + ? new NpgsqlParameter { TypedValue = parent } + : new NpgsqlParameter { Value = System.DBNull.Value }); + + try + { + return (int)(await command.ExecuteScalarAsync(cancellationToken))!; + } + catch (PostgresException ex) when (ex.SqlState == InsufficientPrivilegeSqlState) + { + throw new ViewerReadOnlyException(ex); + } + } + + /// Renames a tag. + public async Task RenameServerTagAsync(int tagId, string name, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagRenameSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = tagId }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = name }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// Moves a tag under a new parent (null = promote to root). Cycle and depth-cap rejection is + /// the caller's job, against its in-memory tree. + public async Task ReparentServerTagAsync(int tagId, int? newParentId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagReparentSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = tagId }); + command.Parameters.Add(newParentId is int parent + ? new NpgsqlParameter { TypedValue = parent } + : new NpgsqlParameter { Value = System.DBNull.Value }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// Whether a tag currently has children, read fresh from the store. + public async Task ServerTagHasChildrenAsync(int tagId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagHasChildrenSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = tagId }); + return (bool)(await command.ExecuteScalarAsync(cancellationToken))!; + } + + /// Deletes a tag. + public async Task DeleteServerTagAsync(int tagId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagDeleteSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = tagId }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// Assigns one tag to many servers in a single statement. + public async Task AssignServerTagAsync(IReadOnlyList serverIds, int tagId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagAssignSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = System.Linq.Enumerable.ToArray(serverIds) }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = tagId }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// Removes one tag from many servers in a single statement. + public async Task UnassignServerTagAsync(IReadOnlyList serverIds, int tagId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagUnassignSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = System.Linq.Enumerable.ToArray(serverIds) }); + command.Parameters.Add(new NpgsqlParameter { TypedValue = tagId }); + await ExecuteWriteAsync(command, cancellationToken); + } + + /// Drops every tag assignment for a removed server, so a re-add cannot resurrect old tags. + public async Task ClearServerTagsAsync(int serverId, CancellationToken cancellationToken = default) + { + await using var command = _dataSource.CreateCommand(ServerTagClearForServerSql); + command.Parameters.Add(new NpgsqlParameter { TypedValue = serverId }); + await ExecuteWriteAsync(command, cancellationToken); + } +} From 7632f675623d2b6d1f61689ec852e96663625f3f Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:28:06 -0400 Subject: [PATCH 047/296] Darling viewer: fleet-tag sidebar tree (render, CRUD menus, bulk dialog) Builds the viewer front end on the parked V32 server-tags data layer. - FleetView projects a mixed header/server tree. Tags are opt-in: the sidebar stays a flat server list until the first tag exists. Favorites pinned on top, Untagged at the bottom, a multi-tagged server appears under each tag, nesting is capped at 4 levels, and the projection is cycle / dangling-parent safe so a hand-edited store cannot hang it. - Sidebar renders headers vs servers via FleetRowTemplateSelector with a depth indent; the ported server-row bindings now go through FleetServerRow.Server. - Server row right-click gains a checkable "Assign Tags" submenu; a tag header's right-click does New / New Child / Rename / Delete (disabled on the Favorites/Untagged pseudo-groups); "Manage Tags" opens a bulk editor (tag tree with create/rename/delete/nest + a searchable per-tag server checklist with add-all / remove-all). - Expand/collapse state persists across restarts (viewer preferences). - 28 FleetViewTests cover the projection and the persistence round-trip; full Darling suite green (2987 passed). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + Darling/Darling.Tests/FleetViewTests.cs | 317 ++++++++++- .../FleetRow.cs | 172 ++++++ .../FleetRowTemplateSelector.cs | 53 ++ .../FleetView.cs | 313 ++++++++-- .../MainWindow.ServerManagement.cs | 4 +- .../MainWindow.Tags.cs | 430 ++++++++++++++ .../MainWindow.xaml | 110 +++- .../MainWindow.xaml.cs | 58 +- .../ManageTagsWindow.xaml | 109 ++++ .../ManageTagsWindow.xaml.cs | 534 ++++++++++++++++++ .../TagNameDialog.xaml | 33 ++ .../TagNameDialog.xaml.cs | 47 ++ .../ViewerPreferences.cs | 8 + 14 files changed, 2097 insertions(+), 93 deletions(-) create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/FleetRow.cs create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/FleetRowTemplateSelector.cs create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/MainWindow.Tags.cs create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/ManageTagsWindow.xaml create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/ManageTagsWindow.xaml.cs create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/TagNameDialog.xaml create mode 100644 Darling/PerformanceMonitor.Darling.Viewer/TagNameDialog.xaml.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index de0effda2..e3f1cc9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Darling Web: composed panels read the CAGG rollups for old windows instead of truncating at the raw horizon** ([#1625]) - the read side of the retention tiers (#1623): once the 4-day raw drop runs, a Custom Views panel looking back further than 4 days would show only 4 days (the older history lives in the hourly/daily CAGGs the composer never read). The compiler now routes by the window's oldest-point AGE - recent -> raw, hour-age -> the hourly CAGG (21d), day-age -> the daily CAGG (indefinite) - and rewrites the measure's aggregate to the CAGG's pre-aggregated columns (`SUM(delta_x)` -> `SUM(x_sum)`, `AVG` -> `SUM(x_sum)/SUM(sample_count)` exactly, Sum-ratios mapping both operands). Age-based (not display grain, not window span) so Ranked/Scalar panels and historical windows route correctly; the display grain clamps up to the tier's grain since a rollup can't render finer than it was materialized. Covers query_stats, procedure_stats AND Query Store (whose execution-weighted mean reconstructs exactly from #1624's reshaped weighted sums, validated on 159M live executions); only query_stats `object_name` (a #1568 module join) falls back to raw. Every existing panel is byte-for-byte unchanged inside the raw horizon. This unblocks activating retention at the field box - the raw drop coarsens old windows instead of truncating them. - **Darling Web: Query Store panels route past the 21-day hourly horizon (query_store_stats_daily)** ([#1626]) - completes the CAGG read-routing (#1625): adds the hierarchical `query_store_stats_daily` (sourced from the reshaped `query_store_stats_hourly`, same composer dims + weighted sums, kept indefinitely), so a QS Custom Views panel looking back past the hourly CAGG's 21-day retention routes to the daily rollup instead of capping. The mapper reads the daily's identical columns unchanged. Removes the last read-routing TIER gap; the one remaining dimensional exception, query_stats `object_name`, is closed by [#1627]. - **Darling Web: composed `object_name` panels on query_stats route to the CAGG rollups too — the last read-routing exception, closed** ([#1627]) - #1625/#1626 left exactly one dimension on raw: a Custom Views panel grouping query_stats by `object_name`. object_name is not a query_stats column - it is stitched read-time from procedure_stats via `sql_handle` (the #1568 module join) - so those panels fell back to raw and truncated at the 4-day horizon while every other dimension reached the 21-day/indefinite rollups. Closed by carrying `sql_handle` into the query_stats hourly + daily CAGGs and adding a retained `collect.module_map` (a compact `sql_handle -> object_name/database/schema` map refreshed at startup and daily from procedure_stats, so the mapping survives the raw 4-day drop). On a CAGG route the compiler now LEFT JOINs `module_map` for the attribution (the RAW path still uses the live window-bounded #1568 procedure_stats CTE, since raw procedure_stats is retained), so `object_name` resolves as `m.object_name` either way and object_name panels reach the hourly (21d) and daily (indefinite) tiers exactly like query_hash. The CAGG reshape self-heals in place - an existing `query_stats_hourly` without `sql_handle` is dropped (CASCADE takes `query_stats_daily`) and rebuilt by the ensure sweep, so a field box upgrading over #1621/#1623 CAGGs reshapes with no manual steps. Nothing composable now falls back to raw for lack of CAGG coverage. +- **Darling viewer: organize the fleet into nested tags** ([#1640]) - the sidebar server list can be grouped into user-authored tags (folders in the sidebar): a server carries any number of them and tags nest up to four levels. Fully opt-in - the list stays a plain flat list until the first tag is created, so an untagged viewer sees no change. Favourites float to a pinned group at the top (collapsing a tag can never hide a starred server) and an Untagged group always accounts for the rest of the fleet; a server carrying multiple tags appears under each. Assign inline from a server row's right-click "Assign Tags" submenu (checkable, indented by nesting), do tag CRUD from a tag header's right-click (New / New Child / Rename / Delete, disabled on the Favorites/Untagged pseudo-groups), or open "Manage Tags..." for a bulk editor - a tag tree with create/rename/delete/nest on the left and a searchable per-tag server checklist with Add-all / Remove-all on the right for first-time tagging of a large fleet. Expand/collapse state persists across restarts (viewer preferences). The projection is a WPF-free `FleetView` rendering headers + servers as one mixed list through a template selector, pinned by `FleetViewTests` (opt-in flat default, favourites/nesting/untagged, collapse hiding descendants, multi-tag duplication, selection skipping headers, and cycle / dangling-parent safety). Built on the V32 `server_tags` / `server_tag_map` config-plane tables. ### Changed @@ -552,6 +553,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#1636]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1636 [#1637]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1637 [#1639]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1639 +[#1640]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1640 [#1617]: https://github.com/erikdarlingdata/PerformanceMonitor/issues/1617 [#1621]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1621 [#1601]: https://github.com/erikdarlingdata/PerformanceMonitor/pull/1601 diff --git a/Darling/Darling.Tests/FleetViewTests.cs b/Darling/Darling.Tests/FleetViewTests.cs index e3372c383..23bec1cf0 100644 --- a/Darling/Darling.Tests/FleetViewTests.cs +++ b/Darling/Darling.Tests/FleetViewTests.cs @@ -6,6 +6,7 @@ * Licensed under the MIT License. See LICENSE file in the project root for full license information. */ +using System.Collections.Generic; using System.Linq; using PerformanceMonitor.Darling.Viewer; using Xunit; @@ -14,20 +15,30 @@ namespace Darling.Tests; /// /// Pins , the seam that separates "the whole fleet" from "what the sidebar is -/// currently showing". +/// currently showing" — now a projected tree of group headers and servers, not a flat list. /// -/// Before it, ServerList.ItemsSource WAS the fleet, and ten call sites read it back to answer -/// "what servers exist?" — the alert-badge painter, the Overview builder, the deep-link resolver, the -/// card click handlers, the schedule editor. That is correct only while the bound list is complete. Once -/// the sidebar shows a filtered subset (or gains tag rows), every one of those consumers silently answers -/// the wrong question: badges stop painting for hidden servers, a card click resolves to nothing, the -/// schedule editor receives a truncated fleet. Nothing throws — it just quietly misbehaves, which is why -/// the rules live here, WPF-free and asserted, rather than in a window. +/// Before it, ServerList.ItemsSource WAS the fleet, and call sites read it back to answer "what +/// servers exist?" — the alert-badge painter, the Overview builder, the deep-link resolver, the card +/// click handlers, the schedule editor. That is correct only while the bound list is complete. Once the +/// sidebar gains tag rows, every one of those consumers would silently answer the wrong question if it +/// read the bound list — badges stop painting for collapsed servers, a card click resolves to a header. +/// The rules live here, WPF-free and asserted, rather than in a window. /// public sealed class FleetViewTests { - private static DarlingServer Server(int id, string name) => - new(id, name, name, isEnabled: true, sqlMajorVersion: 16); + private static DarlingServer Server(int id, string name, bool favorite = false) + { + var server = new DarlingServer(id, name, name, isEnabled: true, sqlMajorVersion: 16) + { + IsFavorite = favorite + }; + return server; + } + + private static DarlingTag Tag(int id, string name, int? parentId = null, int sortOrder = 0) => + new(id, name, parentId, sortOrder); + + private static DarlingTagAssignment Assign(int serverId, int tagId) => new(serverId, tagId); private static FleetView FleetOf(params DarlingServer[] servers) { @@ -36,19 +47,25 @@ private static FleetView FleetOf(params DarlingServer[] servers) return fleet; } + private static IEnumerable Headers(FleetView fleet) => fleet.Visible.OfType(); + + private static IEnumerable ServerRows(FleetView fleet) => fleet.Visible.OfType(); + + // ── The whole-fleet invariants that predate tags ───────────────────────────────────────────────── + [Fact] - public void SetAll_PublishesTheWholeFleetAndProjectsIt() + public void SetAll_PublishesTheWholeFleet_AndProjectsAFlatListUntilTagsExist() { var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02")); Assert.Equal(new[] { 1, 2 }, fleet.All.Select(s => s.ServerId)); Assert.Equal(2, fleet.TotalCount); - /* Step 1 is deliberately behaviour-neutral: no filters exist yet, so the projection is identity - and nothing may report itself as filtered. */ - Assert.Equal(new[] { 1, 2 }, fleet.Visible.Select(s => s.ServerId)); - Assert.Equal(2, fleet.VisibleCount); - Assert.False(fleet.IsFiltered); + /* Tags are opt-in: with none defined the projection is a flat server list with no headers, exactly + as the sidebar looked before tags existed. */ + Assert.Empty(Headers(fleet)); + Assert.Equal(new[] { 1, 2 }, ServerRows(fleet).Select(r => r.Server.ServerId)); + Assert.All(fleet.Visible, r => Assert.Equal(0, r.Depth)); } [Fact] @@ -69,19 +86,30 @@ public void Find_ResolvesAgainstTheWholeFleet_NotTheProjection() var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02")); /* The load-bearing rule: a deep link, an Overview card click, or an acknowledge must reach a - server even when the sidebar is not showing it. Asserted through the public surface every - consumer uses, so a future filter cannot quietly narrow it. */ + server even when the sidebar is not showing it (collapsed under a tag). */ Assert.Equal("SQL02", fleet.Find(2)!.ServerName); Assert.Null(fleet.Find(999)); } + [Fact] + public void SetAll_ReplacesRatherThanAccumulates() + { + var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02")); + + fleet.SetAll(new[] { Server(3, "SQL03") }); + + Assert.Equal(new[] { 3 }, fleet.All.Select(s => s.ServerId)); + Assert.Null(fleet.Find(1)); + } + + // ── Selection through mixed rows ───────────────────────────────────────────────────────────────── + [Fact] public void ResolveSelection_KeepsThePreviousServer_WhenItSurvives() { var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02"), Server(3, "SQL03")); - /* Resolved by id, not index, so a re-sort (favourites) or a rename does not move the selection. */ - Assert.Equal(3, fleet.ResolveSelection(previousServerId: 3)!.ServerId); + Assert.Equal(3, fleet.ResolveSelection(previousServerId: 3)!.Server.ServerId); } [Fact] @@ -89,10 +117,8 @@ public void ResolveSelection_FallsBackToTheFirstVisibleServer() { var fleet = FleetOf(Server(7, "SQL07"), Server(8, "SQL08")); - /* A server that no longer exists (removed) falls back to the first VISIBLE server — never to - index 0 of the bound list, which becomes a tag header once tag rows share it. */ - Assert.Equal(7, fleet.ResolveSelection(previousServerId: 999)!.ServerId); - Assert.Equal(7, fleet.ResolveSelection(previousServerId: null)!.ServerId); + Assert.Equal(7, fleet.ResolveSelection(previousServerId: 999)!.Server.ServerId); + Assert.Equal(7, fleet.ResolveSelection(previousServerId: null)!.Server.ServerId); } [Fact] @@ -105,15 +131,244 @@ public void ResolveSelection_IsNullWhenThereIsNothingToSelect() } [Fact] - public void SetAll_ReplacesRatherThanAccumulates() + public void ResolveSelection_ReturnsAServerRow_NeverAHeader() { - var fleet = FleetOf(Server(1, "SQL01"), Server(2, "SQL02")); + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); - fleet.SetAll(new[] { Server(3, "SQL03") }); + /* Row 0 is now the Prod header; ResolveSelection must skip it and land on the server. */ + var selected = fleet.ResolveSelection(previousServerId: null); + Assert.NotNull(selected); + Assert.Equal(1, selected!.Server.ServerId); + } - /* A reload after a remove must not leave the deleted servers resolvable — otherwise a stale id - keeps opening tabs for a server that is gone. */ - Assert.Equal(new[] { 3 }, fleet.All.Select(s => s.ServerId)); - Assert.Null(fleet.Find(1)); + [Fact] + public void ResolveSelection_WhenThePreviousServerIsCollapsedAway_FallsBackToFirstVisible() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01"), Server(2, "SQL02") }); + fleet.SetTags( + new[] { Tag(10, "Prod"), Tag(20, "Staging") }, + new[] { Assign(1, 10), Assign(2, 20) }); + + fleet.ToggleExpanded(Headers(fleet).Single(h => h.Title == "Prod")); + + /* Server 1 is now hidden under a collapsed Prod; the resolver skips to the first VISIBLE server. */ + Assert.Equal(2, fleet.ResolveSelection(previousServerId: 1)!.Server.ServerId); + } + + // ── The tag tree projection ────────────────────────────────────────────────────────────────────── + + [Fact] + public void SetTags_ProjectsFavorites_ThenTags_ThenUntagged() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01", favorite: true), Server(2, "SQL02"), Server(3, "SQL03") }); + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10), Assign(2, 10) }); + + var headers = Headers(fleet).Select(h => (h.Kind, h.Title, h.ServerCount)).ToList(); + Assert.Equal( + new[] + { + (FleetGroupKind.Favorites, "Favorites", 1), + (FleetGroupKind.Tag, "Prod", 2), + (FleetGroupKind.Untagged, "Untagged", 1), + }, + headers); + } + + [Fact] + public void SetTags_FavoritesGroupAppearsOnlyWhenAFavoriteExists_AndAlsoUnderItsTag() + { + var withFavorite = new FleetView(); + withFavorite.SetAll(new[] { Server(1, "SQL01", favorite: true) }); + withFavorite.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); + + Assert.Contains(Headers(withFavorite), h => h.Kind == FleetGroupKind.Favorites); + /* The favourite appears in Favorites AND under Prod — the same duplication as any multi-tag. */ + Assert.Equal(2, ServerRows(withFavorite).Count(r => r.Server.ServerId == 1)); + + var noFavorite = new FleetView(); + noFavorite.SetAll(new[] { Server(1, "SQL01") }); + noFavorite.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); + + Assert.DoesNotContain(Headers(noFavorite), h => h.Kind == FleetGroupKind.Favorites); + } + + [Fact] + public void SetTags_ServerWithMultipleTags_AppearsUnderEachTag() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + fleet.SetTags( + new[] { Tag(10, "Prod"), Tag(20, "Staging") }, + new[] { Assign(1, 10), Assign(1, 20) }); + + Assert.Equal(2, ServerRows(fleet).Count(r => r.Server.ServerId == 1)); + /* A server carrying any tag is never "untagged". */ + Assert.DoesNotContain(Headers(fleet), h => h.Kind == FleetGroupKind.Untagged); + } + + [Fact] + public void SetTags_NestedTags_IndentByDepth_ChildTagsBeforeServers() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + fleet.SetTags( + new[] { Tag(10, "Prod"), Tag(11, "US", parentId: 10) }, + new[] { Assign(1, 11) }); + + var rows = fleet.Visible.ToList(); + var prod = rows.OfType().Single(h => h.Title == "Prod"); + var us = rows.OfType().Single(h => h.Title == "US"); + var server = rows.OfType().Single(r => r.Server.ServerId == 1); + + Assert.Equal(0, prod.Depth); + Assert.Equal(1, us.Depth); + Assert.Equal(2, server.Depth); + /* Child tags emit before the parent's own servers, so a nested header never appears below the + rows it contains. */ + Assert.True(rows.IndexOf(us) < rows.IndexOf(server)); + } + + [Fact] + public void SetTags_UntaggedGroup_HoldsOnlyServersWithNoAssignment() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01"), Server(2, "SQL02") }); + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); + + var untagged = Headers(fleet).Single(h => h.Kind == FleetGroupKind.Untagged); + Assert.Equal(1, untagged.ServerCount); + + /* Server 2 is the only untagged one; it appears under Untagged, not under Prod. */ + var prodIndex = fleet.Visible.ToList().FindIndex(r => r is FleetHeaderRow { Title: "Prod" }); + var untaggedIndex = fleet.Visible.ToList().FindIndex(r => r is FleetHeaderRow { Kind: FleetGroupKind.Untagged }); + Assert.True(prodIndex < untaggedIndex); + } + + [Fact] + public void EmptyTags_CollapsesBackToTheFlatList() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); + Assert.NotEmpty(Headers(fleet)); + + /* Deleting the last tag returns the sidebar to the plain server list. */ + fleet.SetTags(System.Array.Empty(), System.Array.Empty()); + + Assert.Empty(Headers(fleet)); + Assert.Equal(new[] { 1 }, ServerRows(fleet).Select(r => r.Server.ServerId)); + } + + // ── Expand / collapse ──────────────────────────────────────────────────────────────────────────── + + [Fact] + public void ToggleExpanded_Collapse_HidesDescendants_ButKeepsTheChevron() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01"), Server(2, "SQL02") }); + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10), Assign(2, 10) }); + + Assert.Equal(2, ServerRows(fleet).Count()); + + fleet.ToggleExpanded(Headers(fleet).Single(h => h.Title == "Prod")); + + var prod = Headers(fleet).Single(h => h.Title == "Prod"); + Assert.False(prod.IsExpanded); + Assert.True(prod.HasChildren); + Assert.Empty(ServerRows(fleet)); + } + + [Fact] + public void CollapseState_SurvivesADataReload_KeyedByGroupIdentity() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); + fleet.ToggleExpanded(Headers(fleet).Single(h => h.Title == "Prod")); + + /* A status refresh reloads tags/assignments; the collapse must persist (keyed by tag id, so a + rename would keep it too). */ + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); + + Assert.False(Headers(fleet).Single(h => h.Title == "Prod").IsExpanded); + } + + [Fact] + public void SetCollapsedKeys_RestoresPersistedExpandState() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + fleet.SetTags(new[] { Tag(10, "Prod") }, new[] { Assign(1, 10) }); + + fleet.SetCollapsedKeys(new[] { FleetGroupKey.ForTag(10) }); + + Assert.False(Headers(fleet).Single(h => h.Title == "Prod").IsExpanded); + Assert.Contains(FleetGroupKey.ForTag(10), fleet.CollapsedKeys); + } + + // ── Defensive: a hand-edited store must never lose servers or hang ──────────────────────────────── + + [Fact] + public void Rebuild_DanglingParent_SurfacesTheTagAsARoot_WithoutLosingItsServers() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + fleet.SetTags(new[] { Tag(11, "Orphan", parentId: 99) }, new[] { Assign(1, 11) }); + + var orphan = Headers(fleet).Single(h => h.Title == "Orphan"); + Assert.Equal(0, orphan.Depth); + Assert.Contains(ServerRows(fleet), r => r.Server.ServerId == 1); + } + + [Fact] + public void Rebuild_CyclicTags_Terminate_AndRenderEachTagOnce() + { + var fleet = new FleetView(); + fleet.SetAll(new[] { Server(1, "SQL01") }); + + /* A pure 10↔11 cycle: neither tag is a root, so without the unvisited-tag fallback nothing would + render and the server would vanish. */ + fleet.SetTags( + new[] { Tag(10, "A", parentId: 11), Tag(11, "B", parentId: 10) }, + new[] { Assign(1, 10) }); + + Assert.Equal(2, Headers(fleet).Count(h => h.Kind == FleetGroupKind.Tag)); + Assert.Contains(ServerRows(fleet), r => r.Server.ServerId == 1); + } + + // ── FleetGroupKey persistence round-trip (expand/collapse survives a restart) ───────────────────── + + [Theory] + [InlineData("Favorites")] + [InlineData("Untagged")] + public void FleetGroupKey_RoundTripsThePseudoGroups(string stored) + { + Assert.True(FleetGroupKey.TryParse(stored, out var key)); + Assert.Equal(stored, key.ToStorageString()); + } + + [Fact] + public void FleetGroupKey_RoundTripsATag() + { + var key = FleetGroupKey.ForTag(42); + + Assert.Equal("Tag:42", key.ToStorageString()); + Assert.True(FleetGroupKey.TryParse("Tag:42", out var parsed)); + Assert.Equal(key, parsed); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Bogus")] + [InlineData("Tag:")] + [InlineData("Tag:notanumber")] + public void FleetGroupKey_TryParse_RejectsUnrecognizedOrStaleForms(string? stored) + { + Assert.False(FleetGroupKey.TryParse(stored, out _)); } } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FleetRow.cs b/Darling/PerformanceMonitor.Darling.Viewer/FleetRow.cs new file mode 100644 index 000000000..27c7f931c --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FleetRow.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; + +namespace PerformanceMonitor.Darling.Viewer; + +/// +/// One rendered row in the fleet sidebar: either a group header () or a +/// server (). Deliberately a plain POCO with no WPF dependency, so +/// 's tree projection can be asserted from unit tests without a window — the same +/// reason itself is WPF-free. +/// +/// Rows are immutable snapshots the projection rebuilds wholesale: expanding or collapsing a header, +/// assigning a tag, or pinning a favourite re-runs and produces a fresh +/// list, rather than mutating a row in place. A ~100-row rebuild is trivial, and it keeps these types free +/// of change notification — the bound list is simply reassigned. +/// +internal abstract class FleetRow +{ + /// + /// Indent level for rendering: 0 = a root group header. A server sits one level deeper than the + /// header it appears under, and a nested tag one level deeper than its parent. The sidebar multiplies + /// this by a fixed step to produce the left margin. + /// + public int Depth { get; protected init; } +} + +/// +/// What a stands for. A real user carries a +/// ; the two synthetic groups the projection always frames the fleet with do not. +/// +internal enum FleetGroupKind +{ + /// A user-authored tag (possibly nested). is non-null. + Tag, + + /// The pinned-favourites group, floated to the top so a collapsed tag can never hide a + /// starred server. A favourite ALSO appears under each of its tags — the same duplication as any + /// multi-tagged server. + Favorites, + + /// The catch-all for servers carrying no tag, shown at the bottom so the tree always accounts + /// for the whole fleet. + Untagged +} + +/// +/// Stable identity for a group across rebuilds, so expand/collapse state survives reprojection. A real +/// tag keys on its id; the two pseudo-groups key on their alone (their +/// is unused and left zero). Deliberately a value type so it works as a dictionary / +/// set key. +/// +internal readonly record struct FleetGroupKey(FleetGroupKind Kind, int TagId) +{ + public static FleetGroupKey ForTag(int tagId) => new(FleetGroupKind.Tag, tagId); + public static readonly FleetGroupKey Favorites = new(FleetGroupKind.Favorites, 0); + public static readonly FleetGroupKey Untagged = new(FleetGroupKind.Untagged, 0); + + /// A stable, human-readable string for persisting this key in the viewer preferences. + public string ToStorageString() => Kind switch + { + FleetGroupKind.Favorites => "Favorites", + FleetGroupKind.Untagged => "Untagged", + _ => $"Tag:{TagId}" + }; + + /// Parses a value; false (and = default) + /// for null, empty, or an unrecognized/forward-version form. + public static bool TryParse(string? value, out FleetGroupKey key) + { + key = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + switch (value) + { + case "Favorites": + key = Favorites; + return true; + case "Untagged": + key = Untagged; + return true; + default: + if (value.StartsWith("Tag:", StringComparison.Ordinal) + && int.TryParse(value.AsSpan(4), out var id)) + { + key = ForTag(id); + return true; + } + + return false; + } + } +} + +/// +/// A group header row: a real tag, or the Favorites / Untagged pseudo-group. Clicking it expands or +/// collapses rather than selecting a server (see , which never +/// returns a header). +/// +internal sealed class FleetHeaderRow : FleetRow +{ + public FleetHeaderRow( + FleetGroupKind kind, + string title, + int depth, + int serverCount, + bool hasChildren, + bool isExpanded, + DarlingTag? tag = null) + { + Kind = kind; + Title = title; + Depth = depth; + ServerCount = serverCount; + HasChildren = hasChildren; + IsExpanded = isExpanded; + Tag = tag; + } + + /// Which kind of group this header stands for. + public FleetGroupKind Kind { get; } + + /// The backing tag when is ; null for the + /// Favorites and Untagged pseudo-groups. + public DarlingTag? Tag { get; } + + /// Display label: the tag name, or "Favorites" / "Untagged". + public string Title { get; } + + /// Servers shown directly under this header (its own directly-assigned servers; for the + /// pseudo-groups, the favourites or untagged count). Rendered as a "(n)" badge. + public int ServerCount { get; } + + /// True when the header has anything beneath it — child tags OR servers — so a disclosure + /// chevron is drawn and the row is expandable. A childless tag shows no chevron. + public bool HasChildren { get; } + + /// Whether the group is currently expanded. A collapsed header renders alone; its descendants + /// are omitted from the projection entirely. + public bool IsExpanded { get; } + + /// The stable key used to track this group's expand/collapse state across rebuilds. + public FleetGroupKey Key => Kind == FleetGroupKind.Tag + ? FleetGroupKey.ForTag(Tag?.Id ?? throw new InvalidOperationException("A Tag header must carry a tag.")) + : Kind == FleetGroupKind.Favorites + ? FleetGroupKey.Favorites + : FleetGroupKey.Untagged; +} + +/// A server row: one monitored server, indented under whichever group header precedes it. The +/// same server can appear in more than one when it carries multiple tags (or +/// is a favourite), which is the intended label-style model, not a bug. +internal sealed class FleetServerRow : FleetRow +{ + public FleetServerRow(DarlingServer server, int depth) + { + Server = server; + Depth = depth; + } + + /// The monitored server this row renders. + public DarlingServer Server { get; } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FleetRowTemplateSelector.cs b/Darling/PerformanceMonitor.Darling.Viewer/FleetRowTemplateSelector.cs new file mode 100644 index 000000000..2df18825b --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/FleetRowTemplateSelector.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.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; + +namespace PerformanceMonitor.Darling.Viewer; + +/// +/// Chooses the sidebar row template — a group header vs a server row — so one can +/// render the mixed projection. Set as ServerList.ItemTemplateSelector; the +/// two templates are supplied from XAML. +/// +internal sealed class FleetRowTemplateSelector : DataTemplateSelector +{ + /// Template for a (tag / Favorites / Untagged). + public DataTemplate? HeaderTemplate { get; set; } + + /// Template for a (the ported Lite server row). + public DataTemplate? ServerTemplate { get; set; } + + public override DataTemplate? SelectTemplate(object item, DependencyObject container) => item switch + { + FleetHeaderRow => HeaderTemplate, + FleetServerRow => ServerTemplate, + _ => base.SelectTemplate(item, container) + }; +} + +/// +/// Turns a into a left indent so nested tags and their servers step inward. +/// The step is deliberately small: with a 4-level nesting cap the deepest server indents ~5 steps, which +/// must still fit a narrow sidebar without truncating names. +/// +[ValueConversion(typeof(int), typeof(Thickness))] +internal sealed class FleetDepthToIndentConverter : IValueConverter +{ + private const double StepPixels = 14; + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => + new Thickness(value is int depth ? depth * StepPixels : 0, 0, 0, 0); + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/FleetView.cs b/Darling/PerformanceMonitor.Darling.Viewer/FleetView.cs index 5190bb2da..0727d1a8a 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/FleetView.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/FleetView.cs @@ -13,52 +13,62 @@ namespace PerformanceMonitor.Darling.Viewer; /// -/// Owns the fleet: the AUTHORITATIVE full server set () and the subset currently shown -/// in the sidebar (). +/// Owns the fleet: the AUTHORITATIVE full server set () and the projected rows the +/// sidebar renders () — now a mix of group headers and servers, not a flat server +/// list. /// -/// Why this exists. Before this class, ServerList.ItemsSource WAS the fleet: ten call +/// Why this exists. Before this class, ServerList.ItemsSource WAS the fleet: call /// sites read it back with as IEnumerable<DarlingServer> to answer "what servers exist?" — /// the alert-badge painter, the Overview builder, the deep-link resolver, the card click handlers, the /// schedule editor. That is fine only while the bound list is the complete list. The moment the sidebar -/// shows a SUBSET (a tag filter, a search) or a MIXED row type (tag headers above servers), every one of -/// those consumers silently answers the wrong question — badges stop painting for filtered-out servers, -/// the schedule editor receives a truncated fleet, and a card click for a hidden server resolves to -/// nothing. None of that throws; it just quietly misbehaves. +/// shows a SUBSET or a MIXED row type (tag headers above servers), every one of those consumers silently +/// answers the wrong question. So the two ideas are separated permanently: ask "does +/// this server exist / give me the whole fleet", and bind to the list. /// -/// So the two ideas are separated permanently: ask "does this server exist / give -/// me the whole fleet", and bind to the list. Free of any WPF dependency, so the -/// projection and lookup rules are unit-testable without a window. +/// Tags are opt-in. With no tags defined, is exactly the flat, ordered +/// server list it always was — a viewer that never touches tags sees no change. The grouped tree +/// (Favorites → tags → Untagged) appears only once the user creates their first tag. /// -/// Step 1 is deliberately behaviour-neutral: is every server, in the -/// order handed in. Filtering (tags, search, database) slots into alone — the one -/// seam — so no consumer has to change again when it lands. +/// Free of any WPF dependency — is a plain POCO — so the projection and +/// lookup rules are unit-testable without a window. Filtering (search, tag filter) will slot into +/// alone, the one seam, so no consumer changes again when it lands. /// internal sealed class FleetView { private List _all = new(); - private List _visible = new(); + private List _tags = new(); + + /// serverId → the set of tag ids assigned to it. Empty/absent = untagged. + private Dictionary> _serverTags = new(); + + /// The groups the user has collapsed. Absence = expanded (the friendly default), so a fresh + /// fleet shows everything. Keyed by so state survives reprojection and a + /// tag rename. + private readonly HashSet _collapsed = new(); + + private List _visible = new(); /// Every known server, regardless of what the sidebar is currently showing. This is what a /// consumer asking "what is in the fleet?" must read — never the bound list. public IReadOnlyList All => _all; - /// The servers the sidebar shows, after filtering. Bound to the list control. - public IReadOnlyList Visible => _visible; + /// The rows the sidebar shows: group headers and server rows after projection. Bound to the + /// list control; a fresh list each rebuild. + public IReadOnlyList Visible => _visible; - /// Total fleet size — the M in "showing N of M". + /// Total fleet size — what the "Servers: N" count reports. public int TotalCount => _all.Count; - /// How many servers survive the current filter — the N in "showing N of M". - public int VisibleCount => _visible.Count; - - /// True when a filter is hiding at least one server, so the UI must say so rather than - /// letting a short list read as the whole fleet. - public bool IsFiltered => _visible.Count != _all.Count; + /// The keys of the currently-collapsed groups, so the window can persist expand/collapse + /// state across sessions. + public IReadOnlyCollection CollapsedKeys => _collapsed; /// Raised after is recomputed, so the window can rebind once. public event EventHandler? ProjectionChanged; - /// Replaces the fleet (a load, or a refresh that changed membership) and reprojects. + /// Replaces the fleet (a load, or a refresh that changed membership) and reprojects. The + /// caller hands servers already favourites-first sorted; that order is preserved for the flat + /// (no-tags) projection and for the pseudo-groups. public void SetAll(IEnumerable servers) { ArgumentNullException.ThrowIfNull(servers); @@ -68,43 +78,268 @@ public void SetAll(IEnumerable servers) } /// - /// Recomputes from . The SINGLE place filtering will be - /// applied, so adding tag/search/database filters never touches a consumer or a call site again. + /// Replaces the tag forest and the server→tag assignments, then reprojects. Passing an empty tag list + /// collapses the view back to the flat server list (the opt-in default). Assignments referencing a + /// server not in are simply never rendered; the reverse index is rebuilt each call. + /// + public void SetTags(IEnumerable tags, IEnumerable assignments) + { + ArgumentNullException.ThrowIfNull(tags); + ArgumentNullException.ThrowIfNull(assignments); + + _tags = tags.ToList(); + + _serverTags = new Dictionary>(); + foreach (var a in assignments) + { + if (!_serverTags.TryGetValue(a.ServerId, out var set)) + { + set = new HashSet(); + _serverTags[a.ServerId] = set; + } + + set.Add(a.TagId); + } + + Rebuild(); + } + + /// Flips a group's expand/collapse state and reprojects. A snapshot-safe toggle: header rows + /// are immutable snapshots, so this keys on the group identity, not the row instance. + public void ToggleExpanded(FleetHeaderRow header) + { + ArgumentNullException.ThrowIfNull(header); + + var key = header.Key; + if (!_collapsed.Remove(key)) + { + _collapsed.Add(key); + } + + Rebuild(); + } + + /// Restores persisted expand/collapse state (the collapsed groups) and reprojects. + public void SetCollapsedKeys(IEnumerable keys) + { + ArgumentNullException.ThrowIfNull(keys); + + _collapsed.Clear(); + foreach (var k in keys) + { + _collapsed.Add(k); + } + + Rebuild(); + } + + private bool IsExpanded(FleetGroupKey key) => !_collapsed.Contains(key); + + /// + /// Recomputes from the fleet, the tag forest, and the collapse state — the + /// SINGLE place projection happens. Projection order: the Favorites pseudo-group (when any favourite + /// exists), the root tags depth-first (child tags before that tag's own servers), then the Untagged + /// pseudo-group (when any untagged server exists). A collapsed header emits itself and nothing + /// beneath it. A server carrying multiple tags appears under each — the intended label model. /// public void Rebuild() { - /* Step 1: identity projection — no filters exist yet, so Visible is the whole fleet in the order - it was handed in (already favourites-first sorted by the caller). */ - _visible = new List(_all); + var rows = new List(); + /* No tags anywhere → behaviour-neutral flat list, exactly as before tags existed. */ + if (_tags.Count == 0) + { + foreach (var s in _all) + { + rows.Add(new FleetServerRow(s, depth: 0)); + } + + _visible = rows; + ProjectionChanged?.Invoke(this, EventArgs.Empty); + return; + } + + /* Favorites float to the top so collapsing a tag can never hide a starred server. _all arrives + favourites-first + name sorted, so this preserves that order. Favourites still also appear + under their own tags below. */ + var favorites = _all.Where(s => s.IsFavorite).ToList(); + if (favorites.Count > 0) + { + var expanded = IsExpanded(FleetGroupKey.Favorites); + rows.Add(new FleetHeaderRow( + FleetGroupKind.Favorites, "Favorites", depth: 0, + serverCount: favorites.Count, hasChildren: true, isExpanded: expanded)); + + if (expanded) + { + foreach (var s in favorites) + { + rows.Add(new FleetServerRow(s, depth: 1)); + } + } + } + + /* Tag forest: parent id (null = root) → ordered children, and tag id → its directly-assigned + servers (favourites-first, then name). Assignment is to a specific tag, never inherited to + ancestors — a server tagged only on a child shows under that child, not its parent. */ + var childrenByParent = _tags + .Where(t => t.ParentId is not null) + .GroupBy(t => t.ParentId!.Value) + .ToDictionary( + g => g.Key, + g => g.OrderBy(t => t.SortOrder).ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase).ToList()); + + var knownTagIds = _tags.Select(t => t.Id).ToHashSet(); + + var serversByTag = new Dictionary>(); + foreach (var s in _all) + { + if (!_serverTags.TryGetValue(s.ServerId, out var tagIds)) + { + continue; + } + + foreach (var tid in tagIds) + { + if (!serversByTag.TryGetValue(tid, out var list)) + { + list = new List(); + serversByTag[tid] = list; + } + + list.Add(s); + } + } + + foreach (var list in serversByTag.Values) + { + list.Sort(CompareFavoriteThenName); + } + + /* A visited guard makes the DFS cycle-proof: CRUD forbids cycles and enforces the 4-level cap, + but a hand-edited store must not be able to hang the viewer. */ + var visited = new HashSet(); + + void Emit(DarlingTag tag, int depth) + { + if (!visited.Add(tag.Id)) + { + return; + } + + var expanded = IsExpanded(FleetGroupKey.ForTag(tag.Id)); + var kids = childrenByParent.TryGetValue(tag.Id, out var k) ? k : EmptyTags; + var servers = serversByTag.TryGetValue(tag.Id, out var sv) ? sv : EmptyServers; + var hasChildren = kids.Count > 0 || servers.Count > 0; + + rows.Add(new FleetHeaderRow( + FleetGroupKind.Tag, tag.Name, depth, + serverCount: servers.Count, hasChildren: hasChildren, isExpanded: expanded, tag: tag)); + + if (!expanded) + { + return; + } + + foreach (var child in kids) + { + Emit(child, depth + 1); + } + + foreach (var s in servers) + { + rows.Add(new FleetServerRow(s, depth + 1)); + } + } + + /* Roots: parent is null OR a dangling parent id (an orphaned subtree still surfaces rather than + vanishing with its servers). */ + var roots = _tags + .Where(t => t.ParentId is null || !knownTagIds.Contains(t.ParentId.Value)) + .OrderBy(t => t.SortOrder) + .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var root in roots) + { + Emit(root, 0); + } + + /* Any tag not reached from a root is part of a cycle or a fully disconnected component — only + possible from a hand-edited store, since CRUD forbids cycles. Surface each as a root so no tag, + and no server assigned only to it, silently vanishes; the visited guard keeps it terminating. */ + foreach (var tag in _tags) + { + if (!visited.Contains(tag.Id)) + { + Emit(tag, 0); + } + } + + /* Untagged: servers with no assignment at all. Shown last so the tree always accounts for the + whole fleet. */ + var untagged = _all + .Where(s => !_serverTags.TryGetValue(s.ServerId, out var t) || t.Count == 0) + .ToList(); + + if (untagged.Count > 0) + { + var expanded = IsExpanded(FleetGroupKey.Untagged); + rows.Add(new FleetHeaderRow( + FleetGroupKind.Untagged, "Untagged", depth: 0, + serverCount: untagged.Count, hasChildren: true, isExpanded: expanded)); + + if (expanded) + { + foreach (var s in untagged) + { + rows.Add(new FleetServerRow(s, depth: 1)); + } + } + } + + _visible = rows; ProjectionChanged?.Invoke(this, EventArgs.Empty); } /// /// The server with this id, or null. Resolves against ON PURPOSE: a deep link, an - /// Overview card click, or a selection restore must still find a server that the current filter - /// happens to be hiding — otherwise the action silently does nothing, which is exactly the class of - /// bug this type exists to prevent. + /// Overview card click, or a selection restore must still find a server the current view happens to be + /// hiding (collapsed, or filtered) — otherwise the action silently does nothing, which is exactly the + /// class of bug this type exists to prevent. /// public DarlingServer? Find(int serverId) => _all.Find(s => s.ServerId == serverId); /// - /// The server to select after a rebuild: the previously-selected one if it still exists (by id, so a - /// renamed or re-sorted server still resolves), else the first VISIBLE server, else null when the - /// filter hides everything. Callers must not fall back to index 0 of the bound list — once tag rows - /// share that list, row 0 is a header, not a server. + /// The row to select after a rebuild: the server row for the previously-selected server if it is still + /// visible (by id, so a rename or re-sort still resolves), else the first visible SERVER row, else + /// null when nothing is showing. Returns a server row, never a header — callers must not fall back to + /// index 0 of the bound list, since row 0 is now often a group header. /// - public DarlingServer? ResolveSelection(int? previousServerId) + public FleetServerRow? ResolveSelection(int? previousServerId) { + var serverRows = _visible.OfType().ToList(); + if (previousServerId is int previous) { - var kept = _visible.Find(s => s.ServerId == previous); + var kept = serverRows.FirstOrDefault(r => r.Server.ServerId == previous); if (kept is not null) { return kept; } } - return _visible.Count > 0 ? _visible[0] : null; + return serverRows.Count > 0 ? serverRows[0] : null; + } + + private static int CompareFavoriteThenName(DarlingServer a, DarlingServer b) + { + var byFavorite = b.IsFavorite.CompareTo(a.IsFavorite); + return byFavorite != 0 + ? byFavorite + : string.Compare(a.DisplayName, b.DisplayName, StringComparison.OrdinalIgnoreCase); } + + private static readonly List EmptyTags = new(); + private static readonly List EmptyServers = new(); } diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs index a89534d20..b5fef05b5 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.ServerManagement.cs @@ -75,7 +75,7 @@ whatever the sidebar currently shows. */ /* Re-sort through the model and rebind its projection once, rather than assigning a fresh list to ItemsSource behind the model's back (which is how the bound list and the fleet drifted apart). */ - var selected = (ServerList.SelectedItem as DarlingServer)?.ServerId; + var selected = (ServerList.SelectedItem as FleetServerRow)?.Server.ServerId; _fleet.SetAll(SortWithFavorites(_fleet.All.ToList())); ServerList.ItemsSource = _fleet.Visible; ServerList.SelectedItem = _fleet.ResolveSelection(selected); @@ -247,7 +247,7 @@ private static string FormatBytes(long bytes) var contextMenu = menuItem.Parent as ContextMenu; var target = contextMenu?.PlacementTarget as FrameworkElement; - return target?.DataContext as DarlingServer; + return (target?.DataContext as FleetServerRow)?.Server; } private void ServerContextMenu_ToggleFavorite_Click(object sender, RoutedEventArgs e) diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.Tags.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.Tags.cs new file mode 100644 index 000000000..1f740b7b2 --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.Tags.cs @@ -0,0 +1,430 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * 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; + +namespace PerformanceMonitor.Darling.Viewer; + +/// +/// Fleet tags on the sidebar: loading the tag forest into , the tag-header context +/// menu (create / rename / delete / nest), and the server row's quick "Assign Tags" submenu. Bulk work +/// lives in ; this file is the inline, one-click surface. +/// +public partial class MainWindow +{ + /// Four levels: a tag at depth 0..2 may take a child; depth 3 (the fourth) may not. + private const int MaxTagDepth = 4; + + private List _tags = new(); + + /// serverId → its assigned tag ids, for the quick submenu's check marks. + private Dictionary> _serverTagIds = new(); + + /// Guards the one-time restore of persisted collapse state on first tag load. + private bool _collapseRestored; + + /// + /// Loads the tag forest and assignments, feeds the sidebar projection, and rebinds — preserving the + /// current server selection. Safe to call on the initial load and after any mutation. Failures are + /// logged and left non-fatal: the sidebar simply stays flat (tags are opt-in) rather than breaking. + /// + private async Task LoadTagsAsync() + { + if (_dataService is null) + { + return; + } + + try + { + var tags = await _dataService.GetServerTagsAsync(); + var assignments = await _dataService.GetServerTagAssignmentsAsync(); + + _tags = tags; + _serverTagIds = new Dictionary>(); + foreach (var a in assignments) + { + if (!_serverTagIds.TryGetValue(a.ServerId, out var set)) + { + set = new HashSet(); + _serverTagIds[a.ServerId] = set; + } + + set.Add(a.TagId); + } + + /* Restore persisted expand/collapse state once, before the first projection with tags, so a + reopened viewer shows the groups the way the user left them. Stale keys (a deleted tag) are + ignored by TryParse. */ + if (!_collapseRestored) + { + var restored = new List(); + foreach (var stored in _preferences.CollapsedFleetGroups) + { + if (FleetGroupKey.TryParse(stored, out var key)) + { + restored.Add(key); + } + } + + _fleet.SetCollapsedKeys(restored); + _collapseRestored = true; + } + + /* Re-projecting keeps the same server selected, so suppress the selection round-trip — the + aggregate tabs are already showing that server and need no reload. */ + var previous = (ServerList.SelectedItem as FleetServerRow)?.Server.ServerId; + _suppressSidebarSelection = true; + try + { + _fleet.SetTags(tags, assignments); + ServerList.ItemsSource = _fleet.Visible; + ServerList.SelectedItem = _fleet.ResolveSelection(previous); + } + finally + { + _suppressSidebarSelection = false; + } + } + catch (Exception ex) + { + ViewerLogger.Error("Tags", "Failed to load fleet tags", ex); + } + } + + /// Saves the current collapse state to the viewer preferences (called after each toggle). + private void PersistCollapseState() + { + _preferences.CollapsedFleetGroups = _fleet.CollapsedKeys.Select(k => k.ToStorageString()).ToList(); + try + { + _preferencesStore.Save(_preferences); + } + catch (Exception ex) + { + ViewerLogger.Error("Tags", "Failed to persist tag collapse state", ex); + } + } + + // ── Tag-header context menu (CRUD) ─────────────────────────────────────────────────────────────── + + /// The behind a header context-menu click (mirrors the server resolver). + private static FleetHeaderRow? GetHeaderFromContextMenu(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 FleetHeaderRow; + } + + /// Disables the tag-only actions on the Favorites / Untagged pseudo-groups, which have no tag. + private void TagHeader_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + if (sender is not FrameworkElement fe || fe.DataContext is not FleetHeaderRow header || fe.ContextMenu is null) + { + return; + } + + var isRealTag = header.Kind == FleetGroupKind.Tag; + foreach (var item in fe.ContextMenu.Items.OfType()) + { + if ((item.Header as string) is "New Child Tag..." or "Rename..." or "Delete Tag") + { + item.IsEnabled = isRealTag; + } + } + } + + private async void TagHeaderContextMenu_NewTag_Click(object sender, RoutedEventArgs e) + { + var name = PromptTagName("New Tag", "Tag name:"); + if (name is not null) + { + await CreateTagAndReloadAsync(name, parentId: null); + } + } + + private async void TagHeaderContextMenu_NewChildTag_Click(object sender, RoutedEventArgs e) + { + var header = GetHeaderFromContextMenu(sender); + if (header?.Tag is null) + { + return; + } + + if (TagDepth(header.Tag.Id) >= MaxTagDepth - 1) + { + StatusText.Text = "Tags can nest at most four levels deep."; + return; + } + + var name = PromptTagName("New Child Tag", $"New tag under ‘{header.Tag.Name}’:"); + if (name is not null) + { + await CreateTagAndReloadAsync(name, header.Tag.Id); + } + } + + private async void TagHeaderContextMenu_Rename_Click(object sender, RoutedEventArgs e) + { + var header = GetHeaderFromContextMenu(sender); + if (header?.Tag is null || _dataService is null) + { + return; + } + + var name = PromptTagName("Rename Tag", "New name:", header.Tag.Name); + if (name is null || name == header.Tag.Name) + { + return; + } + + try + { + await _dataService.RenameServerTagAsync(header.Tag.Id, name); + await LoadTagsAsync(); + StatusText.Text = $"Renamed tag to ‘{name}’."; + } + catch (Exception ex) + { + ViewerLogger.Error("Tags", "Rename tag failed", ex); + StatusText.Text = $"Could not rename the tag: {ex.Message}"; + } + } + + private async void TagHeaderContextMenu_Delete_Click(object sender, RoutedEventArgs e) + { + var header = GetHeaderFromContextMenu(sender); + if (header?.Tag is null || _dataService is null) + { + return; + } + + var hasChildren = _tags.Any(t => t.ParentId == header.Tag.Id); + var childNote = hasChildren ? " and all of its child tags" : string.Empty; + + var confirm = MessageBox.Show(this, + $"Delete ‘{header.Tag.Name}’{childNote}? Server assignments are removed; collected data is not affected.", + "Delete Tag", MessageBoxButton.OKCancel, MessageBoxImage.Warning); + if (confirm != MessageBoxResult.OK) + { + return; + } + + try + { + await _dataService.DeleteServerTagAsync(header.Tag.Id); + await LoadTagsAsync(); + StatusText.Text = $"Deleted tag ‘{header.Tag.Name}’."; + } + catch (Exception ex) + { + ViewerLogger.Error("Tags", "Delete tag failed", ex); + StatusText.Text = $"Could not delete the tag: {ex.Message}"; + } + } + + private void ManageTags_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null) + { + return; + } + + var dialog = new ManageTagsWindow(_dataService, _fleet.All) { Owner = this }; + dialog.ShowDialog(); + if (dialog.ChangedAny) + { + _ = LoadTagsAsync(); + } + } + + private async Task CreateTagAndReloadAsync(string name, int? parentId) + { + if (_dataService is null) + { + return; + } + + try + { + await _dataService.CreateServerTagAsync(name, parentId); + await LoadTagsAsync(); + StatusText.Text = $"Created tag ‘{name}’."; + } + catch (Exception ex) + { + ViewerLogger.Error("Tags", "Create tag failed", ex); + StatusText.Text = $"Could not create the tag: {ex.Message}"; + } + } + + /// 0-based nesting depth of a tag, walking the ParentId chain (guarded against a bad cycle). + private int TagDepth(int tagId) + { + var byId = _tags.ToDictionary(t => t.Id); + var depth = 0; + var current = byId.TryGetValue(tagId, out var start) ? start : null; + var guard = 0; + while (current?.ParentId is int parentId + && byId.TryGetValue(parentId, out var parent) + && guard++ < MaxTagDepth + 2) + { + depth++; + current = parent; + } + + return depth; + } + + private string? PromptTagName(string title, string prompt, string initial = "") + { + var dialog = new TagNameDialog(title, prompt, initial) { Owner = this }; + return dialog.ShowDialog() == true ? dialog.TagName : null; + } + + // ── Server-row quick "Assign Tags" submenu ─────────────────────────────────────────────────────── + + /// + /// Rebuilds the "Assign Tags" submenu for the server whose row was right-clicked: one checkable item + /// per tag (indented by depth, checked when assigned). Populated on open so it always reflects the + /// live tag set; empty state offers a jump to Manage Tags. + /// + private void ServerRow_ContextMenuOpening(object sender, ContextMenuEventArgs e) + { + if (sender is not FrameworkElement fe || fe.DataContext is not FleetServerRow row || fe.ContextMenu is null) + { + return; + } + + var assignItem = fe.ContextMenu.Items.OfType().FirstOrDefault(m => (m.Header as string) == "Assign Tags"); + if (assignItem is null) + { + return; + } + + assignItem.Items.Clear(); + + if (_tags.Count == 0) + { + assignItem.Items.Add(new MenuItem { Header = "No tags yet", IsEnabled = false }); + assignItem.Items.Add(new Separator()); + var manage = new MenuItem { Header = "Manage Tags..." }; + manage.Click += ManageTags_Click; + assignItem.Items.Add(manage); + return; + } + + var assigned = _serverTagIds.TryGetValue(row.Server.ServerId, out var set) ? set : new HashSet(); + + foreach (var (tag, depth) in EnumerateTagForest()) + { + var item = new MenuItem + { + Header = new string(' ', depth * 2) + tag.Name, + IsCheckable = true, + IsChecked = assigned.Contains(tag.Id), + Tag = new AssignTarget(row.Server.ServerId, tag.Id) + }; + item.Click += AssignTag_Click; + assignItem.Items.Add(item); + } + } + + /// The tag forest in display order (roots by sort/name, each followed by its subtree), with + /// depth — the order and indentation the quick submenu shows. Cycle-safe, like the sidebar projection. + private IEnumerable<(DarlingTag Tag, int Depth)> EnumerateTagForest() + { + var byParent = _tags + .Where(t => t.ParentId is not null) + .GroupBy(t => t.ParentId!.Value) + .ToDictionary( + g => g.Key, + g => g.OrderBy(t => t.SortOrder).ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase).ToList()); + + var known = _tags.Select(t => t.Id).ToHashSet(); + var roots = _tags + .Where(t => t.ParentId is null || !known.Contains(t.ParentId.Value)) + .OrderBy(t => t.SortOrder) + .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase); + + var visited = new HashSet(); + var result = new List<(DarlingTag, int)>(); + + void Walk(DarlingTag tag, int depth) + { + if (!visited.Add(tag.Id)) + { + return; + } + + result.Add((tag, depth)); + if (byParent.TryGetValue(tag.Id, out var kids)) + { + foreach (var kid in kids) + { + Walk(kid, depth + 1); + } + } + } + + foreach (var root in roots) + { + Walk(root, 0); + } + + foreach (var tag in _tags) + { + if (!visited.Contains(tag.Id)) + { + Walk(tag, 0); + } + } + + return result; + } + + private sealed record AssignTarget(int ServerId, int TagId); + + private async void AssignTag_Click(object sender, RoutedEventArgs e) + { + if (_dataService is null || sender is not MenuItem item || item.Tag is not AssignTarget target) + { + return; + } + + try + { + /* A checkable MenuItem toggles IsChecked before Click fires, so it now holds the desired state. */ + if (item.IsChecked) + { + await _dataService.AssignServerTagAsync(new[] { target.ServerId }, target.TagId); + } + else + { + await _dataService.UnassignServerTagAsync(new[] { target.ServerId }, target.TagId); + } + + await LoadTagsAsync(); + } + catch (Exception ex) + { + ViewerLogger.Error("Tags", "Assign/unassign failed", ex); + StatusText.Text = $"Could not change tags: {ex.Message}"; + } + } +} diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml index eed27f56f..1c0e6d94a 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml @@ -36,6 +36,7 @@ + - - + + - + + + + + + @@ -461,7 +469,7 @@ @@ -488,13 +496,13 @@ - @@ -552,7 +560,7 @@ + + + + + + + + + + + + + + + + + diff --git a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs index 4cab5073f..85b411f23 100644 --- a/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs +++ b/Darling/PerformanceMonitor.Darling.Viewer/MainWindow.xaml.cs @@ -569,14 +569,33 @@ private static string FormatSnoozeDuration(TimeSpan d) => /// reloads the visible tab. The two syncs suppress their own SelectionChanged, so the visible tab is /// loaded exactly once here by . /// + private bool _suppressSidebarSelection; + + /// + /// Sidebar selection. A server row syncs the aggregate tabs and loads the visible tab; a group header + /// is never a real selection — clicking one expands/collapses its group and the selection is restored + /// to a server. The suppression guard stops that restore from re-entering this handler. + /// private async void ServerList_SelectionChanged(object sender, SelectionChangedEventArgs e) { - if (ServerList.SelectedItem is DarlingServer server) + if (_suppressSidebarSelection) { - SyncAggregateServerSelectors(server); + return; } - await RefreshVisibleAsync(); + if (ServerList.SelectedItem is FleetHeaderRow header) + { + /* The header just took the selection, so the previously-selected server is in RemovedItems. */ + var previousServerId = e.RemovedItems.OfType().FirstOrDefault()?.Server.ServerId; + ToggleGroup(header, previousServerId); + return; + } + + if (ServerList.SelectedItem is FleetServerRow row) + { + SyncAggregateServerSelectors(row.Server); + await RefreshVisibleAsync(); + } } /// @@ -606,9 +625,31 @@ private void SyncAggregateServerSelectors(DarlingServer server) /// Double-click opens (or focuses) the selected server's per-server tab (Lite's rule). private void ServerList_MouseDoubleClick(object sender, MouseButtonEventArgs e) { - if (ServerList.SelectedItem is DarlingServer server) + if (ServerList.SelectedItem is FleetServerRow row) { - OpenServerTab(server); + OpenServerTab(row.Server); + } + } + + /// + /// Expands or collapses a sidebar group and re-selects a server so the sidebar never rests on a header. + /// Reprojects through (the single seam), rebinds, and restores the prior server + /// if it is still visible — else the first visible one — under the suppression guard so the + /// programmatic re-selection does not recurse into . + /// + private void ToggleGroup(FleetHeaderRow header, int? previousServerId) + { + _suppressSidebarSelection = true; + try + { + _fleet.ToggleExpanded(header); + ServerList.ItemsSource = _fleet.Visible; + ServerList.SelectedItem = _fleet.ResolveSelection(previousServerId); + PersistCollapseState(); + } + finally + { + _suppressSidebarSelection = false; } } @@ -640,7 +681,7 @@ private async Task LoadServersAsync(bool preserveSelection = false) try { - var previousSelection = (ServerList.SelectedItem as DarlingServer)?.ServerId; + var previousSelection = (ServerList.SelectedItem as FleetServerRow)?.Server.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 @@ -683,6 +724,11 @@ private async Task LoadServersAsync(bool preserveSelection = false) rather than a server, so a SelectedIndex = 0 fallback would select a non-server and the aggregate-tab sync would silently never run. */ ServerList.SelectedItem = _fleet.ResolveSelection(preserveSelection ? previousSelection : null); + + /* Fold the fleet tags into the sidebar (opt-in: no tags => the list stays flat). Done after + the selectors are populated and the initial selection is set, so the tag re-projection + just preserves that selection under its own suppression guard. */ + await LoadTagsAsync(); } else { diff --git a/Darling/PerformanceMonitor.Darling.Viewer/ManageTagsWindow.xaml b/Darling/PerformanceMonitor.Darling.Viewer/ManageTagsWindow.xaml new file mode 100644 index 000000000..a8f9b9b3d --- /dev/null +++ b/Darling/PerformanceMonitor.Darling.Viewer/ManageTagsWindow.xaml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + +