From 61f15fceeeb602855fdbb39fa2b1c83409257a8b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:56:22 -0400 Subject: [PATCH] Add per-server collector schedule intervals (#703) Lite: Each server can now have its own collection schedule or inherit the default. v1 configs auto-migrate to v2 format on first load. New CollectorScheduleEditorWindow for per-server/default editing. SettingsWindow replaced inline schedule grid with server summary panel. RemoteCollectorService dispatches collectors per-server. Dashboard: CollectorScheduleWindow title now shows server name. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dashboard/CollectorScheduleWindow.xaml.cs | 7 +- Dashboard/ServerTab.xaml.cs | 2 +- Lite/MainWindow.xaml.cs | 4 +- Lite/Models/ServerScheduleOverride.cs | 22 + ...teCollectorService.BlockedProcessReport.cs | 2 +- .../RemoteCollectorService.Deadlocks.cs | 2 +- Lite/Services/RemoteCollectorService.cs | 22 +- Lite/Services/ScheduleManager.cs | 540 +++++++++++++++--- .../CollectorScheduleEditorWindow.xaml | 87 +++ .../CollectorScheduleEditorWindow.xaml.cs | 345 +++++++++++ Lite/Windows/SettingsWindow.xaml | 76 ++- Lite/Windows/SettingsWindow.xaml.cs | 109 ++-- 12 files changed, 1048 insertions(+), 170 deletions(-) create mode 100644 Lite/Models/ServerScheduleOverride.cs create mode 100644 Lite/Windows/CollectorScheduleEditorWindow.xaml create mode 100644 Lite/Windows/CollectorScheduleEditorWindow.xaml.cs diff --git a/Dashboard/CollectorScheduleWindow.xaml.cs b/Dashboard/CollectorScheduleWindow.xaml.cs index a74f4025..0a0a9123 100644 --- a/Dashboard/CollectorScheduleWindow.xaml.cs +++ b/Dashboard/CollectorScheduleWindow.xaml.cs @@ -123,12 +123,17 @@ public partial class CollectorScheduleWindow : Window } }; - public CollectorScheduleWindow(DatabaseService databaseService) + public CollectorScheduleWindow(DatabaseService databaseService, string? serverDisplayName = null) { InitializeComponent(); _databaseService = databaseService; Loaded += CollectorScheduleWindow_Loaded; Closing += CollectorScheduleWindow_Closing; + + if (!string.IsNullOrEmpty(serverDisplayName)) + { + Title = $"Collector Schedules - {serverDisplayName}"; + } } private void CollectorScheduleWindow_Closing(object? sender, CancelEventArgs e) diff --git a/Dashboard/ServerTab.xaml.cs b/Dashboard/ServerTab.xaml.cs index af1aebe4..f23356af 100644 --- a/Dashboard/ServerTab.xaml.cs +++ b/Dashboard/ServerTab.xaml.cs @@ -1665,7 +1665,7 @@ private void HealthDataGrid_MouseDoubleClick(object sender, System.Windows.Input private void EditSchedules_Click(object sender, RoutedEventArgs e) { - var scheduleWindow = new CollectorScheduleWindow(_databaseService); + var scheduleWindow = new CollectorScheduleWindow(_databaseService, _serverConnection.DisplayName); scheduleWindow.Owner = Window.GetWindow(this); scheduleWindow.ShowDialog(); } diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index f86b1a48..2647faf3 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -557,7 +557,7 @@ private async void ConnectToServer(ServerConnection server) { if (_collectorService != null) { - var onLoadCollectors = _scheduleManager.GetOnLoadCollectors(); + var onLoadCollectors = _scheduleManager.GetOnLoadCollectorsForServer(server.Id); foreach (var collector in onLoadCollectors) { try @@ -848,7 +848,7 @@ private void ManageServersButton_Click(object sender, RoutedEventArgs e) private async void SettingsButton_Click(object sender, RoutedEventArgs e) { - var window = new SettingsWindow(_scheduleManager, _backgroundService, _mcpService, _muteRuleService) { Owner = this }; + var window = new SettingsWindow(_scheduleManager, _serverManager, _backgroundService, _mcpService, _muteRuleService) { Owner = this }; window.ShowDialog(); UpdateStatusBar(); diff --git a/Lite/Models/ServerScheduleOverride.cs b/Lite/Models/ServerScheduleOverride.cs new file mode 100644 index 00000000..d661220e --- /dev/null +++ b/Lite/Models/ServerScheduleOverride.cs @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor Lite. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace PerformanceMonitorLite.Models; + +/// +/// Per-server schedule override. Contains the full collector list for one server. +/// When present, replaces the default schedule entirely for that server. +/// +public class ServerScheduleOverride +{ + [JsonPropertyName("collectors")] + public List Collectors { get; set; } = new(); +} diff --git a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs index cd5c90a7..5ed567f6 100644 --- a/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs +++ b/Lite/Services/RemoteCollectorService.BlockedProcessReport.cs @@ -32,7 +32,7 @@ public partial class RemoteCollectorService public async Task EnsureBlockedProcessXeSessionAsync(ServerConnection server, int engineEdition = 0, CancellationToken cancellationToken = default) { /* Skip if the blocked_process_report collector is disabled */ - var schedule = _scheduleManager.GetSchedule("blocked_process_report"); + var schedule = _scheduleManager.GetScheduleForServer(server.Id, "blocked_process_report"); if (schedule == null || !schedule.Enabled) { return; diff --git a/Lite/Services/RemoteCollectorService.Deadlocks.cs b/Lite/Services/RemoteCollectorService.Deadlocks.cs index 22c8e4df..68bd656e 100644 --- a/Lite/Services/RemoteCollectorService.Deadlocks.cs +++ b/Lite/Services/RemoteCollectorService.Deadlocks.cs @@ -32,7 +32,7 @@ public partial class RemoteCollectorService public async Task EnsureDeadlockXeSessionAsync(ServerConnection server, int engineEdition = 0, CancellationToken cancellationToken = default) { /* Skip if the deadlock collector is disabled */ - var schedule = _scheduleManager.GetSchedule("deadlocks"); + var schedule = _scheduleManager.GetScheduleForServer(server.Id, "deadlocks"); if (schedule == null || !schedule.Enabled) { return; diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index afbd4895..be1f9576 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -235,10 +235,9 @@ Record the error message so the user can see what's wrong. */ /// public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = default) { - var dueCollectors = _scheduleManager.GetDueCollectors(); var enabledServers = _serverManager.GetEnabledServers(); - if (dueCollectors.Count == 0 || enabledServers.Count == 0) + if (enabledServers.Count == 0) { return; } @@ -258,15 +257,22 @@ public async Task RunDueCollectorsAsync(CancellationToken cancellationToken = de onlineServers.Add(server); } - _logger?.LogInformation("Running {CollectorCount} collectors for {OnlineCount}/{TotalCount} servers ({SkippedCount} offline, skipped)", - dueCollectors.Count, onlineServers.Count, enabledServers.Count, skippedOffline); + if (onlineServers.Count == 0) + { + return; + } + + _logger?.LogInformation("Checking per-server schedules for {OnlineCount}/{TotalCount} servers ({SkippedCount} offline, skipped)", + onlineServers.Count, enabledServers.Count, skippedOffline); /* Run servers in parallel, but collectors within each server sequentially. DuckDB is single-writer; running all collectors in parallel causes spin-wait contention (50%+ CPU, multi-second stalls). Sequential per-server eliminates - this while still allowing multi-server parallelism. */ + this while still allowing multi-server parallelism. + Each server gets its own due-collector list from per-server schedules. */ var serverTasks = onlineServers.Select(server => Task.Run(async () => { + var dueCollectors = _scheduleManager.GetDueCollectorsForServer(server.Id); foreach (var collector in dueCollectors) { await RunCollectorAsync(server, collector.Name, cancellationToken); @@ -299,8 +305,8 @@ CHECKPOINT reorganizes/truncates the database file. */ /// public async Task RunAllCollectorsForServerAsync(ServerConnection server, CancellationToken cancellationToken = default) { - var enabledSchedules = _scheduleManager.GetEnabledSchedules() - .Concat(_scheduleManager.GetOnLoadCollectors()) + var enabledSchedules = _scheduleManager.GetSchedulesForServer(server.Id) + .Where(s => s.Enabled) .ToList(); /* Ensure XE sessions are set up before collecting */ @@ -396,7 +402,7 @@ public async Task RunCollectorAsync(ServerConnection server, string collectorNam _ => throw new ArgumentException($"Unknown collector: {collectorName}") }; - _scheduleManager.MarkCollectorRun(collectorName, startTime); + _scheduleManager.MarkCollectorRunForServer(server.Id, collectorName, startTime); var elapsed = (int)(DateTime.UtcNow - startTime).TotalMilliseconds; AppLogger.Info("Collector", $" [{server.DisplayName}] {collectorName} => {rowsCollected} rows in {elapsed}ms (sql:{_lastSqlMs}ms, duck:{_lastDuckDbMs}ms)"); diff --git a/Lite/Services/ScheduleManager.cs b/Lite/Services/ScheduleManager.cs index 6fd8c848..d6a2bbd9 100644 --- a/Lite/Services/ScheduleManager.cs +++ b/Lite/Services/ScheduleManager.cs @@ -19,6 +19,7 @@ namespace PerformanceMonitorLite.Services; /// /// Manages collector schedules and determines when each collector should run. +/// Supports per-server schedule overrides (v2 config format). /// public class ScheduleManager { @@ -63,82 +64,96 @@ public class ScheduleManager private readonly string _schedulePath; private readonly ILogger? _logger; - private List _schedules; private readonly object _lock = new(); + private List _defaultSchedule; + private Dictionary _serverOverrides; + + /// + /// Per-server runtime state: serverId → (collectorName → lastRunTime). + /// Kept separate from config because runtime state is not persisted to JSON. + /// + private readonly Dictionary> _serverRunState = new(); + public ScheduleManager(string configDirectory, ILogger? logger = null) { _schedulePath = Path.Combine(configDirectory, "collection_schedule.json"); _logger = logger; - _schedules = new List(); + _defaultSchedule = new List(); + _serverOverrides = new Dictionary(); LoadSchedules(); } + // ────────────────────────────────────────────────────────────────── + // Existing public API — operates on the default schedule. + // These methods are unchanged from v1 so existing callers keep working. + // ────────────────────────────────────────────────────────────────── + /// - /// Gets all configured collector schedules. + /// Gets all configured collector schedules (default schedule). /// public IReadOnlyList GetAllSchedules() { lock (_lock) { - return _schedules.ToList(); + return _defaultSchedule.ToList(); } } /// - /// Gets only enabled and scheduled collectors. + /// Gets only enabled and scheduled collectors (default schedule). /// public IReadOnlyList GetEnabledSchedules() { lock (_lock) { - return _schedules.Where(s => s.Enabled && s.IsScheduled).ToList(); + return _defaultSchedule.Where(s => s.Enabled && s.IsScheduled).ToList(); } } /// - /// Gets collectors that are due to run. + /// Gets collectors that are due to run (default schedule, global run state). /// public IReadOnlyList GetDueCollectors() { lock (_lock) { - return _schedules.Where(s => s.IsDue).ToList(); + return _defaultSchedule.Where(s => s.IsDue).ToList(); } } /// - /// Gets on-load only collectors (frequency = 0). + /// Gets on-load only collectors (frequency = 0) from the default schedule. /// public IReadOnlyList GetOnLoadCollectors() { lock (_lock) { - return _schedules.Where(s => s.Enabled && !s.IsScheduled).ToList(); + return _defaultSchedule.Where(s => s.Enabled && !s.IsScheduled).ToList(); } } /// - /// Gets a specific collector schedule by name. + /// Gets a specific collector schedule by name (default schedule). /// public CollectorSchedule? GetSchedule(string collectorName) { lock (_lock) { - return _schedules.FirstOrDefault(s => + return _defaultSchedule.FirstOrDefault(s => s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); } } /// - /// Marks a collector as having been run. + /// Marks a collector as having been run (default schedule, global run state). /// public void MarkCollectorRun(string collectorName, DateTime runTime) { lock (_lock) { - var schedule = _schedules.FirstOrDefault(s => + var schedule = _defaultSchedule.FirstOrDefault(s => s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); if (schedule != null) @@ -157,13 +172,13 @@ public void MarkCollectorRun(string collectorName, DateTime runTime) } /// - /// Updates a collector's schedule settings. + /// Updates a collector's schedule settings (default schedule). /// public void UpdateSchedule(string collectorName, bool? enabled = null, int? frequencyMinutes = null, int? retentionDays = null) { lock (_lock) { - var schedule = _schedules.FirstOrDefault(s => + var schedule = _defaultSchedule.FirstOrDefault(s => s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); if (schedule == null) @@ -194,33 +209,18 @@ public void UpdateSchedule(string collectorName, bool? enabled = null, int? freq } /// - /// Detects which preset matches the current intervals, or returns "Custom". + /// Detects which preset matches the current default schedule intervals, or returns "Custom". /// public string GetActivePreset() { lock (_lock) { - foreach (var (presetName, intervals) in s_presets) - { - bool matches = true; - foreach (var (collector, freq) in intervals) - { - var schedule = _schedules.FirstOrDefault(s => - s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); - if (schedule != null && schedule.FrequencyMinutes != freq) - { - matches = false; - break; - } - } - if (matches) return presetName; - } - return "Custom"; + return DetectPreset(_defaultSchedule); } } /// - /// Applies a named preset, changing all scheduled collector frequencies. + /// Applies a named preset to the default schedule. /// Does not modify enabled/disabled state or on-load (frequency=0) collectors. /// public void ApplyPreset(string presetName) @@ -232,31 +232,287 @@ public void ApplyPreset(string presetName) lock (_lock) { - foreach (var (collector, freq) in intervals) + ApplyPresetToList(_defaultSchedule, intervals); + SaveSchedules(); + + _logger?.LogInformation("Applied collection preset '{Preset}' to default schedule", presetName); + } + } + + // ────────────────────────────────────────────────────────────────── + // New per-server API (v2) + // ────────────────────────────────────────────────────────────────── + + /// + /// Returns the default schedule list. + /// + public IReadOnlyList GetDefaultSchedule() + { + lock (_lock) + { + return _defaultSchedule.ToList(); + } + } + + /// + /// Returns the schedule for a specific server. + /// If the server has an override, returns those collectors; otherwise returns a copy of the default. + /// + public IReadOnlyList GetSchedulesForServer(string serverId) + { + lock (_lock) + { + if (_serverOverrides.TryGetValue(serverId, out var over)) { - var schedule = _schedules.FirstOrDefault(s => - s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); - if (schedule != null) + return over.Collectors.ToList(); + } + + return CloneScheduleList(_defaultSchedule); + } + } + + /// + /// Gets collectors that are due to run for a specific server, using per-server run state. + /// + public IReadOnlyList GetDueCollectorsForServer(string serverId) + { + lock (_lock) + { + var schedules = _serverOverrides.TryGetValue(serverId, out var over) + ? over.Collectors + : _defaultSchedule; + + _serverRunState.TryGetValue(serverId, out var runState); + + var due = new List(); + foreach (var s in schedules) + { + if (!s.Enabled || !s.IsScheduled) + continue; + + if (runState == null || !runState.TryGetValue(s.Name, out var lastRun)) { - schedule.FrequencyMinutes = freq; + due.Add(s); // never run — due immediately + continue; } + + var elapsed = DateTime.UtcNow - lastRun; + if (elapsed.TotalMinutes >= s.FrequencyMinutes) + { + due.Add(s); + } + } + + return due; + } + } + + /// + /// Gets on-load only collectors for a specific server. + /// + public IReadOnlyList GetOnLoadCollectorsForServer(string serverId) + { + lock (_lock) + { + var schedules = _serverOverrides.TryGetValue(serverId, out var over) + ? over.Collectors + : _defaultSchedule; + + return schedules.Where(s => s.Enabled && !s.IsScheduled).ToList(); + } + } + + /// + /// Gets a specific collector schedule by name for a server. + /// + public CollectorSchedule? GetScheduleForServer(string serverId, string collectorName) + { + lock (_lock) + { + var schedules = _serverOverrides.TryGetValue(serverId, out var over) + ? over.Collectors + : _defaultSchedule; + + return schedules.FirstOrDefault(s => + s.Name.Equals(collectorName, StringComparison.OrdinalIgnoreCase)); + } + } + + /// + /// Records a collector run for a specific server. + /// + public void MarkCollectorRunForServer(string serverId, string collectorName, DateTime runTime) + { + lock (_lock) + { + if (!_serverRunState.TryGetValue(serverId, out var runState)) + { + runState = new Dictionary(StringComparer.OrdinalIgnoreCase); + _serverRunState[serverId] = runState; } + runState[collectorName] = runTime; + + _logger?.LogDebug("Marked collector '{Name}' as run for server {ServerId} at {Time}", + collectorName, serverId, runTime); + } + } + + /// + /// Creates or updates a per-server schedule override. + /// + public void SetScheduleForServer(string serverId, List schedules) + { + lock (_lock) + { + _serverOverrides[serverId] = new ServerScheduleOverride { Collectors = schedules }; SaveSchedules(); - _logger?.LogInformation("Applied collection preset '{Preset}'", presetName); + _logger?.LogInformation("Set schedule override for server {ServerId} ({Count} collectors)", + serverId, schedules.Count); } } /// - /// Loads schedules from the JSON config file. + /// Removes a server's schedule override, reverting it to the default. + /// + public void RemoveServerOverride(string serverId) + { + lock (_lock) + { + if (_serverOverrides.Remove(serverId)) + { + SaveSchedules(); + _logger?.LogInformation("Removed schedule override for server {ServerId}", serverId); + } + } + } + + /// + /// Returns true if the server has a custom schedule override. + /// + public bool HasServerOverride(string serverId) + { + lock (_lock) + { + return _serverOverrides.ContainsKey(serverId); + } + } + + /// + /// Applies a preset to a single server's schedule. + /// Creates an override if one doesn't exist (copies default first). + /// + public void ApplyPresetForServer(string serverId, string presetName) + { + if (!s_presets.TryGetValue(presetName, out var intervals)) + { + throw new ArgumentException($"Unknown preset: {presetName}"); + } + + lock (_lock) + { + if (!_serverOverrides.TryGetValue(serverId, out var over)) + { + over = new ServerScheduleOverride { Collectors = CloneScheduleList(_defaultSchedule) }; + _serverOverrides[serverId] = over; + } + + ApplyPresetToList(over.Collectors, intervals); + SaveSchedules(); + + _logger?.LogInformation("Applied preset '{Preset}' to server {ServerId}", presetName, serverId); + } + } + + /// + /// Applies a preset to the default schedule (alias for ApplyPreset). + /// + public void ApplyPresetToDefault(string presetName) + { + ApplyPreset(presetName); + } + + /// + /// Detects which preset matches a server's active schedule. + /// + public string GetActivePresetForServer(string serverId) + { + lock (_lock) + { + var schedules = _serverOverrides.TryGetValue(serverId, out var over) + ? over.Collectors + : _defaultSchedule; + + return DetectPreset(schedules); + } + } + + /// + /// Removes orphaned overrides for servers that no longer exist. + /// + public void CleanupRemovedServers(IEnumerable activeServerIds) + { + lock (_lock) + { + var activeSet = new HashSet(activeServerIds); + var orphaned = _serverOverrides.Keys.Where(id => !activeSet.Contains(id)).ToList(); + + if (orphaned.Count == 0) + return; + + foreach (var id in orphaned) + { + _serverOverrides.Remove(id); + _serverRunState.Remove(id); + } + + SaveSchedules(); + + _logger?.LogInformation("Cleaned up {Count} orphaned server schedule override(s)", orphaned.Count); + } + } + + // ────────────────────────────────────────────────────────────────── + // Persistence + // ────────────────────────────────────────────────────────────────── + + /// + /// Saves schedules to the JSON config file (v2 format). + /// + public void SaveSchedules() + { + lock (_lock) + { + try + { + var config = new ScheduleConfigV2 + { + Version = 2, + DefaultSchedule = _defaultSchedule, + ServerOverrides = _serverOverrides + }; + string json = JsonSerializer.Serialize(config, s_jsonOptions); + File.WriteAllText(_schedulePath, json); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to save collection_schedule.json"); + throw; + } + } + } + + /// + /// Loads schedules from the JSON config file, handling v1→v2 migration. /// private void LoadSchedules() { if (!File.Exists(_schedulePath)) { _logger?.LogInformation("Schedule file not found, using defaults"); - _schedules = GetDefaultSchedules(); + _defaultSchedule = GetDefaultSchedules(); + _serverOverrides = new Dictionary(); SaveSchedules(); return; } @@ -264,16 +520,35 @@ private void LoadSchedules() try { string json = File.ReadAllText(_schedulePath); - var config = JsonSerializer.Deserialize(json); - _schedules = config?.Collectors ?? GetDefaultSchedules(); - /* Create backup of valid config */ - try { File.Copy(_schedulePath, _schedulePath + ".bak", overwrite: true); } - catch { /* best effort */ } + if (TryLoadV2(json)) + { + /* Create backup of valid config */ + try { File.Copy(_schedulePath, _schedulePath + ".bak", overwrite: true); } + catch { /* best effort */ } + + _logger?.LogInformation( + "Loaded v2 schedule config: {DefaultCount} default collectors, {OverrideCount} server override(s)", + _defaultSchedule.Count, _serverOverrides.Count); + } + else + { + /* v1 format — migrate */ + var v1Config = JsonSerializer.Deserialize(json); + _defaultSchedule = v1Config?.Collectors ?? GetDefaultSchedules(); + _serverOverrides = new Dictionary(); - _logger?.LogInformation("Loaded {Count} collector schedules from configuration", _schedules.Count); + /* Backup the v1 file before overwriting */ + try { File.Copy(_schedulePath, _schedulePath + ".v1.bak", overwrite: true); } + catch { /* best effort */ } + + SaveSchedules(); + + _logger?.LogInformation( + "Migrated v1 schedule config to v2: {Count} collectors moved to default_schedule", + _defaultSchedule.Count); + } - /* Add any new default collectors that are missing from the saved config */ MergeNewDefaults(); } catch (Exception ex) @@ -287,79 +562,167 @@ private void LoadSchedules() try { string bakJson = File.ReadAllText(bakPath); - var bakConfig = JsonSerializer.Deserialize(bakJson); - _schedules = bakConfig?.Collectors ?? GetDefaultSchedules(); - _logger?.LogInformation("Restored schedules from backup file"); + if (TryLoadV2(bakJson)) + { + _logger?.LogInformation("Restored schedules from backup file"); + return; + } + + var bakConfig = JsonSerializer.Deserialize(bakJson); + _defaultSchedule = bakConfig?.Collectors ?? GetDefaultSchedules(); + _serverOverrides = new Dictionary(); + _logger?.LogInformation("Restored v1 schedules from backup file"); return; } catch { /* backup also corrupt, fall through to defaults */ } } - _schedules = GetDefaultSchedules(); + _defaultSchedule = GetDefaultSchedules(); + _serverOverrides = new Dictionary(); SaveSchedules(); } } /// - /// Merges any new default collectors that are missing from the loaded config, - /// and removes any obsolete/renamed collectors that no longer have a dispatch case. - /// This handles the case where new collectors are added to the code but the user - /// has an existing config file from an older version. + /// Attempts to load JSON as v2 format. Returns true if successful. + /// + private bool TryLoadV2(string json) + { + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("version", out var versionProp) || versionProp.GetInt32() < 2) + return false; + + var config = JsonSerializer.Deserialize(json); + if (config == null) + return false; + + _defaultSchedule = config.DefaultSchedule ?? GetDefaultSchedules(); + _serverOverrides = config.ServerOverrides ?? new Dictionary(); + return true; + } + + /// + /// Merges any new default collectors into the default schedule and all server overrides. + /// Also removes obsolete collectors that no longer have a dispatch case. /// private void MergeNewDefaults() { var defaults = GetDefaultSchedules(); var defaultNames = new HashSet(defaults.Select(s => s.Name), StringComparer.OrdinalIgnoreCase); - var loadedNames = new HashSet(_schedules.Select(s => s.Name), StringComparer.OrdinalIgnoreCase); var changed = false; - /* Remove obsolete collectors that are no longer in the defaults - (e.g., blocking_snapshot was renamed to blocked_process_report) */ - var removed = _schedules.RemoveAll(s => !defaultNames.Contains(s.Name)); + /* Merge into default schedule */ + changed |= MergeIntoList(_defaultSchedule, defaults, defaultNames); + + /* Merge into each server override */ + foreach (var over in _serverOverrides.Values) + { + changed |= MergeIntoList(over.Collectors, defaults, defaultNames); + } + + if (changed) + { + SaveSchedules(); + } + } + + /// + /// Merges new defaults into a collector list. Removes obsolete, adds missing. + /// Returns true if any changes were made. + /// + private bool MergeIntoList(List list, List defaults, HashSet defaultNames) + { + var loadedNames = new HashSet(list.Select(s => s.Name), StringComparer.OrdinalIgnoreCase); + var changed = false; + + /* Remove obsolete collectors */ + var removed = list.RemoveAll(s => !defaultNames.Contains(s.Name)); if (removed > 0) { _logger?.LogInformation("Removed {Count} obsolete collector(s) from schedule", removed); changed = true; } - /* Add any new default collectors that are missing */ + /* Add missing collectors */ foreach (var defaultSchedule in defaults) { if (!loadedNames.Contains(defaultSchedule.Name)) { - _schedules.Add(defaultSchedule); + list.Add(CloneSchedule(defaultSchedule)); _logger?.LogInformation("Added missing collector '{Name}' from defaults", defaultSchedule.Name); changed = true; } } - if (changed) - { - SaveSchedules(); - } + return changed; } + // ────────────────────────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────────────────────────── + /// - /// Saves schedules to the JSON config file. + /// Detects which preset matches a list of collector schedules. /// - public void SaveSchedules() + private static string DetectPreset(List schedules) { - lock (_lock) + foreach (var (presetName, intervals) in s_presets) { - try + bool matches = true; + foreach (var (collector, freq) in intervals) { - var config = new ScheduleConfig { Collectors = _schedules }; - string json = JsonSerializer.Serialize(config, s_jsonOptions); - File.WriteAllText(_schedulePath, json); + var schedule = schedules.FirstOrDefault(s => + s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); + if (schedule != null && schedule.FrequencyMinutes != freq) + { + matches = false; + break; + } } - catch (Exception ex) + if (matches) return presetName; + } + return "Custom"; + } + + /// + /// Applies preset intervals to a collector list. Does not touch enabled/disabled or on-load collectors. + /// + private static void ApplyPresetToList(List schedules, Dictionary intervals) + { + foreach (var (collector, freq) in intervals) + { + var schedule = schedules.FirstOrDefault(s => + s.Name.Equals(collector, StringComparison.OrdinalIgnoreCase)); + if (schedule != null) { - _logger?.LogError(ex, "Failed to save collection_schedule.json"); - throw; + schedule.FrequencyMinutes = freq; } } } + /// + /// Deep-clones a schedule list (for creating overrides from defaults). + /// + private static List CloneScheduleList(List source) + { + return source.Select(CloneSchedule).ToList(); + } + + /// + /// Deep-clones a single CollectorSchedule (config properties only, not runtime state). + /// + private static CollectorSchedule CloneSchedule(CollectorSchedule s) + { + return new CollectorSchedule + { + Name = s.Name, + Enabled = s.Enabled, + FrequencyMinutes = s.FrequencyMinutes, + RetentionDays = s.RetentionDays, + Description = s.Description + }; + } + /// /// Gets the default collector schedules. /// @@ -393,12 +756,31 @@ private static List GetDefaultSchedules() }; } + // ────────────────────────────────────────────────────────────────── + // JSON config models + // ────────────────────────────────────────────────────────────────── + /// - /// JSON wrapper for schedules list. + /// v1 JSON format: { "collectors": [...] } /// - private class ScheduleConfig + private class ScheduleConfigV1 { [JsonPropertyName("collectors")] public List Collectors { get; set; } = new(); } + + /// + /// v2 JSON format: { "version": 2, "default_schedule": [...], "server_overrides": { "guid": { "collectors": [...] } } } + /// + private class ScheduleConfigV2 + { + [JsonPropertyName("version")] + public int Version { get; set; } = 2; + + [JsonPropertyName("default_schedule")] + public List DefaultSchedule { get; set; } = new(); + + [JsonPropertyName("server_overrides")] + public Dictionary ServerOverrides { get; set; } = new(); + } } diff --git a/Lite/Windows/CollectorScheduleEditorWindow.xaml b/Lite/Windows/CollectorScheduleEditorWindow.xaml new file mode 100644 index 00000000..d3aa581c --- /dev/null +++ b/Lite/Windows/CollectorScheduleEditorWindow.xaml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +