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) <noreply@anthropic.com>
This commit is contained in:
@@ -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"; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,25 +29,7 @@
|
||||
<Separator/>
|
||||
<MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
|
||||
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
|
||||
<MenuItem Header="Config _Checkpoints"
|
||||
ItemsSource="{Binding Backups}"
|
||||
ToolTip="Snapshots taken automatically before each save. Click one to restore."
|
||||
SubmenuOpened="OnBackupsSubmenuOpened">
|
||||
<MenuItem.ItemContainerStyle>
|
||||
<Style TargetType="MenuItem">
|
||||
<Setter Property="Header">
|
||||
<Setter.Value>
|
||||
<MultiBinding StringFormat="{}{0:yyyy-MM-dd HH:mm:ss} ({1:n0} bytes)">
|
||||
<Binding Path="SavedAt"/>
|
||||
<Binding Path="SizeBytes"/>
|
||||
</MultiBinding>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
<Setter Property="Command" Value="{Binding DataContext.RestoreBackupCommand, RelativeSource={RelativeSource AncestorType=Window}}"/>
|
||||
<Setter Property="CommandParameter" Value="{Binding}"/>
|
||||
</Style>
|
||||
</MenuItem.ItemContainerStyle>
|
||||
</MenuItem>
|
||||
<MenuItem Header="Config _Checkpoints…" Command="{Binding ShowConfigCheckpointsCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="E_xit" Command="{Binding ExitCommand}"/>
|
||||
</MenuItem>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,4 +100,7 @@ public sealed class AdminPipeClient
|
||||
|
||||
public Task<AdminResponse> ImportConfigAsync(ServerConfig config, CancellationToken ct = default) =>
|
||||
InvokeAsync(AdminOps.ImportConfig, config, ct);
|
||||
|
||||
public Task<BackupEntry?> CreateCheckpointAsync(CancellationToken ct = default) =>
|
||||
InvokeAsync<BackupEntry>(AdminOps.CreateCheckpoint, null, ct);
|
||||
}
|
||||
|
||||
@@ -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<BackupEntry> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,39 +175,18 @@ public sealed partial class MainViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
[ObservableProperty] private System.Collections.ObjectModel.ObservableCollection<BackupEntry> _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]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<Window x:Class="WebhookServer.Gui.Views.ConfigCheckpointsDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:WebhookServer.Gui.ViewModels"
|
||||
mc:Ignorable="d"
|
||||
Title="Config Checkpoints"
|
||||
Height="500" Width="640"
|
||||
Icon="/webhook-server.ico"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
d:DataContext="{d:DesignInstance Type=vm:ConfigCheckpointsViewModel}">
|
||||
<DockPanel Margin="12">
|
||||
<TextBlock DockPanel.Dock="Top" TextWrapping="Wrap" Margin="0,0,0,8" Foreground="#444">
|
||||
A checkpoint is a snapshot of <Bold>config.json</Bold> taken before each save and once a day at midnight.
|
||||
Pick one and click <Bold>Roll Back</Bold> to restore it. The current configuration is automatically saved
|
||||
as a new checkpoint before any rollback, so you can always roll forward again.
|
||||
</TextBlock>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
||||
<Button Content="Take Checkpoint Now" Command="{Binding TakeCheckpointCommand}" Margin="0,0,8,0" Padding="10,4"/>
|
||||
<Button Content="Roll Back" Command="{Binding RollbackCommand}"
|
||||
IsEnabled="{Binding Selected, Converter={StaticResource NotNull}}"
|
||||
Margin="0,0,8,0" Padding="10,4"/>
|
||||
<Button Content="Close" IsCancel="True" Click="OnClose" Padding="10,4"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<Button Content="Refresh" Command="{Binding RefreshCommand}" Padding="8,2"/>
|
||||
<TextBlock Text="{Binding StatusMessage}" Foreground="Gray" FontStyle="Italic" VerticalAlignment="Center" Margin="12,0,0,0"/>
|
||||
</StackPanel>
|
||||
|
||||
<DataGrid ItemsSource="{Binding Checkpoints}"
|
||||
SelectedItem="{Binding Selected, Mode=TwoWay}"
|
||||
AutoGenerateColumns="False"
|
||||
CanUserAddRows="False"
|
||||
CanUserDeleteRows="False"
|
||||
IsReadOnly="True"
|
||||
HeadersVisibility="Column"
|
||||
GridLinesVisibility="Horizontal">
|
||||
<DataGrid.Columns>
|
||||
<DataGridTextColumn Header="When (local)" Width="200"
|
||||
Binding="{Binding SavedAt, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}', ConverterCulture=en-US}"/>
|
||||
<DataGridTextColumn Header="Size" Width="100"
|
||||
Binding="{Binding SizeBytes, StringFormat='{}{0:n0} bytes'}"/>
|
||||
<DataGridTextColumn Header="File name" Width="*"
|
||||
Binding="{Binding FileName}" FontFamily="Consolas"/>
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Windows;
|
||||
using WebhookServer.Gui.ViewModels;
|
||||
|
||||
namespace WebhookServer.Gui.Views;
|
||||
|
||||
public partial class ConfigCheckpointsDialog : Window
|
||||
{
|
||||
public ConfigCheckpointsDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
Loaded += async (_, _) =>
|
||||
{
|
||||
if (DataContext is ConfigCheckpointsViewModel vm)
|
||||
await vm.RefreshAsync();
|
||||
};
|
||||
}
|
||||
|
||||
private void OnClose(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -225,11 +225,47 @@ internal sealed class AdminPipeServer : BackgroundService
|
||||
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
|
||||
}
|
||||
|
||||
case AdminOps.CreateCheckpoint:
|
||||
{
|
||||
var entry = CreateCheckpoint("manual");
|
||||
_logger.LogInformation("Manual checkpoint created: {File}", entry.FileName);
|
||||
return AdminResponse.Success(entry);
|
||||
}
|
||||
|
||||
default:
|
||||
return AdminResponse.Failure($"unknown op '{request.Op}'");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the current config.json into the backups folder. Used both by the
|
||||
/// "Take checkpoint now" GUI action and by the midnight scheduler.
|
||||
/// </summary>
|
||||
public static BackupEntry CreateCheckpoint(string reason)
|
||||
{
|
||||
var configPath = ServicePaths.ConfigPath;
|
||||
if (!File.Exists(configPath))
|
||||
throw new FileNotFoundException("no config.json exists yet to snapshot");
|
||||
|
||||
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var dest = Path.Combine(dir, $"config-{stamp}.json");
|
||||
// If we somehow snapshot twice in the same second, append a suffix.
|
||||
if (File.Exists(dest))
|
||||
dest = Path.Combine(dir, $"config-{stamp}-{reason}.json");
|
||||
|
||||
File.Copy(configPath, dest);
|
||||
var info = new FileInfo(dest);
|
||||
return new BackupEntry
|
||||
{
|
||||
FileName = info.Name,
|
||||
SavedAt = info.LastWriteTimeUtc,
|
||||
SizeBytes = info.Length,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<BackupEntry> ListBackups()
|
||||
{
|
||||
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace WebhookServer.Service;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a daily config checkpoint at midnight (local time). Combined with
|
||||
/// the auto-on-save snapshots in ConfigStore.SaveAsync, this guarantees a
|
||||
/// rollback point for every day even if the user makes no changes.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class CheckpointScheduler : BackgroundService
|
||||
{
|
||||
private readonly ILogger<CheckpointScheduler> _logger;
|
||||
|
||||
public CheckpointScheduler(ILogger<CheckpointScheduler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Daily checkpoint scheduler running");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var nextMidnight = now.Date.AddDays(1);
|
||||
var delay = nextMidnight - now;
|
||||
|
||||
try { await Task.Delay(delay, stoppingToken).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try
|
||||
{
|
||||
var entry = AdminPipeServer.CreateCheckpoint("daily");
|
||||
_logger.LogInformation("Daily checkpoint created: {File}", entry.FileName);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// No config.json yet (fresh install, GUI never opened) - skip silently.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Daily checkpoint creation failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ try
|
||||
builder.Services.AddSingleton<WebhookRouter>();
|
||||
builder.Services.AddHostedService<CallbackBackgroundService>();
|
||||
builder.Services.AddHostedService<AdminPipeServer>();
|
||||
builder.Services.AddHostedService<CheckpointScheduler>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user