diff --git a/docs/roadmap.md b/docs/roadmap.md index 303dd83..ff175ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -61,8 +61,9 @@ - [x] swap, inode, network и состояние SSH/WireGuard; - [x] предупреждения по диску, памяти, inode и недоступности; - [x] автоматическое обновление каждые 30 секунд; -- [ ] короткая локальная история; -- [ ] графики и экспорт диагностики без секретов; +- [x] короткая локальная история до 240 точек на сервер; +- [x] встроенный график CPU, RAM и диска; +- [ ] экспорт диагностики без секретов; - [ ] отдельный прямой SSH-терминал; - [ ] отдельная terminal identity и подтверждение пользователя; - [ ] отдельная automation identity для AI-агента. diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml b/src/ServerMonitorManager.Desktop/MainPage.xaml index a040abf..7a3382e 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml @@ -174,6 +174,7 @@ @@ -213,6 +214,51 @@ + + + + + + + + + + + + + + + + + + + + + _history = []; private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) }; private readonly SemaphoreSlim _refreshLock = new(1, 1); private bool _loaded; @@ -36,11 +40,17 @@ public sealed partial class MainPage : Page _loaded = true; _refreshTimer.Start(); + _history.AddRange(await _historyStorage.LoadAsync()); foreach (var profile in await _storage.LoadAsync()) { Servers.Add(new ServerViewModel(profile)); } UpdateEmptyState(); + if (Servers.Count > 0) + { + ServerList.SelectedIndex = 0; + RenderHistory(); + } if (Servers.Count > 0) { @@ -266,7 +276,9 @@ public sealed partial class MainPage : Page return; } Servers.Remove(selected); + _history.RemoveAll(sample => sample.ServerId == selected.Profile.Id); await SaveProfilesAsync(); + await _historyStorage.SaveAsync(_history); UpdateEmptyState(); ShowInfo("Профиль удалён", selected.Name, InfoBarSeverity.Success); } @@ -289,6 +301,7 @@ public sealed partial class MainPage : Page } await Task.WhenAll(Servers.Select(RefreshServerAsync)); + await _historyStorage.SaveAsync(_history); var online = Servers.Count(server => server.IsOnline); var warnings = Servers.Count(server => !server.IsOnline || server.HasWarning); AvailabilityValueText.Text = $"{online} / {Servers.Count}"; @@ -329,6 +342,25 @@ public sealed partial class MainPage : Page server.LatencyText = $"{metrics.Latency.TotalMilliseconds:F0} ms"; server.Status = $"Онлайн · uptime {FormatUptime(metrics.Uptime)}"; server.IsOnline = true; + _history.Add(new MetricSampleData( + server.Profile.Id, + DateTimeOffset.Now, + metrics.CpuPercent, + memoryPercent, + diskPercent)); + var overflow = _history + .Where(sample => sample.ServerId == server.Profile.Id) + .OrderByDescending(sample => sample.Timestamp) + .Skip(240) + .ToList(); + foreach (var sample in overflow) + { + _history.Remove(sample); + } + if (ServerList.SelectedItem == server) + { + RenderHistory(); + } } catch (Exception exception) { @@ -381,6 +413,52 @@ public sealed partial class MainPage : Page ? $"{bytes / 1024d / 1024d / 1024d:F1} GB" : $"{bytes / 1024d / 1024d:F1} MB"; + private void ServerList_SelectionChanged(object sender, SelectionChangedEventArgs e) + => RenderHistory(); + + private void HistoryChart_SizeChanged(object sender, SizeChangedEventArgs e) + => RenderHistory(); + + private void RenderHistory() + { + if (ServerList.SelectedItem is not ServerViewModel server) + { + HistoryCaptionText.Text = "Выберите сервер"; + CpuHistoryLine.Points = new PointCollection(); + MemoryHistoryLine.Points = new PointCollection(); + DiskHistoryLine.Points = new PointCollection(); + return; + } + + var samples = _history + .Where(sample => sample.ServerId == server.Profile.Id) + .OrderBy(sample => sample.Timestamp) + .ToList(); + HistoryCaptionText.Text = samples.Count == 0 + ? $"{server.Name} · данные появятся после обновления" + : $"{server.Name} · {samples.Count} точек · {samples[0].Timestamp:dd.MM HH:mm} — {samples[^1].Timestamp:dd.MM HH:mm}"; + CpuHistoryLine.Points = BuildHistoryPoints(samples.Select(sample => sample.CpuPercent).ToList()); + MemoryHistoryLine.Points = BuildHistoryPoints(samples.Select(sample => sample.MemoryPercent).ToList()); + DiskHistoryLine.Points = BuildHistoryPoints(samples.Select(sample => sample.DiskPercent).ToList()); + } + + private PointCollection BuildHistoryPoints(IReadOnlyList values) + { + var points = new PointCollection(); + if (values.Count == 0 || HistoryChart.ActualWidth <= 0 || HistoryChart.ActualHeight <= 0) + { + return points; + } + var step = values.Count == 1 ? 0 : HistoryChart.ActualWidth / (values.Count - 1); + for (var index = 0; index < values.Count; index++) + { + var x = values.Count == 1 ? HistoryChart.ActualWidth : index * step; + var y = HistoryChart.ActualHeight * (1 - Math.Clamp(values[index], 0, 100) / 100d); + points.Add(new Point(x, y)); + } + return points; + } + private static string CompactError(Exception exception) { var firstLine = exception.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); diff --git a/src/ServerMonitorManager.Desktop/MetricsHistoryStorage.cs b/src/ServerMonitorManager.Desktop/MetricsHistoryStorage.cs new file mode 100644 index 0000000..8121b54 --- /dev/null +++ b/src/ServerMonitorManager.Desktop/MetricsHistoryStorage.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using Windows.Storage; + +namespace ServerMonitorManager_Desktop; + +public sealed record MetricSampleData( + string ServerId, + DateTimeOffset Timestamp, + double CpuPercent, + double MemoryPercent, + double DiskPercent); + +public sealed class MetricsHistoryStorage +{ + private const string FileName = "metrics-history.json"; + private const int MaxSamplesPerServer = 240; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + + public async Task> LoadAsync() + { + try + { + var file = await ApplicationData.Current.LocalFolder.GetFileAsync(FileName); + var json = await FileIO.ReadTextAsync(file); + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (FileNotFoundException) + { + return []; + } + catch (JsonException) + { + return []; + } + } + + public async Task SaveAsync(IEnumerable samples) + { + var trimmed = samples + .GroupBy(sample => sample.ServerId, StringComparer.Ordinal) + .SelectMany(group => group.OrderByDescending(sample => sample.Timestamp).Take(MaxSamplesPerServer)) + .OrderBy(sample => sample.Timestamp) + .ToList(); + var file = await ApplicationData.Current.LocalFolder.CreateFileAsync( + FileName, + CreationCollisionOption.ReplaceExisting); + await FileIO.WriteTextAsync(file, JsonSerializer.Serialize(trimmed, JsonOptions)); + } +}