Add Windows SSH monitoring MVP #1

Merged
ochenstarik-ui merged 23 commits from agent/windows-ssh-monitoring into main 2026-07-16 15:25:19 +00:00
5 changed files with 75 additions and 19 deletions
Showing only changes of commit 594b9aaced - Show all commits

View file

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

View file

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

View file

@ -11,12 +11,15 @@ public sealed partial class MainPage : Page
{
private readonly ServerStorage _storage = 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;
public MainPage()
{
InitializeComponent();
Loaded += MainPage_Loaded;
_refreshTimer.Tick += async (_, _) => await RefreshAllAsync();
MainNavigation.SelectedItem = MainNavigation.MenuItems[0];
}
@ -32,6 +35,7 @@ public sealed partial class MainPage : Page
}
_loaded = true;
_refreshTimer.Start();
foreach (var profile in await _storage.LoadAsync())
{
Servers.Add(new ServerViewModel(profile));
@ -272,26 +276,37 @@ public sealed partial class MainPage : Page
private async Task RefreshAllAsync()
{
if (Servers.Count == 0)
if (!await _refreshLock.WaitAsync(0))
{
ShowInfo("Серверы не добавлены", "Сначала создайте SSH-ключ и установите его на сервере.", InfoBarSeverity.Informational);
return;
}
await Task.WhenAll(Servers.Select(RefreshServerAsync));
var online = Servers.Count(server => server.IsOnline);
var warnings = Servers.Count - online;
AvailabilityValueText.Text = $"{online} / {Servers.Count}";
AvailabilityDetailText.Text = warnings == 0 ? "Все серверы доступны" : $"Недоступно: {warnings}";
AverageLoadValueText.Text = online == 0
? "—"
: $"{Servers.Where(server => server.IsOnline).Average(server => server.CpuPercent):F0}%";
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте SSH и ключ";
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
if (Servers.Any(server => server.IsHub))
try
{
await RefreshMeshAsync(showSuccess: false);
if (Servers.Count == 0)
{
ShowInfo("Серверы не добавлены", "Сначала создайте SSH-ключ и установите его на сервере.", InfoBarSeverity.Informational);
return;
}
await Task.WhenAll(Servers.Select(RefreshServerAsync));
var online = Servers.Count(server => server.IsOnline);
var warnings = Servers.Count(server => !server.IsOnline || server.HasWarning);
AvailabilityValueText.Text = $"{online} / {Servers.Count}";
AvailabilityDetailText.Text = warnings == 0 ? "Все серверы доступны" : $"Недоступно: {warnings}";
AverageLoadValueText.Text = online == 0
? "—"
: $"{Servers.Where(server => server.IsOnline).Average(server => server.CpuPercent):F0}%";
WarningValueText.Text = warnings.ToString(CultureInfo.InvariantCulture);
WarningDetailText.Text = warnings == 0 ? "Нет предупреждений" : "Проверьте доступность и ресурсы";
HeaderStatusText.Text = $"SSH monitoring · {Servers.Count} сервер(а) · обновлено {DateTime.Now:HH:mm:ss}";
if (Servers.Any(server => server.IsHub))
{
await RefreshMeshAsync(showSuccess: false);
}
}
finally
{
_refreshLock.Release();
}
}
@ -306,6 +321,11 @@ public sealed partial class MainPage : Page
server.CpuText = $"{metrics.CpuPercent:F0}% load";
server.MemoryText = $"{FormatSize(metrics.MemoryUsedKb)} / {FormatSize(metrics.MemoryTotalKb)} RAM";
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.Status = $"Онлайн · uptime {FormatUptime(metrics.Uptime)}";
server.IsOnline = true;
@ -319,6 +339,8 @@ public sealed partial class MainPage : Page
server.DiskText = "—";
server.LatencyText = "offline";
server.IsOnline = false;
server.HasWarning = true;
server.HealthText = "Нет данных о службах и сети";
}
}
@ -351,6 +373,14 @@ public sealed partial class MainPage : Page
? $"{uptime.TotalDays: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)
{
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 _diskText = "—";
private string _latencyText = "—";
private string _healthText = "—";
private bool _isOnline;
private bool _hasWarning;
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 DiskText { get => _diskText; set => Set(ref _diskText, 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 HasWarning { get => _hasWarning; set => Set(ref _hasWarning, value); }
public event PropertyChangedEventHandler? PropertyChanged;

View file

@ -12,6 +12,14 @@ public sealed record ServerMetrics(
long MemoryTotalKb,
long DiskUsedKb,
long DiskTotalKb,
long SwapUsedKb,
long SwapTotalKb,
long InodesUsed,
long InodesTotal,
long NetworkRxBytes,
long NetworkTxBytes,
string SshState,
string WireGuardState,
TimeSpan Uptime,
TimeSpan Latency);
@ -61,6 +69,10 @@ public sealed partial class SshMonitorService
var memoryAvailable = ReadLong(values, "MEM_AVAILABLE_KB");
var diskTotal = ReadLong(values, "DISK_TOTAL_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(
values.GetValueOrDefault("HOSTNAME", profile.Name),
@ -69,6 +81,14 @@ public sealed partial class SshMonitorService
memoryTotal,
Math.Max(0, diskTotal - diskAvailable),
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")),
stopwatch.Elapsed);
}