Sync from GitHub main: v0.1.1 + v0.1.2 + wiki sync (#3)
This commit was merged in pull request #3.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user