From e65527f316bc988fd42c92637d976daffb4c95b9 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 10:38:28 -0400 Subject: [PATCH] Config Checkpoints dialog + daily auto-checkpoint; drop installer GUI launch Three fixes: 1. Config Checkpoints submenu replaced with a proper dialog. Lists checkpoints with timestamp/size/filename, has a "Take Checkpoint Now" button, and a "Roll Back" button that becomes enabled when a row is selected. The previous click-a-menu-entry-immediate-restore flow was too easy to fire by accident. 2. New CheckpointScheduler BackgroundService creates a checkpoint at midnight every day. Combined with the existing auto-on-save snapshots, this guarantees a daily rollback point even if the config wasn't edited that day. A new "create-checkpoint" admin op plus AdminPipeServer.CreateCheckpoint helper does the actual file copy; both manual (via the dialog) and the scheduler use it. 3. Installer: drop the post-install "Launch Webhook Server" wizard step. It tried to launch the GUI un-elevated, which fails because the GUI's manifest is requireAdministrator. The Start Menu shortcut handles elevation correctly, so the user can launch from there. Co-Authored-By: Claude Opus 4.7 (1M context) --- installer/webhook-server.iss | 7 +- src/WebhookServer.Core/Ipc/AdminProtocol.cs | 1 + src/WebhookServer.Gui/MainWindow.xaml | 20 +--- src/WebhookServer.Gui/MainWindow.xaml.cs | 5 - .../Services/AdminPipeClient.cs | 3 + .../ViewModels/ConfigCheckpointsViewModel.cs | 94 +++++++++++++++++++ .../ViewModels/MainViewModel.cs | 39 ++------ .../Views/ConfigCheckpointsDialog.xaml | 51 ++++++++++ .../Views/ConfigCheckpointsDialog.xaml.cs | 19 ++++ src/WebhookServer.Service/AdminPipeServer.cs | 36 +++++++ .../CheckpointScheduler.cs | 50 ++++++++++ src/WebhookServer.Service/Program.cs | 1 + 12 files changed, 269 insertions(+), 57 deletions(-) create mode 100644 src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs create mode 100644 src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml create mode 100644 src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml.cs create mode 100644 src/WebhookServer.Service/CheckpointScheduler.cs diff --git a/installer/webhook-server.iss b/installer/webhook-server.iss index e112497..89ab350 100644 --- a/installer/webhook-server.iss +++ b/installer/webhook-server.iss @@ -69,9 +69,10 @@ Filename: "powershell.exe"; \ Parameters: "-NoProfile -ExecutionPolicy Bypass -File ""{app}\scripts\install-service.ps1"" -BinaryPath ""{app}\{#ServiceExeName}"""; \ StatusMsg: "Installing Windows Service..."; \ Flags: runhidden -Filename: "{app}\{#AppExeName}"; \ - Description: "Launch {#AppName}"; \ - Flags: postinstall nowait skipifsilent +; No post-install GUI launch: the GUI is requireAdministrator and the launch +; from the installer wizard ends up un-elevated for the post-install user, so +; it would just fail to connect to the admin pipe. The Start Menu shortcut +; handles the elevation correctly via the embedded manifest. [UninstallRun] Filename: "powershell.exe"; \ diff --git a/src/WebhookServer.Core/Ipc/AdminProtocol.cs b/src/WebhookServer.Core/Ipc/AdminProtocol.cs index 5ca1c4e..6977ccb 100644 --- a/src/WebhookServer.Core/Ipc/AdminProtocol.cs +++ b/src/WebhookServer.Core/Ipc/AdminProtocol.cs @@ -26,6 +26,7 @@ public static class AdminOps public const string ListBackups = "list-backups"; public const string RestoreBackup = "restore-backup"; public const string ImportConfig = "import-config"; + public const string CreateCheckpoint = "create-checkpoint"; } public sealed class BackupEntry diff --git a/src/WebhookServer.Gui/MainWindow.xaml b/src/WebhookServer.Gui/MainWindow.xaml index c42fc0e..466c38a 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml +++ b/src/WebhookServer.Gui/MainWindow.xaml @@ -29,25 +29,7 @@ - - - - - + diff --git a/src/WebhookServer.Gui/MainWindow.xaml.cs b/src/WebhookServer.Gui/MainWindow.xaml.cs index de2a239..9f387ff 100644 --- a/src/WebhookServer.Gui/MainWindow.xaml.cs +++ b/src/WebhookServer.Gui/MainWindow.xaml.cs @@ -53,9 +53,4 @@ public partial class MainWindow : Window vm.EditEndpointCommand.Execute(null); } - private async void OnBackupsSubmenuOpened(object sender, RoutedEventArgs e) - { - if (DataContext is MainViewModel vm) - await vm.RefreshBackupsCommand.ExecuteAsync(null); - } } diff --git a/src/WebhookServer.Gui/Services/AdminPipeClient.cs b/src/WebhookServer.Gui/Services/AdminPipeClient.cs index 4ce804e..300d3f0 100644 --- a/src/WebhookServer.Gui/Services/AdminPipeClient.cs +++ b/src/WebhookServer.Gui/Services/AdminPipeClient.cs @@ -100,4 +100,7 @@ public sealed class AdminPipeClient public Task ImportConfigAsync(ServerConfig config, CancellationToken ct = default) => InvokeAsync(AdminOps.ImportConfig, config, ct); + + public Task CreateCheckpointAsync(CancellationToken ct = default) => + InvokeAsync(AdminOps.CreateCheckpoint, null, ct); } diff --git a/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs new file mode 100644 index 0000000..72e6797 --- /dev/null +++ b/src/WebhookServer.Gui/ViewModels/ConfigCheckpointsViewModel.cs @@ -0,0 +1,94 @@ +using System.Collections.ObjectModel; +using System.Runtime.Versioning; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using WebhookServer.Core.Ipc; +using WebhookServer.Gui.Services; + +namespace WebhookServer.Gui.ViewModels; + +[SupportedOSPlatform("windows")] +public sealed partial class ConfigCheckpointsViewModel : ObservableObject +{ + private readonly AdminPipeClient _client; + + public ObservableCollection Checkpoints { get; } = new(); + + [ObservableProperty] private BackupEntry? _selected; + [ObservableProperty] private string _statusMessage = ""; + + public ConfigCheckpointsViewModel(AdminPipeClient client) + { + _client = client; + } + + [RelayCommand] + public async Task RefreshAsync() + { + try + { + var list = await _client.ListBackupsAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + { + Checkpoints.Clear(); + foreach (var b in list) Checkpoints.Add(b); + StatusMessage = list.Count == 0 + ? "No checkpoints yet. Save the config or click Take Checkpoint Now." + : $"{list.Count} checkpoint{(list.Count == 1 ? "" : "s")}."; + }); + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => StatusMessage = $"Could not load: {ex.Message}"); + } + } + + [RelayCommand] + private async Task TakeCheckpointAsync() + { + try + { + var entry = await _client.CreateCheckpointAsync().ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + if (entry is not null) + { + Application.Current.Dispatcher.Invoke(() => + { + Selected = Checkpoints.FirstOrDefault(c => c.FileName == entry.FileName); + StatusMessage = $"Created {entry.FileName}"; + }); + } + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => + MessageBox.Show(ex.Message, "Take checkpoint failed", MessageBoxButton.OK, MessageBoxImage.Error)); + } + } + + [RelayCommand] + private async Task RollbackAsync() + { + if (Selected is null) return; + + var ok = MessageBox.Show( + $"Roll the configuration back to the checkpoint from {Selected.SavedAt.ToLocalTime():yyyy-MM-dd HH:mm:ss}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", + "Confirm rollback", + MessageBoxButton.OKCancel, + MessageBoxImage.Warning); + if (ok != MessageBoxResult.OK) return; + + try + { + await _client.RestoreBackupAsync(Selected.FileName).ConfigureAwait(false); + await RefreshAsync().ConfigureAwait(false); + Application.Current.Dispatcher.Invoke(() => + StatusMessage = $"Rolled back to {Selected!.FileName}."); + } + catch (Exception ex) + { + Application.Current.Dispatcher.Invoke(() => + MessageBox.Show(ex.Message, "Rollback failed", MessageBoxButton.OK, MessageBoxImage.Error)); + } + } +} diff --git a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs index 96190b0..5e944b6 100644 --- a/src/WebhookServer.Gui/ViewModels/MainViewModel.cs +++ b/src/WebhookServer.Gui/ViewModels/MainViewModel.cs @@ -175,39 +175,18 @@ public sealed partial class MainViewModel : ObservableObject } } - [ObservableProperty] private System.Collections.ObjectModel.ObservableCollection _backups = new(); - [RelayCommand] - private async Task RefreshBackupsAsync() + private void ShowConfigCheckpoints() { - try + var dlg = new Views.ConfigCheckpointsDialog { - var list = await _client.ListBackupsAsync().ConfigureAwait(false); - Application.Current.Dispatcher.Invoke(() => - { - Backups.Clear(); - foreach (var b in list) Backups.Add(b); - }); - } - catch { /* ignore - checkpoint listing isn't critical */ } - } - - [RelayCommand] - private async Task RestoreBackupAsync(BackupEntry? entry) - { - if (entry is null) return; - var ok = MessageBox.Show( - $"Restore the configuration from the checkpoint taken at {entry.SavedAt:yyyy-MM-dd HH:mm}?\n\nThe current configuration is automatically saved as a new checkpoint first, so you can roll forward again.", - "Restore checkpoint", - MessageBoxButton.OKCancel, - MessageBoxImage.Question); - if (ok != MessageBoxResult.OK) return; - try - { - await _client.RestoreBackupAsync(entry.FileName).ConfigureAwait(false); - await RefreshAsync().ConfigureAwait(false); - } - catch (Exception ex) { ShowError("Restore failed", ex); } + 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] diff --git a/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml new file mode 100644 index 0000000..a4f70c8 --- /dev/null +++ b/src/WebhookServer.Gui/Views/ConfigCheckpointsDialog.xaml @@ -0,0 +1,51 @@ + + + + A checkpoint is a snapshot of config.json taken before each save and once a day at midnight. + Pick one and click Roll Back to restore it. The current configuration is automatically saved + as a new checkpoint before any rollback, so you can always roll forward again. + + + +