using System.Collections.ObjectModel; using System.Globalization; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Automation; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; using ServerMonitorManager.Core; using Windows.ApplicationModel.DataTransfer; using Windows.Foundation; using Windows.Storage; using Windows.Storage.Pickers; namespace ServerMonitorManager_Desktop; public sealed partial class MainPage : Page { private readonly ServerStorage _storage = new(); private readonly SshMonitorService _ssh = new(); private readonly MetricsHistoryStorage _historyStorage = new(); private readonly ControlClientService _control = new(); private readonly UpdateService _update = new(new DefaultHttpTransport(), new ProcessSignatureVerifier(new DefaultFileStorage(), new DefaultHttpTransport()), new DefaultFileStorage()); private UpdateInfo? _pendingUpdate; private readonly List _history = []; private readonly DispatcherTimer _refreshTimer = new() { Interval = TimeSpan.FromSeconds(30) }; private readonly SemaphoreSlim _refreshLock = new(1, 1); private readonly CancellationTokenSource _controlCancellation = new(); private ServersPage? _serversPage; private LinksPage? _linksPage; private SessionsPage? _sessionsPage; private SettingsPage? _settingsPage; private bool _loaded; private bool _controlListening; private bool _meshFirewallUnavailable; public MainPage() { InitializeComponent(); Loaded += MainPage_Loaded; Unloaded += (_, _) => _controlCancellation.Cancel(); _refreshTimer.Tick += async (_, _) => await RefreshAllAsync(); MainNavigation.SelectedItem = MainNavigation.MenuItems[0]; } public ObservableCollection Servers { get; } = []; public ObservableCollection MeshNodes { get; } = []; public ObservableCollection MeshLinks { get; } = []; internal bool IsControlConfigured => _control.IsConfigured; internal void AddServerFromPage() => AddServerButton_Click(this, new RoutedEventArgs()); internal void EditServerFromPage(ServerViewModel? server) { ServerList.SelectedItem = server; EditServerButton_Click(this, new RoutedEventArgs()); } internal void DeleteServerFromPage(ServerViewModel? server) { ServerList.SelectedItem = server; DeleteServerButton_Click(this, new RoutedEventArgs()); } internal async Task ConfirmHostKeyFromPageAsync(ServerViewModel? server) { if (server is null) { ShowInfo("Сервер не выбран", "Выберите профиль для подтверждения host key.", InfoBarSeverity.Warning); return; } var fingerprint = await ConfirmHostKeyAsync(server.Profile); if (fingerprint is null) { return; } var index = Servers.IndexOf(server); if (index < 0) { return; } var confirmed = new ServerViewModel(server.Profile with { HostKeyFingerprint = fingerprint }); Servers[index] = confirmed; ServerList.SelectedItem = confirmed; await SaveProfilesAsync(); await RefreshServerAsync(confirmed); ShowInfo("SSH host key подтверждён", confirmed.Name, InfoBarSeverity.Success); } internal void OpenTerminalFromPage(ServerViewModel? server) { ServerList.SelectedItem = server; TerminalButton_Click(this, new RoutedEventArgs()); } internal async Task RefreshServersFromPageAsync() => await RefreshAllAsync(); internal async Task RefreshLinksFromPageAsync() => await RefreshMeshAsync(); internal async Task ChangeLinkFromPageAsync( MeshNodeViewModel? source, MeshNodeViewModel? target, MeshLinkViewModel? selectedLink, string protocol, int port, int ttlMinutes, bool enable) { SourceNodeBox.SelectedItem = source; TargetNodeBox.SelectedItem = target; LinkProtocolBox.SelectedIndex = string.Equals(protocol, "udp", StringComparison.OrdinalIgnoreCase) ? 1 : 0; LinkPortBox.Value = port; LinkTtlBox.Value = ttlMinutes; MeshLinksList.SelectedItem = selectedLink; await ChangeLinkAsync(enable); } internal void ReenrollNodeFromPage(MeshNodeViewModel? source) { SourceNodeBox.SelectedItem = source; ReenrollNodeButton_Click(this, new RoutedEventArgs()); } internal void ShowSshKeyFromPage() => SshKeyButton_Click(this, new RoutedEventArgs()); internal void ConnectControlHubFromPage() => ControlHubButton_Click(this, new RoutedEventArgs()); internal void ExportDiagnosticsFromPage() => ExportDiagnosticsButton_Click(this, new RoutedEventArgs()); private async void MainPage_Loaded(object sender, RoutedEventArgs e) { if (_loaded) { return; } _loaded = true; _ = Task.Run(async () => { try { var update = await _update.CheckForUpdatesAsync(usePreRelease: true); if (update is not null) { DispatcherQueue.TryEnqueue(() => { _pendingUpdate = update; UpdateInfoBar.Message = $"Доступна новая версия: {update.Version}"; UpdateInfoBar.IsOpen = true; }); } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Update check failed: {ex.Message}"); } }); _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(); } var pendingNames = Servers .Where(server => server.HostKeyPendingConfirmation) .Select(server => server.Name) .ToArray(); if (pendingNames.Length > 0) { ShowInfo( "Требуется подтверждение SSH host key", $"Нажмите «Подтвердить host key» в карточке: {string.Join(", ", pendingNames)}.", InfoBarSeverity.Warning); } if (Servers.Count > 0 || _control.IsConfigured) { await RefreshAllAsync(); } StartControlEvents(); } private async void SshKeyButton_Click(object sender, RoutedEventArgs e) { try { var publicKey = await _ssh.EnsureKeyPairAsync(); var keyBox = new TextBox { Text = publicKey, IsReadOnly = true, AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinWidth = 520 }; AutomationProperties.SetName(keyBox, "Публичный SSH-ключ мониторинга"); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "SSH-ключ мониторинга", Content = new StackPanel { Spacing = 12, Children = { new TextBlock { Text = "Скопируйте этот публичный ключ и вставьте его в установочный скрипт на каждом сервере. Приватный ключ остаётся только на этом ПК.", TextWrapping = TextWrapping.Wrap }, keyBox } }, PrimaryButtonText = "Копировать", CloseButtonText = "Закрыть", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() == ContentDialogResult.Primary) { var package = new DataPackage(); package.SetText(publicKey); Clipboard.SetContent(package); ShowInfo("SSH-ключ скопирован", "Вставьте его в ochenstarik-server-monitor-manager.sh на каждом сервере.", InfoBarSeverity.Success); } } catch (Exception exception) { ShowInfo("Не удалось создать SSH-ключ", exception.Message, InfoBarSeverity.Error); } } private async void ExportDiagnosticsButton_Click(object sender, RoutedEventArgs e) { try { var window = App.MainWindow ?? throw new InvalidOperationException("Главное окно приложения недоступно."); var picker = new FileSavePicker { SuggestedStartLocation = PickerLocationId.DocumentsLibrary, SuggestedFileName = $"server-monitor-manager-diagnostics-{DateTimeOffset.Now:yyyyMMdd-HHmmss}" }; picker.FileTypeChoices.Add("JSON diagnostics", [".json"]); WinRT.Interop.InitializeWithWindow.Initialize( picker, WinRT.Interop.WindowNative.GetWindowHandle(window)); var file = await picker.PickSaveFileAsync(); if (file is null) { return; } var json = DiagnosticsExportService.CreateJson( Servers.Select(server => new DiagnosticServerInput( $"{server.Profile.Host}:{server.Profile.Port}", server.IsHub, server.IsOnline, server.HasWarning, server.CpuPercent)), MeshNodes.Select(node => new DiagnosticNodeInput( $"{node.Name}:{node.Address}", node.State, node.HandshakeAgeSeconds)), MeshLinks.Select(link => new DiagnosticLinkInput( link.Source, link.Target, link.Protocol, link.Port, link.State, link.Version, link.ExpiresUnix)), _history.Select(sample => new DiagnosticMetricInput( sample.ServerId, sample.Timestamp, sample.CpuPercent, sample.MemoryPercent, sample.DiskPercent)), _control.IsConfigured); await FileIO.WriteTextAsync(file, json); ShowInfo( "Диагностика экспортирована", "Файл не содержит адресов серверов, пользователей, ключей, сертификатов или токенов.", InfoBarSeverity.Success); } catch (Exception exception) { ShowInfo( "Не удалось экспортировать диагностику", CompactError(exception), InfoBarSeverity.Error); } } private async void TerminalButton_Click(object sender, RoutedEventArgs e) { if (ServerList.SelectedItem is not ServerViewModel selected) { ShowInfo("Сервер не выбран", "Выберите сервер, к которому нужно открыть SSH-терминал.", InfoBarSeverity.Warning); return; } var userBox = new TextBox { Header = "Unix-пользователь", PlaceholderText = "starik", MinWidth = 360 }; AutomationProperties.SetName(userBox, "Пользователь интерактивного SSH-терминала"); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"SSH-терминал: {selected.Name}", Content = new StackPanel { Spacing = 12, Children = { new TextBlock { Text = "Терминал использует системный OpenSSH и ваши обычные SSH-ключи. Ключ мониторинга с ограниченной командой здесь не применяется.", TextWrapping = TextWrapping.Wrap }, userBox } }, PrimaryButtonText = "Открыть", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) { return; } try { _ssh.OpenInteractiveTerminal(selected.Profile, userBox.Text.Trim()); ShowInfo("SSH-терминал открыт", $"Подключение к {selected.Profile.Host} запущено от имени {userBox.Text.Trim()}.", InfoBarSeverity.Success); } catch (Exception exception) { ShowInfo("Не удалось открыть SSH-терминал", exception.Message, InfoBarSeverity.Error); } } private async void ControlHubButton_Click(object sender, RoutedEventArgs e) { var codeBox = new TextBox { Header = "Код устройства SMMDEV1", PlaceholderText = "SMMDEV1-...", AcceptsReturn = true, TextWrapping = TextWrapping.Wrap, MinWidth = 360 }; AutomationProperties.SetName(codeBox, "Одноразовый код Control Hub для Windows-устройства"); var codeDialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Подключение Control Hub", Content = new StackPanel { Spacing = 12, Children = { new TextBlock { Text = "На Hub выполните: sudo ochenstarik-server-monitor-manager.sh control-device-code windows-pc. Вставьте полученный одноразовый код.", TextWrapping = TextWrapping.Wrap }, codeBox } }, PrimaryButtonText = "Проверить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await codeDialog.ShowAsync() != ContentDialogResult.Primary) { return; } try { var preview = _control.ParseEnrollmentCode(codeBox.Text); var fingerprintBox = new TextBox { Header = "SHA-256 fingerprint Control CA", Text = preview.Fingerprint, IsReadOnly = true, TextWrapping = TextWrapping.Wrap }; AutomationProperties.SetName(fingerprintBox, "SHA-256 fingerprint Control CA"); var confirmDialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Подтвердите Control Hub", Content = new StackPanel { Spacing = 12, Children = { new TextBlock { Text = $"Устройство: {preview.DeviceId}\nHub: {preview.ControlUrl}", TextWrapping = TextWrapping.Wrap }, fingerprintBox, new TextBlock { Text = "Сравните fingerprint с показанным на Hub. После подтверждения приложение локально создаст отдельный operator key и защитит его через Windows DPAPI.", TextWrapping = TextWrapping.Wrap } } }, PrimaryButtonText = "Fingerprint совпадает", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await confirmDialog.ShowAsync() != ContentDialogResult.Primary) { return; } using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); await _control.EnrollAsync(preview, timeout.Token); ShowInfo( "Control Hub подключён", $"Operator identity {preview.DeviceId} сохранена через DPAPI. Поток событий запускается.", InfoBarSeverity.Success); StartControlEvents(); } catch (Exception exception) { ShowInfo("Не удалось подключить Control Hub", CompactError(exception), InfoBarSeverity.Error); } } private void StartControlEvents() { if (_controlListening || _controlCancellation.IsCancellationRequested) { return; } _controlListening = true; _ = Task.Run(async () => { try { await _control.ListenAsync(HandleControlEventAsync, _controlCancellation.Token); } finally { _controlListening = false; } }); } private async void InstallUpdateButton_Click(object sender, RoutedEventArgs e) { if (_pendingUpdate is null) return; InstallUpdateButton.IsEnabled = false; InstallUpdateButton.Content = "Скачивание..."; UpdateInfoBar.IsClosable = false; try { await _update.DownloadAndVerifyUpdateAsync(_pendingUpdate); _update.InstallUpdate(); } catch (Exception ex) { ShowInfo("Ошибка обновления", ex.Message, InfoBarSeverity.Error); UpdateInfoBar.IsOpen = false; } } private Task HandleControlEventAsync(ControlEvent controlEvent) { DispatcherQueue.TryEnqueue(() => { if (controlEvent.Type == MeshLinkViewModel.FirewallUnavailableErrorCode) { SetMeshFirewallUnavailable(true); _ = RefreshMeshAsync(showSuccess: false); return; } if (controlEvent.Type == "mesh.firewall-available") { SetMeshFirewallUnavailable(false); _ = RefreshMeshAsync(showSuccess: false); return; } if (controlEvent.Type.StartsWith("link.", StringComparison.Ordinal)) { ShowInfo( "Событие Control Hub", $"{controlEvent.Subject}: {controlEvent.Type}", controlEvent.Type is "link.failed" or "link.partial" ? InfoBarSeverity.Warning : InfoBarSeverity.Informational); _ = RefreshMeshAsync(showSuccess: false); } }); return Task.CompletedTask; } private async Task ConfirmHostKeyAsync(ServerProfileData profile) { try { SshHostKeyCandidate candidate; using (var scanTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(15))) { candidate = await _ssh.ScanHostKeyAsync(profile, scanTimeout.Token); } var fingerprintBox = new TextBox { Text = candidate.Fingerprint, IsReadOnly = true, TextWrapping = TextWrapping.Wrap }; AutomationProperties.SetName(fingerprintBox, "Fingerprint SSH host key"); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Подтвердите SSH host key: {profile.Name}", Content = new StackPanel { Spacing = 12, MinWidth = 480, Children = { new TextBlock { Text = $"Алгоритм: {candidate.KeyType}\nСверьте fingerprint через доверенную консоль сервера или панель провайдера. Не подтверждайте его только по данным текущего подключения.", TextWrapping = TextWrapping.Wrap }, fingerprintBox } }, PrimaryButtonText = "Fingerprint совпадает", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) { return null; } using var persistTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await _ssh.TrustHostKeyAsync(candidate, persistTimeout.Token); return candidate.Fingerprint; } catch (Exception exception) { ShowInfo( "Не удалось проверить SSH host key", exception.Message, InfoBarSeverity.Error); return null; } } private async void AddServerButton_Click(object sender, RoutedEventArgs e) { try { await _ssh.EnsureKeyPairAsync(); } catch (Exception exception) { ShowInfo("Не удалось подготовить SSH-ключ", exception.Message, InfoBarSeverity.Error); return; } var nameBox = new TextBox { Header = "Название", PlaceholderText = "Home Lab" }; var hostBox = new TextBox { Header = "IP или домен", PlaceholderText = "192.0.2.10" }; var portBox = new NumberBox { Header = "SSH-порт", Value = 22, Minimum = 1, Maximum = 65535, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact }; var userBox = new TextBox { Header = "Пользователь", Text = "ochenstarik-monitor" }; var hubBox = new CheckBox { Content = "Это главный Mesh Hub" }; AutomationProperties.SetName(hubBox, "Использовать сервер как главный Mesh Hub"); var validationText = new TextBlock { TextWrapping = TextWrapping.Wrap }; var content = new StackPanel { Spacing = 12, MinWidth = 420, Children = { nameBox, hostBox, portBox, userBox, hubBox, validationText } }; var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Добавить сервер", Content = content, PrimaryButtonText = "Добавить и проверить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; dialog.PrimaryButtonClick += (_, args) => { if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(hostBox.Text) || string.IsNullOrWhiteSpace(userBox.Text) || double.IsNaN(portBox.Value)) { validationText.Text = "Заполните название, адрес, порт и пользователя."; args.Cancel = true; } }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) { return; } if (hubBox.IsChecked == true && Servers.Any(server => server.IsHub)) { ShowInfo("Hub уже выбран", "Измените существующий Hub или снимите эту отметку.", InfoBarSeverity.Warning); return; } var profile = new ServerProfileData( Guid.NewGuid().ToString("N"), nameBox.Text.Trim(), hostBox.Text.Trim(), checked((int)portBox.Value), userBox.Text.Trim(), hubBox.IsChecked == true); var hostKeyFingerprint = await ConfirmHostKeyAsync(profile); if (hostKeyFingerprint is null) { return; } profile = profile with { HostKeyFingerprint = hostKeyFingerprint }; var server = new ServerViewModel(profile); Servers.Add(server); await SaveProfilesAsync(); UpdateEmptyState(); await RefreshServerAsync(server); } private async void EditServerButton_Click(object sender, RoutedEventArgs e) { if (ServerList.SelectedItem is not ServerViewModel selected) { ShowInfo("Сервер не выбран", "Выберите сервер в списке для изменения.", InfoBarSeverity.Warning); return; } var nameBox = new TextBox { Header = "Название", Text = selected.Profile.Name }; var hostBox = new TextBox { Header = "IP или домен", Text = selected.Profile.Host }; var portBox = new NumberBox { Header = "SSH-порт", Value = selected.Profile.Port, Minimum = 1, Maximum = 65535, SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact }; var userBox = new TextBox { Header = "Пользователь", Text = selected.Profile.User }; var hubBox = new CheckBox { Content = "Это главный Mesh Hub", IsChecked = selected.IsHub }; AutomationProperties.SetName(hubBox, "Использовать сервер как главный Mesh Hub"); var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = "Изменить сервер", Content = new StackPanel { Spacing = 12, MinWidth = 420, Children = { nameBox, hostBox, portBox, userBox, hubBox } }, PrimaryButtonText = "Сохранить", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Primary }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) { return; } if (string.IsNullOrWhiteSpace(nameBox.Text) || string.IsNullOrWhiteSpace(hostBox.Text) || string.IsNullOrWhiteSpace(userBox.Text) || double.IsNaN(portBox.Value)) { ShowInfo("Данные не сохранены", "Название, адрес, порт и пользователь обязательны.", InfoBarSeverity.Warning); return; } if (hubBox.IsChecked == true && Servers.Any(server => server.IsHub && server != selected)) { ShowInfo("Hub уже выбран", "В конфигурации может быть только один главный Mesh Hub.", InfoBarSeverity.Warning); return; } var index = Servers.IndexOf(selected); var updatedProfile = new ServerProfileData( selected.Profile.Id, nameBox.Text.Trim(), hostBox.Text.Trim(), checked((int)portBox.Value), userBox.Text.Trim(), hubBox.IsChecked == true); var knownHostsDirectory = Path.Combine( ApplicationData.Current.LocalFolder.Path, "ssh", "known-hosts"); var hostKeyFingerprint = SshHostKeyTrust.CanReusePin( knownHostsDirectory, selected.Profile, updatedProfile) ? selected.Profile.HostKeyFingerprint : await ConfirmHostKeyAsync(updatedProfile); if (hostKeyFingerprint is null) { return; } var updated = new ServerViewModel( updatedProfile with { HostKeyFingerprint = hostKeyFingerprint }); Servers[index] = updated; await SaveProfilesAsync(); await RefreshServerAsync(updated); ShowInfo("Сервер изменён", updated.Name, InfoBarSeverity.Success); } private async void DeleteServerButton_Click(object sender, RoutedEventArgs e) { if (ServerList.SelectedItem is not ServerViewModel selected) { ShowInfo("Сервер не выбран", "Выберите сервер в списке для удаления.", InfoBarSeverity.Warning); return; } var dialog = new ContentDialog { XamlRoot = XamlRoot, Title = $"Удалить {selected.Name}?", Content = "Удаляется только локальный профиль. Серверная часть и WireGuard Node останутся установленными.", PrimaryButtonText = "Удалить профиль", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await dialog.ShowAsync() != ContentDialogResult.Primary) { return; } Servers.Remove(selected); _history.RemoveAll(sample => sample.ServerId == selected.Profile.Id); await SaveProfilesAsync(); await _historyStorage.SaveAsync(_history); UpdateEmptyState(); ShowInfo("Профиль удалён", selected.Name, InfoBarSeverity.Success); } private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await RefreshAllAsync(); private async Task RefreshAllAsync() { if (!await _refreshLock.WaitAsync(0)) { return; } try { if (Servers.Count == 0) { if (_control.IsConfigured) { await RefreshControlMeshAsync(showSuccess: false); return; } ShowInfo("Серверы не добавлены", "Сначала создайте SSH-ключ и установите его на сервере.", InfoBarSeverity.Informational); return; } 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}"; 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 && !server.HostKeyPendingConfirmation) || _control.IsConfigured) { await RefreshMeshAsync(showSuccess: false); } } finally { _refreshLock.Release(); } } private async Task RefreshServerAsync(ServerViewModel server) { if (server.HostKeyPendingConfirmation) { server.Status = "Требуется подтверждение host key"; server.IsOnline = false; server.HasWarning = true; return; } server.Status = "Подключение…"; try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var metrics = await _ssh.QueryAsync(server.Profile, timeout.Token); server.CpuPercent = metrics.CpuPercent; 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; _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) { server.Status = CompactError(exception); server.CpuText = "—"; server.CpuPercent = 0; server.MemoryText = "—"; server.DiskText = "—"; server.LatencyText = "offline"; server.IsOnline = false; server.HasWarning = true; server.HealthText = "Нет данных о службах и сети"; } } private async Task SaveProfilesAsync() => await _storage.SaveAsync(Servers.Select(server => server.Profile)); private void UpdateEmptyState() { EmptyServersText.Visibility = Servers.Count == 0 ? Visibility.Visible : Visibility.Collapsed; HeaderStatusText.Text = Servers.Count == 0 ? "SSH monitoring · серверы ещё не добавлены" : $"SSH monitoring · {Servers.Count} сервер(а)"; if (Servers.Count == 0) { AvailabilityValueText.Text = "0"; AvailabilityDetailText.Text = "Добавьте серверы"; AverageLoadValueText.Text = "—"; WarningValueText.Text = "0"; WarningDetailText.Text = "Нет данных"; } } private static string FormatSize(long kilobytes) => kilobytes >= 1024 * 1024 ? $"{kilobytes / 1024d / 1024d:F1} GB" : $"{kilobytes / 1024d:F0} MB"; private static string FormatUptime(TimeSpan uptime) => uptime.TotalDays >= 1 ? $"{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 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 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(); return string.IsNullOrWhiteSpace(firstLine) ? "Ошибка подключения" : firstLine; } private void ShowInfo(string title, string message, InfoBarSeverity severity) { LinkActionInfo.Title = title; LinkActionInfo.Message = message; LinkActionInfo.Severity = severity; LinkActionInfo.IsOpen = true; } private void SetMeshFirewallUnavailable(bool unavailable) { _meshFirewallUnavailable = unavailable; _linksPage?.SetFirewallUnavailable(unavailable); } private ServerViewModel? FindHub() => Servers.FirstOrDefault(server => server.IsHub); private async Task RefreshMeshAsync(bool showSuccess = true) { if (_control.IsConfigured) { await RefreshControlMeshAsync(showSuccess); return; } var hub = FindHub(); if (hub is null) { ShowInfo("Mesh Hub не выбран", "Добавьте главный сервер или отметьте его как Mesh Hub.", InfoBarSeverity.Warning); return; } try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var nodesOutput = await _ssh.RunRestrictedCommandAsync(hub.Profile, "mesh nodes", timeout.Token); var linksOutput = await _ssh.RunRestrictedCommandAsync(hub.Profile, "mesh links", timeout.Token); MeshNodes.Clear(); foreach (var line in nodesOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) { if (!line.StartsWith("NODE=", StringComparison.Ordinal)) { continue; } var fields = line[5..].Split('|'); if (fields.Length == 4 && int.TryParse(fields[3], out var age)) { MeshNodes.Add(new MeshNodeViewModel(fields[0], fields[1], fields[2], age)); } } MeshLinks.Clear(); foreach (var line in linksOutput.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) { if (!line.StartsWith("LINK=", StringComparison.Ordinal)) { continue; } var fields = line[5..].Split('|'); if (fields.Length >= 8 && int.TryParse(fields[4], CultureInfo.InvariantCulture, out var port) && long.TryParse(fields[5], CultureInfo.InvariantCulture, out var expiresUnix) && long.TryParse(fields[7], CultureInfo.InvariantCulture, out var version)) { MeshLinks.Add(new MeshLinkViewModel( fields[0], fields[1], fields[2], fields[3], port, expiresUnix, fields[6], version)); } } ActiveLinksValueText.Text = MeshLinks.Count.ToString(CultureInfo.InvariantCulture); MeshStatusText.Text = $"{MeshNodes.Count} узлов · {MeshLinks.Count} активных связей"; if (showSuccess) { ShowInfo("Mesh обновлён", MeshStatusText.Text, InfoBarSeverity.Success); } } catch (Exception exception) { MeshStatusText.Text = "Hub недоступен"; ShowInfo("Не удалось получить Mesh-состояние", CompactError(exception), InfoBarSeverity.Error); } } private async Task RefreshControlMeshAsync(bool showSuccess) { try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var agents = await _control.GetAgentsAsync(timeout.Token); var links = await _control.GetLinksAsync(timeout.Token); var now = DateTimeOffset.UtcNow; MeshNodes.Clear(); foreach (var agent in agents) { var age = agent.LastSeenAt is null ? int.MaxValue : (int)Math.Clamp((now - agent.LastSeenAt.Value).TotalSeconds, 0, int.MaxValue); MeshNodes.Add(new MeshNodeViewModel( agent.NodeId, "Control", agent.Status == "Revoked" ? "revoked" : age <= 90 ? "online" : "offline", age)); } MeshLinks.Clear(); foreach (var link in links) { MeshLinks.Add(new MeshLinkViewModel( link.SourceNodeId, link.TargetNodeId, link.TargetNodeId, link.Protocol, link.Port, link.ExpiresAt?.ToUnixTimeSeconds() ?? 0, link.ActualState, link.Version, link.Id, link.DesiredState, link.ActualState, link.LastError)); } SetMeshFirewallUnavailable(links .GroupBy(link => new { link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port }) .Select(group => group.MaxBy(link => link.Version)!) .Any(link => link.LastError == MeshLinkViewModel.FirewallUnavailableErrorCode)); _linksPage?.RefreshFilter(); var activeLinks = MeshLinks.Count(link => link.ActualState == "Active"); ActiveLinksValueText.Text = activeLinks.ToString(CultureInfo.InvariantCulture); MeshStatusText.Text = $"Control · {MeshNodes.Count} узлов · {activeLinks} активных · {MeshLinks.Count} политик"; if (showSuccess) { ShowInfo("Control Hub обновлён", MeshStatusText.Text, InfoBarSeverity.Success); } } catch (Exception exception) { MeshStatusText.Text = "Control Hub недоступен"; ShowInfo("Не удалось получить Control-состояние", CompactError(exception), InfoBarSeverity.Error); } } private async void RefreshMeshButton_Click(object sender, RoutedEventArgs e) => await RefreshMeshAsync(); private async void ConnectLinkButton_Click(object sender, RoutedEventArgs e) => await ChangeLinkAsync(enable: true); private async void DisconnectLinkButton_Click(object sender, RoutedEventArgs e) => await ChangeLinkAsync(enable: false); private async void ReenrollNodeButton_Click(object sender, RoutedEventArgs e) { if (!_control.IsConfigured) { ShowInfo( "Control Hub не подключён", "Перерегистрация доступна после подключения через SMMDEV1.", InfoBarSeverity.Warning); return; } if (SourceNodeBox.SelectedItem is not MeshNodeViewModel node) { ShowInfo( "Выберите Node", "Выберите сервер в поле «Источник доступа».", InfoBarSeverity.Warning); return; } var confirmation = new ContentDialog { XamlRoot = XamlRoot, Title = $"Перерегистрировать {node.Name}?", Content = "Старый mTLS-сертификат будет немедленно отозван. Все связанные Links получат желаемое состояние Disabled. Новый token действует 10 минут.", PrimaryButtonText = "Отозвать и создать token", CloseButtonText = "Отмена", DefaultButton = ContentDialogButton.Close }; if (await confirmation.ShowAsync() != ContentDialogResult.Primary) { return; } try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); var ticket = await _control.ReenrollAgentAsync( node.Name, "Windows operator requested certificate rotation", timeout.Token); await RefreshMeshAsync(showSuccess: false); var tokenBox = new TextBox { Header = "Одноразовый enrollment token", Text = ticket.Token, IsReadOnly = true, TextWrapping = TextWrapping.Wrap }; var result = await new ContentDialog { XamlRoot = XamlRoot, Title = $"{ticket.EntityId} ожидает перерегистрацию", Content = new StackPanel { Spacing = 12, Children = { new TextBlock { Text = $"Token действителен до {ticket.ExpiresAt.ToLocalTime():dd.MM.yyyy HH:mm}. Отключено Links: {ticket.DisabledLinks}. Передайте token только администратору этого Node.", TextWrapping = TextWrapping.Wrap }, tokenBox } }, PrimaryButtonText = "Копировать token", CloseButtonText = "Закрыть", DefaultButton = ContentDialogButton.Close }.ShowAsync(); if (result == ContentDialogResult.Primary) { var package = new DataPackage(); package.SetText(ticket.Token); Clipboard.SetContent(package); Clipboard.Flush(); ShowInfo( "Token скопирован", "Он действует 10 минут и предназначен только для выбранного Node.", InfoBarSeverity.Success); } } catch (Exception exception) { ShowInfo("Перерегистрация не выполнена", CompactError(exception), InfoBarSeverity.Error); } } private async Task ChangeLinkAsync(bool enable) { var useControl = _control.IsConfigured; var hub = FindHub(); if (!useControl && hub is null) { ShowInfo("Mesh Hub не выбран", "Сначала добавьте главный сервер с отметкой Mesh Hub.", InfoBarSeverity.Warning); return; } MeshNodeViewModel? source = null; MeshNodeViewModel? target = null; string protocol; int port; int ttlMinutes; if (enable) { if (SourceNodeBox.SelectedItem is not MeshNodeViewModel selectedSource || TargetNodeBox.SelectedItem is not MeshNodeViewModel selectedTarget) { ShowInfo("Выберите серверы", "Укажите источник и сервер назначения.", InfoBarSeverity.Warning); return; } source = selectedSource; target = selectedTarget; protocol = (LinkProtocolBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "tcp"; if (double.IsNaN(LinkPortBox.Value) || double.IsNaN(LinkTtlBox.Value) || LinkPortBox.Value is < 1 or > 65535 || LinkTtlBox.Value is < 0 or > 525600) { ShowInfo("Некорректная политика", "Проверьте порт и TTL.", InfoBarSeverity.Warning); return; } port = checked((int)LinkPortBox.Value); ttlMinutes = checked((int)LinkTtlBox.Value); } else { if (MeshLinksList.SelectedItem is not MeshLinkViewModel selectedLink) { ShowInfo("Выберите связь", "Для отключения выберите правило в списке.", InfoBarSeverity.Warning); return; } source = MeshNodes.FirstOrDefault(node => node.Name == selectedLink.Source); target = MeshNodes.FirstOrDefault(node => node.Name == selectedLink.Target); if (source is null || target is null) { ShowInfo("Узел не найден", "Обновите список Mesh и повторите попытку.", InfoBarSeverity.Warning); return; } protocol = selectedLink.Protocol; port = selectedLink.Port; ttlMinutes = 0; } if (source.Name == target.Name) { ShowInfo("Некорректная связь", "Источник и назначение должны отличаться.", InfoBarSeverity.Warning); return; } try { if (useControl) { using var controlTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); LinkPolicy link; if (enable) { link = await _control.CreateLinkAsync( new LinkPolicyCreateRequest( source.Name, target.Name, protocol, port, ttlMinutes, "Windows client", Guid.NewGuid().ToString()), controlTimeout.Token); } else { var selectedLink = (MeshLinkViewModel)MeshLinksList.SelectedItem; if (string.IsNullOrWhiteSpace(selectedLink.Id)) { throw new InvalidOperationException("У выбранного Link отсутствует Control id."); } link = await _control.DisableLinkAsync(selectedLink.Id, controlTimeout.Token); } await RefreshMeshAsync(showSuccess: false); ShowInfo( enable ? "Control Link создан" : "Control Link отключён", link.LastError == MeshLinkViewModel.NodeNotActivatedErrorCode ? $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · ожидает активации Node в Mesh" : $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}", link.LastError == MeshLinkViewModel.NodeNotActivatedErrorCode ? InfoBarSeverity.Informational : link.ActualState is "Failed" or "Partial" ? InfoBarSeverity.Warning : InfoBarSeverity.Success); return; } var action = enable ? "connect" : "disconnect"; var policyArguments = enable ? $"{protocol} {port} {ttlMinutes}" : $"{protocol} {port}"; using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); var commandOutput = await _ssh.RunRestrictedCommandAsync( hub!.Profile, $"mesh {action} {source.Name} {target.Name} {policyArguments}", timeout.Token); var confirmation = commandOutput .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(line => line.StartsWith("LINK_STATE=", StringComparison.Ordinal)); if (confirmation is null) { throw new InvalidOperationException("Hub не подтвердил фактическое состояние Link."); } await RefreshMeshAsync(showSuccess: false); ShowInfo( enable ? "Hub подтвердил Active" : "Hub подтвердил Disabled", $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {confirmation[11..]}", enable ? InfoBarSeverity.Success : InfoBarSeverity.Warning); } catch (Exception exception) { ShowInfo("Не удалось изменить связь", CompactError(exception), InfoBarSeverity.Error); } } private void MainNavigation_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) { if (args.IsSettingsSelected) { _settingsPage ??= new SettingsPage(this); ShowNavigationPage(_settingsPage); return; } var tag = (args.SelectedItem as NavigationViewItem)?.Tag?.ToString() ?? "overview"; switch (tag) { case "servers": _serversPage ??= new ServersPage(this); ShowNavigationPage(_serversPage); break; case "links": _linksPage ??= new LinksPage(this); _linksPage.SetFirewallUnavailable(_meshFirewallUnavailable); ShowNavigationPage(_linksPage); break; case "sessions": _sessionsPage ??= new SessionsPage(this); ShowNavigationPage(_sessionsPage); break; default: ShowOverview(); break; } } private void ShowOverview() { NavigationPageHost.Visibility = Visibility.Collapsed; NavigationPageHost.Content = null; OverviewCommandBar.Visibility = Visibility.Visible; OverviewSummary.Visibility = Visibility.Visible; WorkspaceScroll.Visibility = Visibility.Visible; ServerWorkspace.Visibility = Visibility.Visible; LinkInspector.Visibility = Visibility.Visible; Grid.SetColumnSpan(LinkInspector, 1); if (ActualWidth >= 1080) { Grid.SetRow(LinkInspector, 0); Grid.SetColumn(LinkInspector, 1); InspectorColumn.Width = new GridLength(360); } else { Grid.SetRow(LinkInspector, 1); Grid.SetColumn(LinkInspector, 0); InspectorColumn.Width = new GridLength(0); } } private void ShowNavigationPage(Page page) { OverviewCommandBar.Visibility = Visibility.Collapsed; OverviewSummary.Visibility = Visibility.Collapsed; WorkspaceScroll.Visibility = Visibility.Collapsed; NavigationPageHost.Content = page; NavigationPageHost.Visibility = Visibility.Visible; } }