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
+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>