Persist and chart short metrics history
This commit is contained in:
parent
594b9aaced
commit
0b642cf9a3
4 changed files with 176 additions and 2 deletions
|
|
@ -61,8 +61,9 @@
|
||||||
- [x] swap, inode, network и состояние SSH/WireGuard;
|
- [x] swap, inode, network и состояние SSH/WireGuard;
|
||||||
- [x] предупреждения по диску, памяти, inode и недоступности;
|
- [x] предупреждения по диску, памяти, inode и недоступности;
|
||||||
- [x] автоматическое обновление каждые 30 секунд;
|
- [x] автоматическое обновление каждые 30 секунд;
|
||||||
- [ ] короткая локальная история;
|
- [x] короткая локальная история до 240 точек на сервер;
|
||||||
- [ ] графики и экспорт диагностики без секретов;
|
- [x] встроенный график CPU, RAM и диска;
|
||||||
|
- [ ] экспорт диагностики без секретов;
|
||||||
- [ ] отдельный прямой SSH-терминал;
|
- [ ] отдельный прямой SSH-терминал;
|
||||||
- [ ] отдельная terminal identity и подтверждение пользователя;
|
- [ ] отдельная terminal identity и подтверждение пользователя;
|
||||||
- [ ] отдельная automation identity для AI-агента.
|
- [ ] отдельная automation identity для AI-агента.
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,7 @@
|
||||||
<ListView
|
<ListView
|
||||||
x:Name="ServerList"
|
x:Name="ServerList"
|
||||||
ItemsSource="{x:Bind Servers}"
|
ItemsSource="{x:Bind Servers}"
|
||||||
|
SelectionChanged="ServerList_SelectionChanged"
|
||||||
SelectionMode="Single">
|
SelectionMode="Single">
|
||||||
<ListView.ItemTemplate>
|
<ListView.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
|
|
@ -213,6 +214,51 @@
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ListView.ItemTemplate>
|
</ListView.ItemTemplate>
|
||||||
</ListView>
|
</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>
|
</StackPanel>
|
||||||
|
|
||||||
<Border
|
<Border
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ using System.Globalization;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Automation;
|
using Microsoft.UI.Xaml.Automation;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
using Windows.ApplicationModel.DataTransfer;
|
using Windows.ApplicationModel.DataTransfer;
|
||||||
|
using Windows.Foundation;
|
||||||
|
|
||||||
namespace ServerMonitorManager_Desktop;
|
namespace ServerMonitorManager_Desktop;
|
||||||
|
|
||||||
|
|
@ -11,6 +13,8 @@ public sealed partial class MainPage : Page
|
||||||
{
|
{
|
||||||
private readonly ServerStorage _storage = new();
|
private readonly ServerStorage _storage = new();
|
||||||
private readonly SshMonitorService _ssh = 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 DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
|
||||||
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
private readonly SemaphoreSlim _refreshLock = new(1, 1);
|
||||||
private bool _loaded;
|
private bool _loaded;
|
||||||
|
|
@ -36,11 +40,17 @@ public sealed partial class MainPage : Page
|
||||||
|
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
_refreshTimer.Start();
|
_refreshTimer.Start();
|
||||||
|
_history.AddRange(await _historyStorage.LoadAsync());
|
||||||
foreach (var profile in await _storage.LoadAsync())
|
foreach (var profile in await _storage.LoadAsync())
|
||||||
{
|
{
|
||||||
Servers.Add(new ServerViewModel(profile));
|
Servers.Add(new ServerViewModel(profile));
|
||||||
}
|
}
|
||||||
UpdateEmptyState();
|
UpdateEmptyState();
|
||||||
|
if (Servers.Count > 0)
|
||||||
|
{
|
||||||
|
ServerList.SelectedIndex = 0;
|
||||||
|
RenderHistory();
|
||||||
|
}
|
||||||
|
|
||||||
if (Servers.Count > 0)
|
if (Servers.Count > 0)
|
||||||
{
|
{
|
||||||
|
|
@ -266,7 +276,9 @@ public sealed partial class MainPage : Page
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Servers.Remove(selected);
|
Servers.Remove(selected);
|
||||||
|
_history.RemoveAll(sample => sample.ServerId == selected.Profile.Id);
|
||||||
await SaveProfilesAsync();
|
await SaveProfilesAsync();
|
||||||
|
await _historyStorage.SaveAsync(_history);
|
||||||
UpdateEmptyState();
|
UpdateEmptyState();
|
||||||
ShowInfo("Профиль удалён", selected.Name, InfoBarSeverity.Success);
|
ShowInfo("Профиль удалён", selected.Name, InfoBarSeverity.Success);
|
||||||
}
|
}
|
||||||
|
|
@ -289,6 +301,7 @@ public sealed partial class MainPage : Page
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.WhenAll(Servers.Select(RefreshServerAsync));
|
await Task.WhenAll(Servers.Select(RefreshServerAsync));
|
||||||
|
await _historyStorage.SaveAsync(_history);
|
||||||
var online = Servers.Count(server => server.IsOnline);
|
var online = Servers.Count(server => server.IsOnline);
|
||||||
var warnings = Servers.Count(server => !server.IsOnline || server.HasWarning);
|
var warnings = Servers.Count(server => !server.IsOnline || server.HasWarning);
|
||||||
AvailabilityValueText.Text = $"{online} / {Servers.Count}";
|
AvailabilityValueText.Text = $"{online} / {Servers.Count}";
|
||||||
|
|
@ -329,6 +342,25 @@ public sealed partial class MainPage : Page
|
||||||
server.LatencyText = $"{metrics.Latency.TotalMilliseconds:F0} ms";
|
server.LatencyText = $"{metrics.Latency.TotalMilliseconds:F0} ms";
|
||||||
server.Status = $"Онлайн · uptime {FormatUptime(metrics.Uptime)}";
|
server.Status = $"Онлайн · uptime {FormatUptime(metrics.Uptime)}";
|
||||||
server.IsOnline = true;
|
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)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|
@ -381,6 +413,52 @@ public sealed partial class MainPage : Page
|
||||||
? $"{bytes / 1024d / 1024d / 1024d:F1} GB"
|
? $"{bytes / 1024d / 1024d / 1024d:F1} GB"
|
||||||
: $"{bytes / 1024d / 1024d:F1} MB";
|
: $"{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)
|
private static string CompactError(Exception exception)
|
||||||
{
|
{
|
||||||
var firstLine = exception.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
var firstLine = exception.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).FirstOrDefault();
|
||||||
|
|
|
||||||
49
src/ServerMonitorManager.Desktop/MetricsHistoryStorage.cs
Normal file
49
src/ServerMonitorManager.Desktop/MetricsHistoryStorage.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue