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] предупреждения по диску, памяти, inode и недоступности;
|
||||
- [x] автоматическое обновление каждые 30 секунд;
|
||||
- [ ] короткая локальная история;
|
||||
- [ ] графики и экспорт диагностики без секретов;
|
||||
- [x] короткая локальная история до 240 точек на сервер;
|
||||
- [x] встроенный график CPU, RAM и диска;
|
||||
- [ ] экспорт диагностики без секретов;
|
||||
- [ ] отдельный прямой SSH-терминал;
|
||||
- [ ] отдельная terminal identity и подтверждение пользователя;
|
||||
- [ ] отдельная automation identity для AI-агента.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
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