diff --git a/Lite/Analysis/TestDataSeeder.cs b/Lite/Analysis/TestDataSeeder.cs index f47b82f7..4623e2a0 100644 --- a/Lite/Analysis/TestDataSeeder.cs +++ b/Lite/Analysis/TestDataSeeder.cs @@ -1397,4 +1397,372 @@ INSERT INTO database_size_stats await cmd.ExecuteNonQueryAsync(); } } + + // ============================================ + // FinOps Test Scenarios + // ============================================ + + /// + /// Scenario 1: Over-provisioned Enterprise server. + /// 32 cores, 256GB RAM, but avg CPU 8%, buffer pool only 40GB of 256GB. + /// + /// Expected recommendations: + /// - CPU right-sizing (P95 < 30%, many cores) + /// - Memory right-sizing (buffer pool < 50% of physical RAM) + /// + public async Task SeedOverProvisionedEnterpriseAsync() + { + await ClearTestDataAsync(); + await SeedTestServerAsync(); + + // 32 cores, 256GB RAM, but avg CPU 8%, buffer pool only 40GB of 256GB + await SeedCpuUtilizationAsync(8, 2); + await SeedMemoryStatsAsync(totalPhysicalMb: 262_144, bufferPoolMb: 40_960, targetMb: 245_760); + await SeedServerPropertiesAsync(cpuCount: 32, htRatio: 2, physicalMemMb: 262_144, + edition: "Enterprise Edition"); + await SeedFileSizeAsync(totalDataSizeMb: 51_200); // 50GB — tiny for 256GB RAM + } + + /// + /// Scenario 2: Idle databases with cost impact. + /// 3 databases seeded — only 1 has query activity, the other 2 are idle. + /// + /// Expected recommendations: + /// - Dormant database detection (2 idle databases) + /// + public async Task SeedIdleDatabasesAsync() + { + await ClearTestDataAsync(); + await SeedTestServerAsync(); + + // Seed database sizes for 3 databases + query activity for only 1 + await SeedDatabaseSizesForIdleTestAsync(); + await SeedQueryStatsForDatabaseAsync("ActiveDB", executions: 5000, cpuMs: 100_000); + } + + /// + /// Scenario 3: High impact query skew — one query consuming 80%+ of CPU. + /// + /// Expected: HighImpactScorer.Score() returns query "AAAA" with dominant CpuShare. + /// + public async Task SeedHighImpactQuerySkewAsync() + { + await ClearTestDataAsync(); + await SeedTestServerAsync(); + + // 5 queries: one uses 80% CPU, rest split the remaining 20% + await SeedQueryStatsForHighImpactAsync(); + } + + /// + /// Scenario 4: Dev/test databases on a production server. + /// Seeds database_size_stats with databases named "staging_app", "dev_analytics", "test_warehouse". + /// + /// NOTE: The recommendation engine detects dev/test databases via a LIVE SQL query + /// (sys.databases WHERE name LIKE '%dev%'). This won't fire against DuckDB test data. + /// The scenario documents the expected behavior but the live check will silently fail. + /// Use this scenario to test the idle-database detection path instead. + /// + public async Task SeedDevTestDatabasesAsync() + { + await ClearTestDataAsync(); + await SeedTestServerAsync(); + + await SeedDatabaseSizesWithNamesAsync("staging_app", "dev_analytics", "test_warehouse", "ProductionDB"); + } + + /// + /// Scenario 5: Long-running maintenance jobs. + /// Seeds running_jobs with a job that ran long 5+ times in 7 days. + /// + /// Expected recommendations: + /// - Maintenance window efficiency warning + /// + public async Task SeedLongRunningJobsAsync() + { + await ClearTestDataAsync(); + await SeedTestServerAsync(); + + await SeedRunningJobsForMaintenanceTestAsync(); + } + + /// + /// Scenario 6: Clean FinOps server — no recommendations expected. + /// Healthy CPU (50%), good buffer pool ratio (75%), no idle databases. + /// + /// Expected: empty or minimal recommendation list. + /// + public async Task SeedCleanFinOpsServerAsync() + { + await ClearTestDataAsync(); + await SeedTestServerAsync(); + + // Healthy: 50% CPU, 75% buffer pool ratio, no idle databases + await SeedCpuUtilizationAsync(50, 5); + await SeedMemoryStatsAsync(totalPhysicalMb: 65_536, bufferPoolMb: 49_152, targetMb: 57_344); + await SeedServerPropertiesAsync(cpuCount: 8, htRatio: 2, physicalMemMb: 65_536, + edition: "Developer Edition"); + await SeedFileSizeAsync(totalDataSizeMb: 204_800); // 200GB + } + + // ============================================ + // FinOps Test Runner Methods + // ============================================ + + /// + /// Runs the FinOps recommendation engine against test data. + /// Pass empty strings for connectionString/utilityConnectionString to skip live SQL checks. + /// + public async Task> RunFinOpsRecommendationsAsync( + PerformanceMonitorLite.Services.LocalDataService dataService, decimal monthlyCost = 10000m) + { + return await dataService.GetRecommendationsAsync(TestServerId, "", "", monthlyCost); + } + + /// + /// Runs the High Impact scorer against test data. + /// + public async Task> RunHighImpactAnalysisAsync( + PerformanceMonitorLite.Services.LocalDataService dataService, int hoursBack = 24) + { + return await dataService.GetHighImpactQueriesAsync(TestServerId, hoursBack); + } + + // ============================================ + // FinOps Seed Helpers + // ============================================ + + /// + /// Seeds database_size_stats with 3 databases for idle-database testing. + /// "ActiveDB" will have query_stats activity (seeded separately). + /// "OldReportsDB" (50GB) and "ArchiveDB" (100GB) have no activity — should be detected as idle. + /// + internal async Task SeedDatabaseSizesForIdleTestAsync() + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + var databases = new (string name, int dbId, decimal totalSizeMb)[] + { + ("ActiveDB", 10, 20_480), // 20GB — active + ("OldReportsDB", 11, 51_200), // 50GB — idle + ("ArchiveDB", 12, 102_400), // 100GB — idle + }; + + foreach (var (name, dbId, totalSizeMb) in databases) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO database_size_stats + (collection_id, collection_time, server_id, server_name, + database_name, database_id, file_id, file_type_desc, file_name, physical_name, + total_size_mb, used_size_mb) +VALUES ($1, $2, $3, $4, $5, $6, 1, 'ROWS', $7, $8, $9, $10)"; + + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = name }); + cmd.Parameters.Add(new DuckDBParameter { Value = dbId }); + cmd.Parameters.Add(new DuckDBParameter { Value = $"{name}.mdf" }); + cmd.Parameters.Add(new DuckDBParameter { Value = $"D:\\Data\\{name}.mdf" }); + cmd.Parameters.Add(new DuckDBParameter { Value = totalSizeMb }); + cmd.Parameters.Add(new DuckDBParameter { Value = totalSizeMb * 0.8m }); // 80% used + + await cmd.ExecuteNonQueryAsync(); + } + } + + /// + /// Seeds query_stats with activity for a specific database. + /// Used to mark a database as "active" so it's excluded from idle detection. + /// + internal async Task SeedQueryStatsForDatabaseAsync(string databaseName, long executions, long cpuMs) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + // Spread across 16 collection points so it falls within time-range queries + var execsPerPoint = executions / 16; + var cpuPerPoint = cpuMs * 1000 / 16; // convert ms to microseconds for delta_worker_time + + for (var i = 0; i < 16; i++) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, + database_name, query_hash, delta_execution_count, + delta_worker_time, delta_elapsed_time, delta_logical_reads) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"; + + var t = TestPeriodStart.AddMinutes(i * 15); + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = t }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = databaseName }); + cmd.Parameters.Add(new DuckDBParameter { Value = $"0xACTIVE{i:D4}" }); + cmd.Parameters.Add(new DuckDBParameter { Value = execsPerPoint }); + cmd.Parameters.Add(new DuckDBParameter { Value = cpuPerPoint }); + cmd.Parameters.Add(new DuckDBParameter { Value = cpuPerPoint * 2 }); + cmd.Parameters.Add(new DuckDBParameter { Value = execsPerPoint * 500L }); + + await cmd.ExecuteNonQueryAsync(); + } + } + + /// + /// Seeds query_stats for high-impact skew testing. + /// 5 queries with one dominant (80% CPU): + /// AAAA — 800,000ms CPU, 10,000 executions (the monster) + /// BBBB — 50,000ms CPU, 5,000 executions + /// CCCC — 50,000ms CPU, 2,000 executions + /// DDDD — 50,000ms CPU, 1,000 executions + /// EEEE — 50,000ms CPU, 500 executions + /// + internal async Task SeedQueryStatsForHighImpactAsync() + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + var queries = new (string hash, long cpuMs, long executions, long reads, long writes, long memoryKb)[] + { + ("AAAA", 800_000, 10_000, 50_000_000, 1_000_000, 512_000), // The monster + ("BBBB", 50_000, 5_000, 5_000_000, 100_000, 64_000), + ("CCCC", 50_000, 2_000, 3_000_000, 50_000, 32_000), + ("DDDD", 50_000, 1_000, 2_000_000, 25_000, 16_000), + ("EEEE", 50_000, 500, 1_000_000, 10_000, 8_000), + }; + + foreach (var (hash, cpuMs, executions, reads, writes, memoryKb) in queries) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO query_stats + (collection_id, collection_time, server_id, server_name, + database_name, query_hash, query_text, + delta_execution_count, delta_worker_time, delta_elapsed_time, + delta_logical_reads, delta_logical_writes, max_grant_kb) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)"; + + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd.AddMinutes(-30) }); // recent + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = "UserDB" }); + cmd.Parameters.Add(new DuckDBParameter { Value = hash }); + cmd.Parameters.Add(new DuckDBParameter { Value = $"SELECT /* {hash} */ * FROM dbo.SomeTable" }); + cmd.Parameters.Add(new DuckDBParameter { Value = executions }); + cmd.Parameters.Add(new DuckDBParameter { Value = cpuMs * 1000L }); // microseconds + cmd.Parameters.Add(new DuckDBParameter { Value = cpuMs * 2000L }); // elapsed ~2x CPU + cmd.Parameters.Add(new DuckDBParameter { Value = reads }); + cmd.Parameters.Add(new DuckDBParameter { Value = writes }); + cmd.Parameters.Add(new DuckDBParameter { Value = memoryKb }); + + await cmd.ExecuteNonQueryAsync(); + } + } + + /// + /// Seeds database_size_stats with named databases. + /// Used for dev/test detection testing and general size seeding. + /// + internal async Task SeedDatabaseSizesWithNamesAsync(params string[] databaseNames) + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + for (var i = 0; i < databaseNames.Length; i++) + { + var name = databaseNames[i]; + var sizeMb = 10_240m + (i * 5_120m); // 10GB, 15GB, 20GB, 25GB, ... + + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO database_size_stats + (collection_id, collection_time, server_id, server_name, + database_name, database_id, file_id, file_type_desc, file_name, physical_name, + total_size_mb, used_size_mb) +VALUES ($1, $2, $3, $4, $5, $6, 1, 'ROWS', $7, $8, $9, $10)"; + + cmd.Parameters.Add(new DuckDBParameter { Value = _nextId-- }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = name }); + cmd.Parameters.Add(new DuckDBParameter { Value = 10 + i }); // database_id + cmd.Parameters.Add(new DuckDBParameter { Value = $"{name}.mdf" }); + cmd.Parameters.Add(new DuckDBParameter { Value = $"D:\\Data\\{name}.mdf" }); + cmd.Parameters.Add(new DuckDBParameter { Value = sizeMb }); + cmd.Parameters.Add(new DuckDBParameter { Value = sizeMb * 0.7m }); // 70% used + + await cmd.ExecuteNonQueryAsync(); + } + } + + /// + /// Seeds running_jobs for maintenance window testing. + /// Creates a "Weekly Index Rebuild" job that ran long 5 times in 7 days, + /// and a normal "Stats Update" job for contrast. + /// + internal async Task SeedRunningJobsForMaintenanceTestAsync() + { + using var readLock = _duckDb.AcquireReadLock(); + using var connection = _duckDb.CreateConnection(); + await connection.OpenAsync(); + + // "Weekly Index Rebuild" — ran long 5 times + var jobId = Guid.NewGuid().ToString(); + for (var i = 0; i < 5; i++) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" +INSERT INTO running_jobs + (collection_time, server_id, server_name, job_name, job_id, + job_enabled, start_time, current_duration_seconds, + avg_duration_seconds, p95_duration_seconds, successful_run_count, + is_running_long, percent_of_average) +VALUES ($1, $2, $3, $4, $5, true, $6, $7, $8, $9, 50, true, $10)"; + + // Spread collections across the 7-day window the recommendation engine queries + var collectionTime = DateTime.UtcNow.AddDays(-6).AddDays(i * 1.2); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + cmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + cmd.Parameters.Add(new DuckDBParameter { Value = "Weekly Index Rebuild" }); + cmd.Parameters.Add(new DuckDBParameter { Value = jobId }); + cmd.Parameters.Add(new DuckDBParameter { Value = collectionTime.AddSeconds(-900) }); // started 15min ago + cmd.Parameters.Add(new DuckDBParameter { Value = 900L }); // current_duration_seconds (15min) + cmd.Parameters.Add(new DuckDBParameter { Value = 300L }); // avg_duration_seconds (5min historical) + cmd.Parameters.Add(new DuckDBParameter { Value = 450L }); // p95_duration_seconds + cmd.Parameters.Add(new DuckDBParameter { Value = 300.0 }); // percent_of_average = 300% + + await cmd.ExecuteNonQueryAsync(); + } + + // "Stats Update" — normal job, not running long + using var normalCmd = connection.CreateCommand(); + normalCmd.CommandText = @" +INSERT INTO running_jobs + (collection_time, server_id, server_name, job_name, job_id, + job_enabled, start_time, current_duration_seconds, + avg_duration_seconds, p95_duration_seconds, successful_run_count, + is_running_long, percent_of_average) +VALUES ($1, $2, $3, $4, $5, true, $6, 120, 100, 130, 200, false, 120.0)"; + + normalCmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd.AddMinutes(-5) }); + normalCmd.Parameters.Add(new DuckDBParameter { Value = TestServerId }); + normalCmd.Parameters.Add(new DuckDBParameter { Value = TestServerName }); + normalCmd.Parameters.Add(new DuckDBParameter { Value = "Stats Update" }); + normalCmd.Parameters.Add(new DuckDBParameter { Value = Guid.NewGuid().ToString() }); + normalCmd.Parameters.Add(new DuckDBParameter { Value = TestPeriodEnd.AddMinutes(-7) }); + + await normalCmd.ExecuteNonQueryAsync(); + } } diff --git a/Lite/Services/LocalDataService.FinOps.cs b/Lite/Services/LocalDataService.FinOps.cs index 07dc5a1e..ad921923 100644 --- a/Lite/Services/LocalDataService.FinOps.cs +++ b/Lite/Services/LocalDataService.FinOps.cs @@ -1307,64 +1307,7 @@ HAVING SUM(delta_execution_count) > 0 }); } - if (allRows.Count == 0) return allRows; - - // Step 2: Find "interesting" hashes — top 10 per dimension via UNION - var interesting = new HashSet(); - foreach (var hash in allRows.OrderByDescending(r => r.TotalCpuMs).Take(10).Select(r => r.QueryHash)) interesting.Add(hash); - foreach (var hash in allRows.OrderByDescending(r => r.TotalDurationMs).Take(10).Select(r => r.QueryHash)) interesting.Add(hash); - foreach (var hash in allRows.OrderByDescending(r => r.TotalReads).Take(10).Select(r => r.QueryHash)) interesting.Add(hash); - foreach (var hash in allRows.OrderByDescending(r => r.TotalWrites).Take(10).Select(r => r.QueryHash)) interesting.Add(hash); - foreach (var hash in allRows.OrderByDescending(r => r.TotalMemoryMb).Take(10).Select(r => r.QueryHash)) interesting.Add(hash); - foreach (var hash in allRows.OrderByDescending(r => r.TotalExecutions).Take(10).Select(r => r.QueryHash)) interesting.Add(hash); - - var filtered = allRows.Where(r => interesting.Contains(r.QueryHash)).ToList(); - - if (filtered.Count == 0) return filtered; - - // Step 3: Compute PERCENT_RANK and share for the interesting set - var cpuValues = filtered.Select(r => r.TotalCpuMs).OrderBy(v => v).ToList(); - var durationValues = filtered.Select(r => r.TotalDurationMs).OrderBy(v => v).ToList(); - var readsValues = filtered.Select(r => (decimal)r.TotalReads).OrderBy(v => v).ToList(); - var writesValues = filtered.Select(r => (decimal)r.TotalWrites).OrderBy(v => v).ToList(); - var memoryValues = filtered.Select(r => r.TotalMemoryMb).OrderBy(v => v).ToList(); - var execValues = filtered.Select(r => (decimal)r.TotalExecutions).OrderBy(v => v).ToList(); - - var totalCpu = filtered.Sum(r => r.TotalCpuMs); - var totalDuration = filtered.Sum(r => r.TotalDurationMs); - var totalReads = filtered.Sum(r => (decimal)r.TotalReads); - var totalWrites = filtered.Sum(r => (decimal)r.TotalWrites); - var totalMemory = filtered.Sum(r => r.TotalMemoryMb); - var totalExecs = filtered.Sum(r => (decimal)r.TotalExecutions); - - foreach (var row in filtered) - { - var cpuPctl = PercentRank(cpuValues, row.TotalCpuMs); - var durationPctl = PercentRank(durationValues, row.TotalDurationMs); - var readsPctl = PercentRank(readsValues, (decimal)row.TotalReads); - var writesPctl = PercentRank(writesValues, (decimal)row.TotalWrites); - var memoryPctl = PercentRank(memoryValues, row.TotalMemoryMb); - var execsPctl = PercentRank(execValues, (decimal)row.TotalExecutions); - - row.CpuShare = totalCpu > 0 ? Math.Round(100m * row.TotalCpuMs / totalCpu, 1) : 0; - row.DurationShare = totalDuration > 0 ? Math.Round(100m * row.TotalDurationMs / totalDuration, 1) : 0; - row.ReadsShare = totalReads > 0 ? Math.Round(100m * row.TotalReads / totalReads, 1) : 0; - row.WritesShare = totalWrites > 0 ? Math.Round(100m * row.TotalWrites / totalWrites, 1) : 0; - row.MemoryShare = totalMemory > 0 ? Math.Round(100m * row.TotalMemoryMb / totalMemory, 1) : 0; - row.ExecutionsShare = totalExecs > 0 ? Math.Round(100m * row.TotalExecutions / totalExecs, 1) : 0; - - var pctlSum = cpuPctl + durationPctl + readsPctl + writesPctl + memoryPctl + execsPctl; - row.ImpactScore = (int)(pctlSum / 6m * 100m); - } - - return filtered.OrderByDescending(r => r.ImpactScore).ToList(); - } - - private static decimal PercentRank(List sortedValues, decimal value) - { - if (sortedValues.Count <= 1) return 0; - int rank = sortedValues.Count(v => v < value); - return (decimal)rank / (sortedValues.Count - 1); + return HighImpactScorer.Score(allRows); } /// @@ -2314,6 +2257,81 @@ public class RecommendationRow public string EstMonthlySavingsDisplay => EstMonthlySavings.HasValue ? $"${EstMonthlySavings.Value:N0}" : ""; } +/// +/// Identifies top-N queries per resource dimension, computes PERCENT_RANK +/// and share percentages, and returns the "interesting" set sorted by impact score. +/// Extracted from GetHighImpactQueriesAsync for testability. +/// +public static class HighImpactScorer +{ + /// + /// Scores a list of query rows by identifying top-N per resource dimension, + /// computing PERCENT_RANK and share percentages, and returning the interesting + /// set sorted by impact score descending. + /// + public static List Score(List allRows, int topN = 10) + { + if (allRows.Count == 0) return allRows; + + // Step 1: Find "interesting" hashes — top N per dimension via UNION + var interesting = new HashSet(); + foreach (var hash in allRows.OrderByDescending(r => r.TotalCpuMs).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalDurationMs).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalReads).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalWrites).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalMemoryMb).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + foreach (var hash in allRows.OrderByDescending(r => r.TotalExecutions).Take(topN).Select(r => r.QueryHash)) interesting.Add(hash); + + var filtered = allRows.Where(r => interesting.Contains(r.QueryHash)).ToList(); + + if (filtered.Count == 0) return filtered; + + // Step 2: Compute PERCENT_RANK and share for the interesting set + var cpuValues = filtered.Select(r => r.TotalCpuMs).OrderBy(v => v).ToList(); + var durationValues = filtered.Select(r => r.TotalDurationMs).OrderBy(v => v).ToList(); + var readsValues = filtered.Select(r => (decimal)r.TotalReads).OrderBy(v => v).ToList(); + var writesValues = filtered.Select(r => (decimal)r.TotalWrites).OrderBy(v => v).ToList(); + var memoryValues = filtered.Select(r => r.TotalMemoryMb).OrderBy(v => v).ToList(); + var execValues = filtered.Select(r => (decimal)r.TotalExecutions).OrderBy(v => v).ToList(); + + var totalCpu = filtered.Sum(r => r.TotalCpuMs); + var totalDuration = filtered.Sum(r => r.TotalDurationMs); + var totalReads = filtered.Sum(r => (decimal)r.TotalReads); + var totalWrites = filtered.Sum(r => (decimal)r.TotalWrites); + var totalMemory = filtered.Sum(r => r.TotalMemoryMb); + var totalExecs = filtered.Sum(r => (decimal)r.TotalExecutions); + + foreach (var row in filtered) + { + var cpuPctl = PercentRank(cpuValues, row.TotalCpuMs); + var durationPctl = PercentRank(durationValues, row.TotalDurationMs); + var readsPctl = PercentRank(readsValues, (decimal)row.TotalReads); + var writesPctl = PercentRank(writesValues, (decimal)row.TotalWrites); + var memoryPctl = PercentRank(memoryValues, row.TotalMemoryMb); + var execsPctl = PercentRank(execValues, (decimal)row.TotalExecutions); + + row.CpuShare = totalCpu > 0 ? Math.Round(100m * row.TotalCpuMs / totalCpu, 1) : 0; + row.DurationShare = totalDuration > 0 ? Math.Round(100m * row.TotalDurationMs / totalDuration, 1) : 0; + row.ReadsShare = totalReads > 0 ? Math.Round(100m * row.TotalReads / totalReads, 1) : 0; + row.WritesShare = totalWrites > 0 ? Math.Round(100m * row.TotalWrites / totalWrites, 1) : 0; + row.MemoryShare = totalMemory > 0 ? Math.Round(100m * row.TotalMemoryMb / totalMemory, 1) : 0; + row.ExecutionsShare = totalExecs > 0 ? Math.Round(100m * row.TotalExecutions / totalExecs, 1) : 0; + + var pctlSum = cpuPctl + durationPctl + readsPctl + writesPctl + memoryPctl + execsPctl; + row.ImpactScore = (int)(pctlSum / 6m * 100m); + } + + return filtered.OrderByDescending(r => r.ImpactScore).ToList(); + } + + internal static decimal PercentRank(List sortedValues, decimal value) + { + if (sortedValues.Count <= 1) return 0; + int rank = sortedValues.Count(v => v < value); + return (decimal)rank / (sortedValues.Count - 1); + } +} + public class HighImpactQueryRow { public string QueryHash { get; set; } = "";