Show health warnings and refresh automatically

This commit is contained in:
Ochenstarik 2026-07-16 19:23:47 +07:00
parent 210acb7bbc
commit 594b9aaced
5 changed files with 75 additions and 19 deletions

View file

@ -58,9 +58,10 @@
## Этап 5 — мониторинг и терминал ## Этап 5 — мониторинг и терминал
- [ ] swap, inode, network и выбранные systemd units; - [x] swap, inode, network и состояние SSH/WireGuard;
- [ ] предупреждения по диску, памяти и недоступности; - [x] предупреждения по диску, памяти, inode и недоступности;
- [ ] автоматическое обновление и короткая локальная история; - [x] автоматическое обновление каждые 30 секунд;
- [ ] короткая локальная история;
- [ ] графики и экспорт диагностики без секретов; - [ ] графики и экспорт диагностики без секретов;
- [ ] отдельный прямой SSH-терминал; - [ ] отдельный прямой SSH-терминал;
- [ ] отдельная terminal identity и подтверждение пользователя; - [ ] отдельная terminal identity и подтверждение пользователя;

View file

@ -202,6 +202,7 @@
<StackPanel Grid.Column="3" VerticalAlignment="Center"> <StackPanel Grid.Column="3" VerticalAlignment="Center">
<TextBlock Text="{Binding MemoryText}" /> <TextBlock Text="{Binding MemoryText}" />
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{Binding DiskText}" /> <TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{Binding DiskText}" />
<TextBlock Foreground="{ThemeResource TextFillColorSecondaryBrush}" Text="{Binding HealthText}" />
</StackPanel> </StackPanel>
<TextBlock <TextBlock
Grid.Column="4" Grid.Column="4"

View file

