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));
+ }
+}