GUI UX, secret visibility, browser-friendly hooks, deploy script

GUI:
- URL column in endpoint grid + Copy URL toolbar button so the full
  http://host:port/hook/<slug> is one click away
- Double-click a row to open the edit dialog
- Bearer/HMAC sections in the editor hide when the auth mode doesn't
  use them, and reappear with previously-entered values when switched
  back
- Log panel auto-scroll checkbox (default on) plus 3s polling so log
  entries stream in without manual refresh
- Secret fields are now plain text with a Copy button. Anyone who can
  open the admin-pipe-ACL'd GUI is already SYSTEM-equivalent on the
  host, so masking the value just made recovery harder. PFX password
  in Server Settings gets the same treatment.

Service:
- Admin pipe ops log info-level lines on every mutation
  (create/update/delete/enable/disable/update-config/bind-https) so
  GUI activity is visible in the Serilog file
- /hook/{slug} accepts GET as well as POST so a browser smoke-test
  works without curl
- /favicon.ico returns 204 so browser hits don't pollute logs with 404s
- AdminPipeServer no longer strips plaintext secrets when sending
  config to the GUI; the pipe ACL already restricts to SYSTEM/Admins

Scripts:
- New deploy.ps1: stops + republishes + copies binaries to
  C:\Program Files\WebhookServer + (re)installs the Windows Service
- install-service.ps1 now uses sc.exe argv splatting consistently for
  both create and config paths

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 08:47:11 -04:00
parent 882d5332b4
commit 87bcb6807f
15 changed files with 299 additions and 62 deletions
@@ -4,6 +4,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using WebhookServer.Core.Ipc;
@@ -24,11 +25,19 @@ public sealed partial class MainViewModel : ObservableObject
[ObservableProperty] private string _connectionStatus = "Disconnected";
[ObservableProperty] private bool _isConnected;
[ObservableProperty] private string _logTail = "";
[ObservableProperty] private bool _autoScrollLogs = true;
[ObservableProperty] private ServerConfig _serverConfig = new();
[ObservableProperty] private string _httpBaseUrl = "http://localhost:8080";
[ObservableProperty] private string? _httpsBaseUrl;
private readonly DispatcherTimer _logTimer;
public MainViewModel(AdminPipeClient client)
{
_client = client;
_logTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromSeconds(3) };
_logTimer.Tick += async (_, _) => await RefreshLogTailAsync();
_logTimer.Start();
}
[RelayCommand]
@@ -46,6 +55,12 @@ public sealed partial class MainViewModel : ObservableObject
? $"Connected — HTTP {status!.HttpPort}{(status.HttpsPort.HasValue ? $" / HTTPS {status.HttpsPort}" : "")}"
: "Disconnected";
if (status is not null)
{
HttpBaseUrl = $"http://localhost:{status.HttpPort}";
HttpsBaseUrl = status.HttpsPort.HasValue ? $"https://localhost:{status.HttpsPort.Value}" : null;
}
Endpoints.Clear();
if (config is not null)
{
@@ -159,6 +174,21 @@ public sealed partial class MainViewModel : ObservableObject
}
}
[RelayCommand]
private void CopyEndpointUrl()
{
if (SelectedEndpoint is null || string.IsNullOrEmpty(HttpBaseUrl)) return;
var url = $"{HttpBaseUrl.TrimEnd('/')}/hook/{SelectedEndpoint.Slug}";
try
{
Clipboard.SetText(url);
}
catch (Exception ex)
{
ShowError("Copy failed", ex);
}
}
[RelayCommand]
private async Task EditServerSettingsAsync()
{