@ -11,12 +11,15 @@ 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 DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) };
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private bool _loaded; private bool _loaded;
public MainPage() public MainPage()
{ {
InitializeComponent(); InitializeComponent();
Loaded += MainPage_Loaded; Loaded += MainPage_Loaded;
_refreshTimer.Tick += async (_, _) => await RefreshAllAsync();
MainNavigation.SelectedItem = MainNavigation.MenuItems[0]; MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
} }
@ -32,6 +35,7 @@ public sealed partial class MainPage : Page
} }
_loaded = true; _loaded = true;
_refreshTimer.Start();
foreach (var profile in await _storage.LoadAsync()) foreach (var profile in await _storage.LoadAsync())
{ {
Servers.Add(new ServerViewModel(profile)); Servers.Add(new ServerViewModel(profile));
@ -271,6 +275,12 @@ public sealed partial class MainPage : Page
=> await RefreshAllAsync(); => await RefreshAllAsync();
private async Task RefreshAllAsync() private async Task RefreshAllAsync()
{
if (!await _refreshLock.WaitAsync(0))
{
return;
}
try
{ {
if (Servers.Count == 0) if (Servers.Count == 0)
{ {
@ -280,20 +290,25 @@ public sealed partial class MainPage : Page
await Task.WhenAll(Servers.Select(RefreshServerAsync)); await Task.WhenAll(Servers.Select(RefreshServerAsync));
var online = Servers.Count(server => server.IsOnline); var online = Servers.Count(server => server.IsOnline);
var warnings = Servers.Count - online; var warnings = Servers.Count(server => !server.IsOnline || server.HasWarning);
AvailabilityValueText.Text = $"{online} / {Servers.Count}"; AvailabilityValueText.Text = $"{online} / {Servers.Count}";
AvailabilityDetailText.Text = warnings == 0 ? "Все серверы доступны" : $"Недоступно: {warnings}"; AvailabilityDetailText.Text = warnings == 0 ? "Все серверы доступны" : $"Недоступно: {warnings}";
AverageLoadValueText.Text = online == 0 AverageLoadValueText.Text = online == 0
? "—" ? "—"
: $"{Servers.Where(server => server.IsOnline).Average(server => server.CpuPercent):F0}%"; : $"{Servers.Where(server => server.IsOnline).Average(server => server.CpuPercent):F0}%";
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture); WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте SSH и ключ"; WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}"; HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
if (Servers.Any(server => server.IsHub)) if (Servers.Any(server => server.IsHub))
{ {
await RefreshMeshAsync(showSuccess: false); await RefreshMeshAsync(showSuccess: false);
} }
} }
finally
{
_refreshLock.Release();
}
}
private async Task RefreshServerAsync(ServerViewModel server) private async Task RefreshServerAsync(ServerViewModel server)
{ {
@ -306,6 +321,11 @@ public sealed partial class MainPage : Page
server.CpuText = $"{metrics.CpuPercent:F0}% load"; server.CpuText = $"{metrics.CpuPercent:F0}% load";
server.MemoryText = $"{FormatSize(metrics.MemoryUsedKb)} / {FormatSize(metrics.MemoryTotalKb)} RAM"; server.MemoryText = $"{FormatSize(metrics.MemoryUsedKb)} / {FormatSize(metrics.MemoryTotalKb)} RAM";
server.DiskText = $"{FormatSize(metrics.DiskUsedKb)} / {FormatSize(metrics.DiskTotalKb)} disk"; server.DiskText = $"{FormatSize(metrics.DiskUsedKb)} / {FormatSize(metrics.DiskTotalKb)} disk";
var memoryPercent = Percent(metrics.MemoryUsedKb, metrics.MemoryTotalKb);
var diskPercent = Percent(metrics.DiskUsedKb, metrics.DiskTotalKb);
var inodePercent = Percent(metrics.InodesUsed, metrics.InodesTotal);
server.HasWarning = memoryPercent >= 90 || diskPercent >= 90 || inodePercent >= 90 || metrics.SshState != "active";
server.HealthText = $"swap {FormatSize(metrics.SwapUsedKb)}/{FormatSize(metrics.SwapTotalKb)} · inode {inodePercent:F0}% · net ↓{FormatBytes(metrics.NetworkRxBytes)} ↑{FormatBytes(metrics.NetworkTxBytes)} · SSH {metrics.SshState}";
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;
@ -319,6 +339,8 @@ public sealed partial class MainPage : Page
server.DiskText = "—"; server.DiskText = "—";
server.LatencyText = "offline"; server.LatencyText = "offline";
server.IsOnline = false; server.IsOnline = false;
server.HasWarning = true;
server.HealthText = "Нет данных о службах и сети";
} }
} }
@ -351,6 +373,14 @@ public sealed partial class MainPage : Page
? $"{uptime.TotalDays:F0} дн." ? $"{uptime.TotalDays:F0} дн."
: $"{uptime.TotalHours:F0} ч."; : $"{uptime.TotalHours:F0} ч.";
private static double Percent(long used, long total)
=> total <= 0 ? 0 : Math.Clamp(used * 100d / total, 0, 100);
private static string FormatBytes(long bytes)
=> bytes >= 1024L * 1024 * 1024
? $"{bytes / 1024d / 1024d / 1024d:F1} GB"
: $"{bytes / 1024d / 1024d:F1} MB";
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();

View file

@ -19,7 +19,9 @@ public sealed class ServerViewModel : INotifyPropertyChanged
private string _memoryText = "—"; private string _memoryText = "—";
private string _diskText = "—"; private string _diskText = "—";
private string _latencyText = "—"; private string _latencyText = "—";
private string _healthText = "—";
private bool _isOnline; private bool _isOnline;
private bool _hasWarning;
public ServerViewModel(ServerProfileData profile) => Profile = profile; public ServerViewModel(ServerProfileData profile) => Profile = profile;
@ -33,7 +35,9 @@ public sealed class ServerViewModel : INotifyPropertyChanged
public string MemoryText { get => _memoryText; set => Set(ref _memoryText, value); } public string MemoryText { get => _memoryText; set => Set(ref _memoryText, value); }
public string DiskText { get => _diskText; set => Set(ref _diskText, value); } public string DiskText { get => _diskText; set => Set(ref _diskText, value); }
public string LatencyText { get => _latencyText; set => Set(ref _latencyText, value); } public string LatencyText { get => _latencyText; set => Set(ref _latencyText, value); }
public string HealthText { get => _healthText; set => Set(ref _healthText, value); }
public bool IsOnline { get => _isOnline; set => Set(ref _isOnline, value); } public bool IsOnline { get => _isOnline; set => Set(ref _isOnline, value); }
public bool HasWarning { get => _hasWarning; set => Set(ref _hasWarning, value); }
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;

View file

@ -12,6 +12,14 @@ public sealed record ServerMetrics(
long MemoryTotalKb, long MemoryTotalKb,
long DiskUsedKb, long DiskUsedKb,
long DiskTotalKb, long DiskTotalKb,
long SwapUsedKb,
long SwapTotalKb,
long InodesUsed,
long InodesTotal,
long NetworkRxBytes,
long NetworkTxBytes,
string SshState,
string WireGuardState,
TimeSpan Uptime, TimeSpan Uptime,
TimeSpan Latency); TimeSpan Latency);
@ -61,6 +69,10 @@ public sealed partial class SshMonitorService
var memoryAvailable = ReadLong(values, "MEM_AVAILABLE_KB"); var memoryAvailable = ReadLong(values, "MEM_AVAILABLE_KB");
var diskTotal = ReadLong(values, "DISK_TOTAL_KB"); var diskTotal = ReadLong(values, "DISK_TOTAL_KB");
var diskAvailable = ReadLong(values, "DISK_AVAILABLE_KB"); var diskAvailable = ReadLong(values, "DISK_AVAILABLE_KB");
var swapTotal = ReadLong(values, "SWAP_TOTAL_KB");
var swapFree = ReadLong(values, "SWAP_FREE_KB");
var inodesTotal = ReadLong(values, "DISK_INODES_TOTAL");
var inodesFree = ReadLong(values, "DISK_INODES_FREE");
return new ServerMetrics( return new ServerMetrics(
values.GetValueOrDefault("HOSTNAME", profile.Name), values.GetValueOrDefault("HOSTNAME", profile.Name),
@ -69,6 +81,14 @@ public sealed partial class SshMonitorService
memoryTotal, memoryTotal,
Math.Max(0, diskTotal - diskAvailable), Math.Max(0, diskTotal - diskAvailable),
diskTotal, diskTotal,
Math.Max(0, swapTotal - swapFree),
swapTotal,
Math.Max(0, inodesTotal - inodesFree),
inodesTotal,
ReadLong(values, "NETWORK_RX_BYTES"),
ReadLong(values, "NETWORK_TX_BYTES"),
values.GetValueOrDefault("SYSTEMD_SSH", "unknown"),
values.GetValueOrDefault("SYSTEMD_WIREGUARD", "unknown"),
TimeSpan.FromSeconds(ReadLong(values, "UPTIME_SECONDS")), TimeSpan.FromSeconds(ReadLong(values, "UPTIME_SECONDS")),
stopwatch.Elapsed); stopwatch.Elapsed);
} }