Sync from GitHub main: v0.1.1 + v0.1.2 + wiki sync (#3)
Sync Wiki / sync (push) Has been cancelled
CI / build (push) Has been cancelled

This commit was merged in pull request #3.
This commit is contained in:
2026-05-08 11:14:17 -04:00
parent fe42f2f908
commit b17d832842
34 changed files with 2109 additions and 146 deletions
@@ -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
@@ -33,6 +34,7 @@ public sealed class BackupEntry
public string FileName { get; set; } = "";
public DateTimeOffset SavedAt { get; set; }
public long SizeBytes { get; set; }
public string? Description { get; set; }
}
public sealed class RestoreBackupArgs
@@ -40,6 +42,11 @@ public sealed class RestoreBackupArgs
public string FileName { get; set; } = "";
}
public sealed class CreateCheckpointArgs
{
public string? Description { get; set; }
}
public sealed class AdminRequest
{
[JsonPropertyName("op")] public string Op { get; set; } = "";
+17 -3
View File
@@ -48,8 +48,15 @@ public sealed class ConfigStore
Directory.CreateDirectory(backupsDir);
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
var backupPath = System.IO.Path.Combine(backupsDir, $"config-{stamp}.json");
File.Copy(Path, backupPath, overwrite: false);
PruneBackups(backupsDir, retain: 30);
if (!File.Exists(backupPath))
{
File.Copy(Path, backupPath, overwrite: false);
var sidecar = new { description = "Before save", reason = "before-save" };
File.WriteAllText(
System.IO.Path.ChangeExtension(backupPath, ".meta.json"),
JsonSerializer.Serialize(sidecar, ConfigJson.Compact));
}
PruneBackups(backupsDir, retain: 90);
}
catch
{
@@ -71,11 +78,18 @@ public sealed class ConfigStore
private static void PruneBackups(string backupsDir, int retain)
{
var stale = new DirectoryInfo(backupsDir).GetFiles("config-*.json")
.Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(f => f.Name)
.Skip(retain);
foreach (var f in stale)
{
try { f.Delete(); } catch { }
try
{
f.Delete();
var sidecar = System.IO.Path.ChangeExtension(f.FullName, ".meta.json");
if (File.Exists(sidecar)) File.Delete(sidecar);
}
catch { }
}
}
+3 -18
View File
@@ -29,24 +29,7 @@
<Separator/>
<MenuItem Header="_Import config…" Command="{Binding ImportConfigCommand}"/>
<MenuItem Header="_Export config…" Command="{Binding ExportConfigCommand}"/>
<MenuItem Header="_Backups"
ItemsSource="{Binding Backups}"
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>
@@ -56,6 +39,8 @@
<MenuItem Header="_Restart service" Command="{Binding RestartServiceCommand}"/>
</MenuItem>
<MenuItem Header="_Help">
<MenuItem Header="_Documentation…" Command="{Binding OpenDocumentationCommand}"/>
<Separator/>
<MenuItem Header="_About Webhook Server…" Command="{Binding ShowAboutCommand}"/>
</MenuItem>
</Menu>
-5
View File
@@ -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(string? description, CancellationToken ct = default) =>
InvokeAsync<BackupEntry>(AdminOps.CreateCheckpoint, new CreateCheckpointArgs { Description = description }, ct);
}
@@ -0,0 +1,105 @@
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()
{
// Prompt for an optional description on the UI thread.
string? description = null;
var prompted = Application.Current.Dispatcher.Invoke(() =>
{
var dlg = new Views.TakeCheckpointDialog { Owner = Application.Current.MainWindow };
if (dlg.ShowDialog() != true) return false;
description = string.IsNullOrWhiteSpace(dlg.Description) ? null : dlg.Description;
return true;
});
if (!prompted) return;
try
{
var entry = await _client.CreateCheckpointAsync(description).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 - backup listing isn't critical */ }
}
[RelayCommand]
private async Task RestoreBackupAsync(BackupEntry? entry)
{
if (entry is null) return;
var ok = MessageBox.Show(
$"Restore configuration from {entry.FileName} ({entry.SavedAt:yyyy-MM-dd HH:mm})?\n\nA backup of the current config will be saved first.",
"Restore backup",
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]
@@ -249,7 +228,7 @@ public sealed partial class MainViewModel : ObservableObject
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 backup of the current config will be saved first.",
$"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);
@@ -290,6 +269,23 @@ public sealed partial class MainViewModel : ObservableObject
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);
}
}
[RelayCommand]
private void Exit()
{
@@ -0,0 +1,53 @@
<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="170"
Binding="{Binding SavedAt, StringFormat='{}{0:yyyy-MM-dd HH:mm:ss}', ConverterCulture=en-US}"/>
<DataGridTextColumn Header="Description" Width="*"
Binding="{Binding Description}"/>
<DataGridTextColumn Header="Size" Width="100"
Binding="{Binding SizeBytes, StringFormat='{}{0:n0} bytes'}"/>
<DataGridTextColumn Header="File name" Width="200"
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();
}
@@ -0,0 +1,34 @@
<Window x:Class="WebhookServer.Gui.Views.TakeCheckpointDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Take checkpoint"
Height="180" Width="440"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner"
Icon="/webhook-server.ico"
ShowInTaskbar="False">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" TextWrapping="Wrap"
Text="Description for this checkpoint (optional):"/>
<TextBox x:Name="DescriptionBox" Grid.Row="1" Margin="0,8,0,0" MaxLength="120">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding OkCommand, ElementName=Self, FallbackValue={x:Null}}"/>
</TextBox.InputBindings>
</TextBox>
<TextBlock Grid.Row="2" Foreground="Gray" FontStyle="Italic" FontSize="11" Margin="0,4,0,0"
Text="Examples: 'Before adding new endpoint', 'Pre-AD-policy-change'. Leave blank to use 'Manual checkpoint'."/>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="OK" Width="80" IsDefault="True" Click="OnOk" Margin="0,0,8,0"/>
<Button Content="Cancel" Width="80" IsCancel="True" Click="OnCancel"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,27 @@
using System.Windows;
namespace WebhookServer.Gui.Views;
public partial class TakeCheckpointDialog : Window
{
public string Description { get; private set; } = "";
public TakeCheckpointDialog()
{
InitializeComponent();
Loaded += (_, _) => DescriptionBox.Focus();
}
private void OnOk(object sender, RoutedEventArgs e)
{
Description = DescriptionBox.Text?.Trim() ?? "";
DialogResult = true;
Close();
}
private void OnCancel(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}
@@ -16,6 +16,7 @@
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>..\..\resources\webhook-server.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyTitle>Webhook Server</AssemblyTitle>
</PropertyGroup>
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="WebhookServer.Gui"/>
<!-- The GUI talks to the service via a named pipe ACL'd to SYSTEM and the
Administrators group. UAC token splitting denies that group on the
standard user token, so without elevation the pipe connect fails with
"Access is denied". Always run elevated. Start Menu shortcuts and the
installer's post-install launch both honor this. -->
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v2">
<requestedExecutionLevel level="requireAdministrator" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
@@ -225,16 +225,65 @@ internal sealed class AdminPipeServer : BackgroundService
return AdminResponse.Success(SafeSnapshotForWire(_state.Snapshot()));
}
case AdminOps.CreateCheckpoint:
{
var args = DeserializeData<CreateCheckpointArgs>(request);
var description = args?.Description;
if (string.IsNullOrWhiteSpace(description)) description = "Manual checkpoint";
var entry = CreateCheckpoint("manual", description);
_logger.LogInformation("Manual checkpoint created: {File} ({Desc})", entry.FileName, description);
return AdminResponse.Success(entry);
}
default:
return AdminResponse.Failure($"unknown op '{request.Op}'");
}
}
/// <summary>
/// Snapshot the current config.json into the backups folder. Used by the
/// "Take checkpoint now" GUI action, the midnight scheduler, and the
/// auto-on-save hook in ConfigStore. Description is stored in a sidecar
/// .meta.json file next to the snapshot so it survives restarts and can
/// be rendered in the GUI.
/// </summary>
public static BackupEntry CreateCheckpoint(string reason, string description)
{
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 (File.Exists(dest))
dest = Path.Combine(dir, $"config-{stamp}-{reason}.json");
File.Copy(configPath, dest);
// Write the sidecar metadata.
var sidecarPath = Path.ChangeExtension(dest, ".meta.json");
var sidecar = new { description, reason };
File.WriteAllText(sidecarPath, JsonSerializer.Serialize(sidecar, ConfigJson.Compact));
var info = new FileInfo(dest);
return new BackupEntry
{
FileName = info.Name,
SavedAt = info.LastWriteTimeUtc,
SizeBytes = info.Length,
Description = description,
};
}
private static List<BackupEntry> ListBackups()
{
var dir = Path.Combine(ServicePaths.DataRoot, "backups");
if (!Directory.Exists(dir)) return new List<BackupEntry>();
return new DirectoryInfo(dir).GetFiles("config-*.json")
.Where(f => !f.Name.EndsWith(".meta.json", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(f => f.Name)
.Take(50)
.Select(f => new BackupEntry
@@ -242,10 +291,23 @@ internal sealed class AdminPipeServer : BackgroundService
FileName = f.Name,
SavedAt = f.LastWriteTimeUtc,
SizeBytes = f.Length,
Description = ReadSidecarDescription(f.FullName),
})
.ToList();
}
private static string? ReadSidecarDescription(string snapshotPath)
{
try
{
var sidecarPath = Path.ChangeExtension(snapshotPath, ".meta.json");
if (!File.Exists(sidecarPath)) return null;
using var doc = JsonDocument.Parse(File.ReadAllText(sidecarPath));
return doc.RootElement.TryGetProperty("description", out var d) ? d.GetString() : null;
}
catch { return null; }
}
private async Task<ServerConfig> RestoreBackupAsync(string fileName, CancellationToken ct)
{
// Refuse anything that tries to escape the backups directory.
@@ -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", "Nightly auto-checkpoint");
_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");
}
}
}
}
+1
View File
@@ -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();