diff --git a/Lite/Controls/ServerTab.xaml b/Lite/Controls/ServerTab.xaml
index 54c51ed7..7cb808dc 100644
--- a/Lite/Controls/ServerTab.xaml
+++ b/Lite/Controls/ServerTab.xaml
@@ -974,43 +974,92 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs
index f89a517b..4e30cdae 100644
--- a/Lite/Controls/ServerTab.xaml.cs
+++ b/Lite/Controls/ServerTab.xaml.cs
@@ -59,6 +59,7 @@ public partial class ServerTab : UserControl
private DataGridFilterManager? _dbScopedConfigFilterMgr;
private DataGridFilterManager? _traceFlagsFilterMgr;
private DataGridFilterManager? _collectionHealthFilterMgr;
+ private DataGridFilterManager? _collectionLogFilterMgr;
private static readonly HashSet _defaultPerfmonCounters = new(StringComparer.OrdinalIgnoreCase)
{
@@ -446,6 +447,7 @@ private async System.Threading.Tasks.Task RefreshAllDataAsync()
var traceFlagsTask = SafeQueryAsync(() => _dataService.GetLatestTraceFlagsAsync(_serverId));
var runningJobsTask = SafeQueryAsync(() => _dataService.GetRunningJobsAsync(_serverId));
var collectionHealthTask = SafeQueryAsync(() => _dataService.GetCollectionHealthAsync(_serverId));
+ var collectionLogTask = SafeQueryAsync(() => _dataService.GetRecentCollectionLogAsync(_serverId, hoursBack));
/* Core data tasks */
await System.Threading.Tasks.Task.WhenAll(
snapshotsTask, cpuTask, memoryTask, memoryTrendTask,
@@ -453,7 +455,7 @@ await System.Threading.Tasks.Task.WhenAll(
deadlockTask, blockedProcessTask, waitTypesTask, perfmonCountersTask,
queryStoreTask, memoryGrantTrendTask,
serverConfigTask, databaseConfigTask, databaseScopedConfigTask, traceFlagsTask,
- runningJobsTask, collectionHealthTask);
+ runningJobsTask, collectionHealthTask, collectionLogTask);
/* Trend chart tasks - run separately so failures don't kill the whole refresh */
var blockingTrendTask = SafeQueryAsync(() => _dataService.GetBlockingTrendAsync(_serverId, hoursBack, fromDate, toDate));
@@ -491,6 +493,8 @@ await System.Threading.Tasks.Task.WhenAll(
_traceFlagsFilterMgr!.UpdateData(traceFlagsTask.Result);
_runningJobsFilterMgr!.UpdateData(runningJobsTask.Result);
_collectionHealthFilterMgr!.UpdateData(collectionHealthTask.Result);
+ _collectionLogFilterMgr!.UpdateData(collectionLogTask.Result);
+ UpdateCollectorDurationChart(collectionLogTask.Result);
/* Update memory summary */
UpdateMemorySummary(memoryTask.Result);
@@ -1844,6 +1848,62 @@ private static string CsvEscape(string value)
return value;
}
+ /* ========== Collection Health ========== */
+
+ private void UpdateCollectorDurationChart(List data)
+ {
+ ClearChart(CollectorDurationChart);
+ ApplyDarkTheme(CollectorDurationChart);
+
+ if (data.Count == 0) { CollectorDurationChart.Refresh(); return; }
+
+ /* Group by collector, plot each as a separate series */
+ var groups = data
+ .Where(d => d.DurationMs.HasValue && d.Status == "SUCCESS")
+ .GroupBy(d => d.CollectorName)
+ .OrderBy(g => g.Key)
+ .ToList();
+
+ int colorIdx = 0;
+ foreach (var group in groups)
+ {
+ var points = group.OrderBy(d => d.CollectionTime).ToList();
+ if (points.Count < 2) continue;
+
+ var times = points.Select(d => d.CollectionTime.ToLocalTime().ToOADate()).ToArray();
+ var durations = points.Select(d => (double)d.DurationMs!.Value).ToArray();
+
+ var scatter = CollectorDurationChart.Plot.Add.Scatter(times, durations);
+ scatter.LegendText = group.Key;
+ scatter.Color = ScottPlot.Color.FromHex(SeriesColors[colorIdx % SeriesColors.Length]);
+ scatter.LineWidth = 2;
+ scatter.MarkerSize = 0;
+ colorIdx++;
+ }
+
+ CollectorDurationChart.Plot.Axes.DateTimeTicksBottom();
+ ReapplyAxisColors(CollectorDurationChart);
+ CollectorDurationChart.Plot.YLabel("Duration (ms)");
+ CollectorDurationChart.Plot.Axes.AutoScale();
+ ShowChartLegend(CollectorDurationChart);
+ CollectorDurationChart.Refresh();
+ }
+
+ private void OpenLogFile_Click(object sender, RoutedEventArgs e)
+ {
+ var logDir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs");
+ var logFile = System.IO.Path.Combine(logDir, $"lite_{DateTime.Now:yyyyMMdd}.log");
+
+ if (File.Exists(logFile))
+ {
+ Process.Start(new ProcessStartInfo(logFile) { UseShellExecute = true });
+ }
+ else if (Directory.Exists(logDir))
+ {
+ Process.Start(new ProcessStartInfo(logDir) { UseShellExecute = true });
+ }
+ }
+
///
/// Stops the refresh timer when the tab is removed.
///
@@ -1868,6 +1928,7 @@ private void InitializeFilterManagers()
_dbScopedConfigFilterMgr = new DataGridFilterManager(DatabaseScopedConfigGrid);
_traceFlagsFilterMgr = new DataGridFilterManager(TraceFlagsGrid);
_collectionHealthFilterMgr = new DataGridFilterManager(CollectionHealthGrid);
+ _collectionLogFilterMgr = new DataGridFilterManager(CollectionLogGrid);
_filterManagers[QuerySnapshotsGrid] = _querySnapshotsFilterMgr;
_filterManagers[QueryStatsGrid] = _queryStatsFilterMgr;
@@ -1881,6 +1942,7 @@ private void InitializeFilterManagers()
_filterManagers[DatabaseScopedConfigGrid] = _dbScopedConfigFilterMgr;
_filterManagers[TraceFlagsGrid] = _traceFlagsFilterMgr;
_filterManagers[CollectionHealthGrid] = _collectionHealthFilterMgr;
+ _filterManagers[CollectionLogGrid] = _collectionLogFilterMgr;
}
private void EnsureFilterPopup()
diff --git a/Lite/Services/LocalDataService.CollectionHealth.cs b/Lite/Services/LocalDataService.CollectionHealth.cs
index 5ba3a7a3..254710fd 100644
--- a/Lite/Services/LocalDataService.CollectionHealth.cs
+++ b/Lite/Services/LocalDataService.CollectionHealth.cs
@@ -61,6 +61,74 @@ GROUP BY collector_name
return items;
}
+ ///
+ /// Gets recent collection log entries for a server, most recent first.
+ ///
+ public async Task> GetRecentCollectionLogAsync(int serverId, int hoursBack = 4, int maxRows = 500)
+ {
+ using var connection = await OpenConnectionAsync();
+ using var command = connection.CreateCommand();
+ command.CommandText = @"
+SELECT
+ collector_name,
+ collection_time,
+ duration_ms,
+ sql_duration_ms,
+ duckdb_duration_ms,
+ rows_collected,
+ status,
+ error_message
+FROM collection_log
+WHERE server_id = $1
+AND collection_time >= $2
+ORDER BY collection_time DESC
+LIMIT $3";
+
+ command.Parameters.Add(new DuckDBParameter { Value = serverId });
+ command.Parameters.Add(new DuckDBParameter { Value = DateTime.UtcNow.AddHours(-hoursBack) });
+ command.Parameters.Add(new DuckDBParameter { Value = maxRows });
+
+ var items = new List();
+ using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ items.Add(new CollectionLogRow
+ {
+ CollectorName = reader.GetString(0),
+ CollectionTime = reader.GetDateTime(1),
+ DurationMs = reader.IsDBNull(2) ? null : (int?)Convert.ToInt32(reader.GetValue(2)),
+ SqlDurationMs = reader.IsDBNull(3) ? null : (int?)Convert.ToInt32(reader.GetValue(3)),
+ DuckDbDurationMs = reader.IsDBNull(4) ? null : (int?)Convert.ToInt32(reader.GetValue(4)),
+ RowsCollected = reader.IsDBNull(5) ? null : (int?)Convert.ToInt32(reader.GetValue(5)),
+ Status = reader.GetString(6),
+ ErrorMessage = reader.IsDBNull(7) ? null : reader.GetString(7)
+ });
+ }
+
+ return items;
+ }
+}
+
+public class CollectionLogRow
+{
+ public string CollectorName { get; set; } = "";
+ public DateTime CollectionTime { get; set; }
+ public int? DurationMs { get; set; }
+ public int? SqlDurationMs { get; set; }
+ public int? DuckDbDurationMs { get; set; }
+ public int? RowsCollected { get; set; }
+ public string Status { get; set; } = "";
+ public string? ErrorMessage { get; set; }
+
+ public string CollectionTimeFormatted => CollectionTime.ToLocalTime().ToString("MM/dd HH:mm:ss");
+
+ public string DurationFormatted => DurationMs.HasValue
+ ? (DurationMs.Value < 1000 ? $"{DurationMs.Value} ms" : $"{DurationMs.Value / 1000.0:F1} s")
+ : "";
+
+ public string SqlDurationFormatted => SqlDurationMs.HasValue ? $"{SqlDurationMs.Value} ms" : "";
+
+ public string DuckDbDurationFormatted => DuckDbDurationMs.HasValue ? $"{DuckDbDurationMs.Value} ms" : "";
}
public class CollectorHealthRow