Persist and chart short metrics history

This commit is contained in:
Ochenstarik 2026-07-16 19:46:37 +07:00
parent 594b9aaced
commit 0b642cf9a3
4 changed files with 176 additions and 2 deletions

View file

@ -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-агента.

View file

@ -174,6 +174,7 @@
<ListView
x:Name="ServerList"
ItemsSource="{x:Bind Servers}"
SelectionChanged="ServerList_SelectionChanged"
SelectionMode="Single">
<ListView.ItemTemplate>
<DataTemplate>
@ -213,6 +214,51 @@
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Border
Padding="16"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Spacing="10">
<Grid>
<StackPanel Spacing="2">
<TextBlock FontWeight="SemiBold" Text="История ресурсов" />
<TextBlock
x:Name="HistoryCaptionText"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Выберите сервер"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal" Spacing="12">
<TextBlock Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}" Text="CPU" />
<TextBlock Foreground="{ThemeResource SystemFillColorCautionBrush}" Text="RAM" />
<TextBlock Foreground="{ThemeResource SystemFillColorCriticalBrush}" Text="Диск" />
</StackPanel>
</Grid>
<Grid
x:Name="HistoryChart"
Height="170"
SizeChanged="HistoryChart_SizeChanged">
<Polyline
x:Name="CpuHistoryLine"
Stroke="{ThemeResource AccentFillColorDefaultBrush}"
StrokeLineJoin="Round"
StrokeThickness="2" />
<Polyline
x:Name="MemoryHistoryLine"
Stroke="{ThemeResource SystemFillColorCautionBrush}"
StrokeLineJoin="Round"
StrokeThickness="2" />
<Polyline
x:Name="DiskHistoryLine"
Stroke="{ThemeResource SystemFillColorCriticalBrush}"
StrokeLineJoin="Round"
StrokeThickness="2" />
</Grid>
</StackPanel>
</Border>
</StackPanel>
<Border

View file

@ -3,7 +3,9 @@ using System.Globalization;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Windows.ApplicationModel.DataTransfer;
using Windows.Foundation;
namespace ServerMonitorManager_Desktop;
@ -11,6 +13,8 @@ public sealed partial class MainPage : Page
{
private readonly ServerStorage _storage = new();
private readonly SshMonitorService _ssh = new();
private readonly MetricsHistoryStorage _historyStorage = new();
private readonly List<MetricSampleData> _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<double> 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();

View file

@ -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<List<MetricSampleData>> LoadAsync()
{
try
{
var file = await ApplicationData.Current.LocalFolder.GetFileAsync(FileName);
var json = await FileIO.ReadTextAsync(file);
return JsonSerializer.Deserialize<List<MetricSampleData>>(json, JsonOptions) ?? [];
}
catch (FileNotFoundException)
{
return [];
}
catch (JsonException)
{
return [];
}
}
public async Task SaveAsync(IEnumerable<MetricSampleData> 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));
}
}