Files
webhook-server/src/WebhookServer.Gui/ViewModels/MainViewModel.cs
T
justin 1ea724cd1f
CI / build (push) Has been cancelled
Sync from GitHub main: v0.1.3 (#6)
2026-05-08 11:32:20 -04:00

355 lines
12 KiB
C#

using System.Collections.ObjectModel;
using System.Runtime.Versioning;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc;
using WebhookServer.Core.Models;
using WebhookServer.Gui.Services;
using WebhookServer.Gui.Views;
namespace WebhookServer.Gui.ViewModels;
[SupportedOSPlatform("windows")]
public sealed partial class MainViewModel : ObservableObject
{
private readonly AdminPipeClient _client;
public ObservableCollection<EndpointConfig> Endpoints { get; } = new();
[ObservableProperty] private EndpointConfig? _selectedEndpoint;
[ObservableProperty] private string _connectionStatus = "Disconnected";
[ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _logTail = "";
[ObservableProperty] private bool _autoScrollLogs = true;
[ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl;
[ObservableProperty] private bool _minimizeToTrayEnabled;
private readonly DispatcherTimer _logTimer;
private readonly GuiSettings _settings;
public MainViewModel(AdminPipeClient client)
{
_client = client;
_settings = GuiSettings.Load();
_minimizeToTrayEnabled = _settings.MinimizeToTrayEnabled;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
}
partial void OnMinimizeToTrayEnabledChanged(bool value)
{
_settings.MinimizeToTrayEnabled = value;
_settings.Save();
}
[RelayCommand]
private async Task RefreshAsync()
{
try
{
var status = await _client.GetStatusAsync().ConfigureAwait(false);
var config = await _client.GetConfigAsync().ConfigureAwait(false);
Application.Current.Dispatcher.Invoke(() =>
{
IsConnected = status?.Running == true;
ConnectionStatus = IsConnected
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
: "Disconnected";
if (status is not null)
{
var host = string.IsNullOrEmpty(status.DisplayHost) ? "localhost" : status.DisplayHost;
HttpBaseUrl = $"http://{host}:{status.HttpPort}";
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://{host}:{status.HttpsPort.Value}" : null;
}
Endpoints.Clear();
if (config is not null)
{
ServerConfig = config;
foreach (var ep in config.Endpoints) Endpoints.Add(ep);
}
});
}
catch (Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
{
IsConnected = false;
ConnectionStatus = $"Disconnected: {ex.Message}";
});
}
await RefreshLogTailAsync().ConfigureAwait(false);
}
[RelayCommand]
private async Task RefreshLogTailAsync()
{
try
{
var lines = await _client.TailLogsAsync(100).ConfigureAwait(false);
var text = new StringBuilder();
foreach (var line in lines) text.AppendLine(line.Message);
Application.Current.Dispatcher.Invoke(() => LogTail = text.ToString());
}
catch
{
// ignore — main connection state already reflects pipe failure
}
}
[RelayCommand]
private async Task AddEndpointAsync()
{
var draft = new EndpointConfig { Id = Guid.NewGuid(), Slug = "new-hook" };
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
var vm = new EndpointEditorViewModel(draft, isNew: true);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
await _client.CreateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Create failed", ex);
}
}
[RelayCommand]
private async Task EditEndpointAsync()
{
if (SelectedEndpoint is null) return;
var dlg = new EndpointEditor { Owner = Application.Current.MainWindow };
var vm = new EndpointEditorViewModel(SelectedEndpoint, isNew: false);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
await _client.UpdateEndpointAsync(vm.Endpoint).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Update failed", ex);
}
}
[RelayCommand]
private async Task DeleteEndpointAsync()
{
if (SelectedEndpoint is null) return;
var ok = MessageBox.Show(
$"Delete endpoint '{SelectedEndpoint.Slug}'?",
"Confirm",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.DeleteEndpointAsync(SelectedEndpoint.Id).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Delete failed", ex);
}
}
[RelayCommand]
private async Task ToggleEnabledAsync(EndpointConfig? ep)
{
if (ep is null) return;
try
{
await _client.SetEndpointEnabledAsync(ep.Id, !ep.Enabled).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Toggle failed", ex);
}
}
[RelayCommand]
private void ShowConfigCheckpoints()
{
var dlg = new Views.ConfigCheckpointsDialog
{
Owner = Application.Current.MainWindow,
DataContext = new ConfigCheckpointsViewModel(_client),
};
dlg.ShowDialog();
// After the dialog closes, the live config may have changed via rollback,
// so refresh the main grid.
_ = RefreshAsync();
}
[RelayCommand]
private async Task ExportConfigAsync()
{
try
{
var snap = await _client.GetConfigAsync().ConfigureAwait(false);
if (snap is null) { ShowError("Export failed", new InvalidOperationException("Service did not return a config.")); return; }
var dlg = new Microsoft.Win32.SaveFileDialog
{
FileName = $"webhook-server-config-{DateTime.Now:yyyyMMdd-HHmmss}.json",
DefaultExt = ".json",
Filter = "JSON config (*.json)|*.json",
};
if (dlg.ShowDialog() != true) return;
var json = System.Text.Json.JsonSerializer.Serialize(snap, WebhookServer.Core.Storage.ConfigJson.Pretty);
await System.IO.File.WriteAllTextAsync(dlg.FileName, json).ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Export failed", ex); }
}
[RelayCommand]
private async Task ImportConfigAsync()
{
var dlg = new Microsoft.Win32.OpenFileDialog
{
Filter = "JSON config (*.json)|*.json",
CheckFileExists = true,
};
if (dlg.ShowDialog() != true) return;
try
{
var json = await System.IO.File.ReadAllTextAsync(dlg.FileName).ConfigureAwait(false);
var cfg = System.Text.Json.JsonSerializer.Deserialize<ServerConfig>(json, WebhookServer.Core.Storage.ConfigJson.Pretty);
if (cfg is null) throw new InvalidOperationException("File did not contain a valid config.");
var ok = MessageBox.Show(
$"Replace the current configuration with {dlg.FileName}?\n\nA checkpoint of the current config is saved first, so you can roll back from File → Config Checkpoints.",
"Import config",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
await _client.ImportConfigAsync(cfg).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex) { ShowError("Import failed", ex); }
}
[RelayCommand]
private async Task RestartServiceAsync()
{
var ok = MessageBox.Show(
"Restart the WebhookServer service? In-flight requests will be aborted.",
"Restart service",
MessageBoxButton.OKCancel,
MessageBoxImage.Warning);
if (ok != MessageBoxResult.OK) return;
try
{
await _client.RestartListenerAsync().ConfigureAwait(false);
await Task.Delay(2000).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Restart failed", ex);
}
}
[RelayCommand]
private void ShowAbout()
{
var dlg = new Views.AboutDialog { Owner = Application.Current.MainWindow };
dlg.ShowDialog();
}
[RelayCommand]
private void OpenDocumentation()
{
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "https://github.com/recklessop/webhook-server/tree/main/docs",
UseShellExecute = true,
});
}
catch (Exception ex)
{
ShowError("Could not open documentation", ex);
}
}
/// <summary>Raised when the user picks File -> Exit. MainWindow flips its
/// ExitForReal flag and shuts down, bypassing the X-hides-to-tray logic.</summary>
public event Action? RealExitRequested;
[RelayCommand]
private void Exit()
{
RealExitRequested?.Invoke();
}
[RelayCommand]
private void CopyEndpointUrl()
{
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
try
{
Clipboard.SetText(url);
}
catch (Exception ex)
{
ShowError("Copy failed", ex);
}
}
[RelayCommand]
private async Task EditServerSettingsAsync()
{
var dlg = new ServerSettings { Owner = Application.Current.MainWindow };
var vm = new ServerSettingsViewModel(ServerConfig);
dlg.DataContext = vm;
if (dlg.ShowDialog() != true) return;
try
{
ServerConfig.HttpPort = vm.HttpPort;
ServerConfig.TrustedProxies = vm.TrustedProxiesList;
ServerConfig.HttpsBinding = vm.BuildBinding();
ServerConfig.BindAddresses = vm.BindAddressesList;
ServerConfig.DisplayHost = vm.DisplayHostValue;
await _client.InvokeAsync(AdminOps.UpdateConfig, ServerConfig).ConfigureAwait(false);
await RefreshAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
ShowError("Save failed", ex);
}
}
private static void ShowError(string title, Exception ex)
{
Application.Current.Dispatcher.Invoke(() =>
MessageBox.Show(ex.Message, title, MessageBoxButton.OK, MessageBoxImage.Error));
}
}