server-monitor-manager/src/ServerMonitorManager.Desktop/MainPage.xaml.cs

1415 lines
57 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<MetricSampleData> _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<ServerViewModel> Servers { get; } = [];
public ObservableCollection<MeshNodeViewModel> MeshNodes { get; } = [];
public ObservableCollection<MeshLinkViewModel> 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<string?> 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<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();
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;
}
}