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:
@@ -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; } = "";
|
||||
|
||||
@@ -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 { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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