diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 0ae6cff..4dd636e 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -23,7 +23,7 @@ services: Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf # No systemd in container: restart by killing and re-running dnsmasq (same as entrypoint.sh). Config path must match DNSMASQ_CONF/MainConfigPath. - Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} >/tmp/dnsmasq-restart.err 2>&1 || { cat /tmp/dnsmasq-restart.err >&2; exit 1; }; dnsmasq -k --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} >/tmp/dnsmasq-restart.err 2>&1 & sleep 1; pgrep -x dnsmasq >/dev/null || { cat /tmp/dnsmasq-restart.err >&2; exit 1; }" + Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} && dnsmasq --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}" Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq" Dnsmasq__StatusCommand: "pgrep -x dnsmasq" # Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf) diff --git a/scripts/dnsmasq-status.sh b/scripts/dnsmasq-status.sh index 0f4f7cc..ebf1aae 100755 --- a/scripts/dnsmasq-status.sh +++ b/scripts/dnsmasq-status.sh @@ -1,12 +1,22 @@ #!/bin/sh # Simulates "systemctl status dnsmasq" output when run in a container without systemd. # Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented. +# When dnsmasq is not running, runs dnsmasq --test to show config/startup errors (use DNSMASQ_CONF for config path). pid=$(pgrep -x dnsmasq) if [ -z "$pid" ]; then echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server" echo " Loaded: (container, no systemd)" echo " Active: inactive (dead)" + conf="${DNSMASQ_CONF:-/etc/dnsmasq.conf}" + if [ -f "$conf" ]; then + err=$(dnsmasq --test --conf-file="$conf" 2>&1) + if [ -n "$err" ]; then + echo "" + echo " Config test (dnsmasq --test):" + echo "$err" | sed 's/^/ /' + fi + fi echo "" exit 0 fi diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor index 9291ab9..a45c5cb 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor @@ -16,7 +16,7 @@
- +
diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor index 5c1a872..5717335 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor @@ -27,14 +27,14 @@ }
- + @if (_status.LogsPath != null) { - Download logs + Download logs } else { - + }
} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/Status/StatusSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/Status/StatusSection.razor index 7c9ad8e..3238f0d 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/Status/StatusSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/Status/StatusSection.razor @@ -25,6 +25,6 @@
@_status.StatusShowOutput
}
- +
} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor index cc7b300..bc9e02a 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor @@ -3,7 +3,7 @@
@Text - +
@code { diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor index 86a351a..59c2b24 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor @@ -26,7 +26,7 @@ + OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" /> } @@ -198,4 +198,11 @@ _showSaveModal = false; StateHasChanged(); } + + private void CloseSaveModalAndEndEdit() + { + _showSaveModal = false; + Session.ExitEditModeDiscard(); + StateHasChanged(); + } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index 9a85c5c..a6e9fbc 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -208,8 +208,8 @@ else foreach (var raw in values) { var v = raw.Trim(); - if (!IPAddress.TryParse(v, out var ip) || ip.AddressFamily != AddressFamily.InterNetwork) - return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 address."; + if (!IPAddress.TryParse(v, out var ip) || (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6)) + return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 or IPv6 address."; } } return null; diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor index c4598f9..88c870e 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor @@ -18,7 +18,8 @@ OnClose="@OnClose" OnSave="@HandleSave" OnRestore="@HandleRestore" - OnReverted="@HandleReverted" /> + OnReverted="@HandleReverted" + OnCloseAndEndEdit="@OnCloseAndEndEdit" /> } @code { @@ -37,6 +38,7 @@ [Parameter] public DnsmasqServiceStatus? Status { get; set; } [Parameter] public EventCallback OnClose { get; set; } [Parameter] public EventCallback OnSaveCompleted { get; set; } + [Parameter] public EventCallback OnCloseAndEndEdit { get; set; } [Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!; private SaveUiState _saveState = SaveUiState.Review; @@ -70,9 +72,9 @@ var result = await Session.ApplyAsync(); _saveResult = result; - if (result.Saved && result.Reloaded) + if (result.Saved && result.Restarted) { - var message = result.UserMessage ?? "Config saved and dnsmasq reloaded."; + var message = result.UserMessage ?? "Config saved and dnsmasq restarted."; NotificationService.ShowSuccess(message); _saveState = SaveUiState.Review; _saveResult = null; @@ -81,7 +83,7 @@ return; } - if (result.Saved && !result.Reloaded) + if (result.Saved && !result.Restarted) _saveState = SaveUiState.ReloadFailed; else if (!result.Saved) _saveState = SaveUiState.WriteFailed; @@ -102,9 +104,9 @@ var result = await SaveService.RestoreAsync(backupPath); _restoreResult = result; - if (result.Restored && result.Reloaded) + if (result.Restored && result.Restarted) { - var message = result.UserMessage ?? "Backup restored and dnsmasq reloaded."; + var message = result.UserMessage ?? "Backup restored and dnsmasq restarted."; NotificationService.ShowSuccess(message); _saveState = SaveUiState.Review; _saveResult = null; diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor index a7d14df..5f52bde 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor @@ -15,24 +15,27 @@ {
-

@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and reloading…" : "Restoring backup and reloading…")

+

@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and restarting…" : "Restoring backup and restarting…")

} else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null) {
Save failed. @SaveResult.UserMessage - @if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr)) + @if (!string.IsNullOrEmpty(SaveResult.RestartStdErr)) { -
@SaveResult.ReloadStdErr
+
@SaveResult.RestartStdErr
}
} else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null) {
-
- Config was saved, but reload failed. You can restore the backup or keep editing. +
+ +
+ Config was saved, but the restart command failed. You can restore the backup or keep editing. +
@if (SaveResult.BackupPath != null) { @@ -40,28 +43,23 @@ Backup: @SaveResult.BackupPath

} - @if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr)) - { -
- - @if (_stderrExpanded) - { -
@SaveResult.ReloadStdErr
- } -
- } +
+

Command output

+
@GetRestartFailedOutput(SaveResult)
+
} else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null) { -
- @RestoreResult.UserMessage - @if (!string.IsNullOrEmpty(RestoreResult.ReloadStdErr)) - { -
@RestoreResult.ReloadStdErr
- } +
+
+ +
@RestoreResult.UserMessage
+
+
+

Command output

+
@GetRestoreFailedOutput(RestoreResult)
+
} else @@ -122,7 +120,7 @@ }
} @@ -130,24 +128,26 @@ } } - @@ -166,13 +166,32 @@ [Parameter] public EventCallback OnSave { get; set; } [Parameter] public EventCallback OnRestore { get; set; } [Parameter] public EventCallback OnReverted { get; set; } - - private bool _stderrExpanded; + [Parameter] public EventCallback OnCloseAndEndEdit { get; set; } private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress; private string? ManagedFilePath => Status?.ManagedFilePath; + private static string GetRestartFailedOutput(EffectiveConfigSaveResult result) + { + var err = (result.RestartStdErr ?? "").Trim(); + var outp = (result.RestartStdOut ?? "").Trim(); + if (err.Length > 0 && outp.Length > 0) + return err + "\n\n" + outp; + if (err.Length > 0) return err; + if (outp.Length > 0) return outp; + var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : ""; + return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)"; + } + + private static string GetRestoreFailedOutput(EffectiveConfigRestoreResult result) + { + var err = (result.RestartStdErr ?? "").Trim(); + if (err.Length > 0) return err; + var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : ""; + return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)"; + } + /// If the value is a list of strings (multi-value option), return it; otherwise null. private static IReadOnlyList? AsStringList(object? v) { @@ -219,4 +238,10 @@ { await OnRestore.InvokeAsync(backupPath); } + + private async Task CloseAndEndEdit() + { + if (!AllowClose) return; + await OnCloseAndEndEdit.InvokeAsync(); + } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor.css b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor.css index 83b857c..fa0bdd5 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor.css +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor.css @@ -1,3 +1,8 @@ +/* Keep footer buttons on one line */ +.ec-save-modal-footer { + flex-wrap: nowrap; +} + /* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */ .modal-dialog { position: relative; @@ -54,8 +59,29 @@ word-break: break-all; } +/* Command output: ensure text is always visible (avoid inherited color/visibility issues) */ .ec-save-modal-pre { font-size: 0.8125rem; max-height: 12rem; overflow: auto; + color: var(--bs-body-color, #212529); + white-space: pre-wrap; + word-break: break-word; + min-height: 2.5rem; +} + +/* Alert icon in reload/restore failed states: larger, spacing, aligned with text */ +.ec-save-modal-alert { + --ec-save-modal-alert-icon-gap: 0.5rem; +} + +.ec-save-modal-alert-icon { + flex-shrink: 0; + font-size: 1.25rem; + margin-right: var(--ec-save-modal-alert-icon-gap); + line-height: 1; +} + +.ec-save-modal-alert-text { + min-width: 0; } diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor index 24f01f5..7ffcac2 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor @@ -146,8 +146,8 @@ } diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index ab06c7e..4127af8 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -97,7 +97,7 @@ else — - + } else { @@ -203,7 +203,7 @@ else @if (editable) {
- +
} @@ -212,11 +212,11 @@ else
- - + + @if (_status.ReloadCommandConfigured) { - + }
} @@ -253,7 +253,7 @@ else {

No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.

- +
} else @@ -286,8 +286,8 @@ else @e.ClientId
- - + +
@@ -309,8 +309,8 @@ else
Name @e.Name
Client ID @e.ClientId
- - + +
@@ -318,7 +318,7 @@ else
- +
} diff --git a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor index 7880409..042c6fd 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor @@ -67,6 +67,10 @@ else if (_status != null) { } + else + { + + } @(_reloading ? "Reloading…" : "Reload config") diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 03f2047..f49be32 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -45,7 +45,7 @@ else } - + @if (_readOnlyHosts?.Count > 0) { diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs index ef465f2..287416c 100644 --- a/src/DnsmasqWebUI/Controllers/StatusController.cs +++ b/src/DnsmasqWebUI/Controllers/StatusController.cs @@ -43,8 +43,10 @@ public class StatusController : ControllerBase var (dhcpRangeStart, dhcpRangeEnd) = _configSetService.GetDhcpRange(); var systemHostsPath = _options.SystemHostsPath?.Trim(); - var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct); - var dnsmasqStatus = statusResult.ExitCode == 0 ? "active" : (statusResult.ExitCode.HasValue ? "inactive" : "unknown"); + var statusResult = await _processRunner.RunAsync(_options.StatusCommand, _options.StatusTimeout, ct); + var dnsmasqStatus = statusResult.TimedOut || statusResult.ExitCode is null || statusResult.ExceptionMessage is not null + ? "unknown" + : statusResult.ExitCode == 0 ? "active" : "inactive"; var statusCommandStdout = string.IsNullOrWhiteSpace(statusResult.Stdout) ? null : statusResult.Stdout.Trim(); var statusCommandStderr = string.IsNullOrWhiteSpace(statusResult.Stderr) ? null : statusResult.Stderr.Trim(); if (statusResult.ExceptionMessage != null) @@ -52,19 +54,19 @@ public class StatusController : ControllerBase var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand) ? Task.FromResult(new ProcessRunResult(null, "", "", false)) - : _processRunner.RunAsync(_options.StatusShowCommand, TimeSpan.FromSeconds(5), ct); + : _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct); var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand) ? Task.FromResult(new ProcessRunResult(null, "", "", false)) - : _processRunner.RunAsync(_options.LogsCommand, TimeSpan.FromSeconds(10), ct); + : _processRunner.RunAsync(_options.LogsCommand, _options.LogsTimeout, ct); await Task.WhenAll(showTask, logsTask); var showResult = await showTask; var logsResult = await logsTask; var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand) - ? FormatStatusShowOutput(showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : "")) + ? FormatStatusShowOutput(AppendCommandFailureMarker(showResult.Stdout, showResult.TimedOut, showResult.ExceptionMessage)) : null; var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand) - ? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "") + ? AppendCommandFailureMarker(logsResult.Stdout, logsResult.TimedOut, logsResult.ExceptionMessage) : null; var status = new DnsmasqServiceStatus( @@ -109,6 +111,15 @@ public class StatusController : ControllerBase } } + private static string AppendCommandFailureMarker(string output, bool timedOut, string? exceptionMessage) + { + if (timedOut) + return output + "\n(Command timed out.)"; + if (!string.IsNullOrEmpty(exceptionMessage)) + return output + "\n(Command failed: " + exceptionMessage + ")"; + return output; + } + /// /// Reformats StatusShow output: Active line gets a line break after the semicolon; /// continuation (e.g. "4 min ago") is indented to align with the value after "Active: ". diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/Abstractions/IProcessHandle.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/Abstractions/IProcessHandle.cs new file mode 100644 index 0000000..815285d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/Abstractions/IProcessHandle.cs @@ -0,0 +1,20 @@ +using DnsmasqWebUI.Models.Contracts; + +namespace DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; + +/// +/// Handle to a started process. Call for streaming output, +/// and/or to wait for completion and get final result. +/// Disposing the handle kills the process if still running. +/// +public interface IProcessHandle : IAsyncDisposable +{ + /// Stream output lines as they are produced. Completes when the process exits and all output is read. + IAsyncEnumerable ReadOutputAsync(CancellationToken ct = default); + + /// Wait for the process to exit (or timeout). Returns final stdout/stderr and exit code. If timeout is set and exceeded, kills the process and returns with true. + Task WaitForExitAsync(TimeSpan? timeout = null, CancellationToken ct = default); + + /// Kill the process. + Task KillAsync(CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/Abstractions/IProcessRunner.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/Abstractions/IProcessRunner.cs index 31d481a..c0b03a8 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/Abstractions/IProcessRunner.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/Abstractions/IProcessRunner.cs @@ -3,7 +3,7 @@ using DnsmasqWebUI.Models.Contracts; namespace DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; -/// Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands. +/// Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands. Supports both run-to-completion and start-then-stream. public interface IProcessRunner : IApplicationScopedService { /// Runs a shell command with timeout and cancellation. Returns exit code (null if timed out or failed to start), stdout, stderr, and whether the run timed out. @@ -11,4 +11,7 @@ public interface IProcessRunner : IApplicationScopedService /// Same as RunAsync but caps stdout/stderr length. When exceeded, truncates and appends a notice. Task RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default); + + /// Starts a shell command and returns a handle for streaming output and/or waiting for exit. Command must be non-null and non-empty. + Task StartAsync(string command, int? maxOutputChars = null, CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessHandle.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessHandle.cs new file mode 100644 index 0000000..eb7845d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessHandle.cs @@ -0,0 +1,178 @@ +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Channels; +using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; +using DnsmasqWebUI.Models.Contracts; +using Microsoft.Extensions.Logging; + +namespace DnsmasqWebUI.Infrastructure.Services.Common.Process; + +/// Handle to a started process; buffers output and exposes streaming and wait-for-exit. +internal sealed class ProcessHandle : IProcessHandle +{ + private const string TruncMsg = "\n\n(output truncated)\n"; + private static readonly TimeSpan StreamCloseWaitTimeout = TimeSpan.FromSeconds(2); + + private readonly System.Diagnostics.Process _process; + private readonly Channel _channel; + private readonly StringBuilder _stdout = new(); + private readonly StringBuilder _stderr = new(); + private readonly int? _maxOutputChars; + private readonly ILogger? _logger; + private bool _channelCompleted; + private readonly object _gate = new(); + private readonly TaskCompletionSource _stdoutClosed = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _stderrClosed = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ProcessHandle(System.Diagnostics.Process process, Channel channel, int? maxOutputChars, ILogger? logger) + { + _process = process; + _channel = channel; + _maxOutputChars = maxOutputChars; + _logger = logger; + + process.EnableRaisingEvents = true; + process.Exited += (_, _) => + { + lock (_gate) + { + if (!_channelCompleted) + { + _channelCompleted = true; + _channel.Writer.Complete(); + } + } + }; + + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + AppendCapped(_stdout, e.Data + "\n", _maxOutputChars); + _channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdOut, e.Data, DateTime.UtcNow)); + } + else + _stdoutClosed.TrySetResult(); + }; + + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + AppendCapped(_stderr, e.Data + "\n", _maxOutputChars); + _channel.Writer.TryWrite(new ProcessOutputLine(ProcessOutputStream.StdErr, e.Data, DateTime.UtcNow)); + } + else + _stderrClosed.TrySetResult(); + }; + } + + public async IAsyncEnumerable ReadOutputAsync([EnumeratorCancellation] CancellationToken ct = default) + { + await foreach (var line in _channel.Reader.ReadAllAsync(ct)) + yield return line; + } + + public async Task WaitForExitAsync(TimeSpan? timeout, CancellationToken ct = default) + { + using var timeoutCts = timeout.HasValue ? CancellationTokenSource.CreateLinkedTokenSource(ct) : null; + if (timeoutCts != null && timeout!.Value > TimeSpan.Zero) + timeoutCts.CancelAfter(timeout.Value); + + var exitToken = timeoutCts?.Token ?? ct; + + // Drain channel so it does not grow unbounded when no one is calling ReadOutputAsync + var drainTask = Task.Run(async () => + { + try + { + await foreach (var _ in _channel.Reader.ReadAllAsync(ct)) { } + } + catch (OperationCanceledException) { } + }, ct); + + try + { + await _process.WaitForExitAsync(exitToken); + } + catch (OperationCanceledException) when (timeoutCts?.IsCancellationRequested == true && !ct.IsCancellationRequested) + { + var timeoutSeconds = timeout?.TotalSeconds ?? 0; + _logger?.LogWarning("Command timed out after {Timeout}s", timeoutSeconds); + try { _process.Kill(); } catch { /* best effort */ } + lock (_gate) + { + if (!_channelCompleted) + { + _channelCompleted = true; + _channel.Writer.Complete(); + } + } + try { await drainTask; } catch (OperationCanceledException) { } + await WaitForStreamsClosedAsync(); + var err = _stderr.ToString(); + if (!string.IsNullOrEmpty(err)) err += "\n"; + err += $"Command timed out after {timeoutSeconds} seconds."; + return new ProcessRunResult(null, _stdout.ToString(), err, true); + } + + try { await drainTask; } catch (OperationCanceledException) { } + await WaitForStreamsClosedAsync(); + var exitCode = _process.HasExited ? _process.ExitCode : -1; + _logger?.LogDebug("Command completed, exit code={ExitCode}", exitCode); + + return new ProcessRunResult(exitCode, _stdout.ToString(), _stderr.ToString(), false); + } + + public Task KillAsync(CancellationToken ct = default) + { + try { _process.Kill(); } catch { /* best effort */ } + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + lock (_gate) + { + if (!_channelCompleted) + { + _channelCompleted = true; + _channel.Writer.Complete(); + } + } + + if (!_process.HasExited) + { + try { _process.Kill(); } catch { /* best effort */ } + } + + _process.Dispose(); + await Task.CompletedTask; + } + + /// Wait for stdout/stderr async read streams to close so buffers are complete before we read them. Best practice: wait for both stream-close signals (e.Data == null) after process exit to avoid lost output (see e.g. dotnet/runtime#18789). + private async Task WaitForStreamsClosedAsync() + { + try + { + await Task.WhenAll(_stdoutClosed.Task, _stderrClosed.Task) + .WaitAsync(StreamCloseWaitTimeout); + } + catch (TimeoutException) + { + _logger?.LogDebug("Stream close wait timed out after {Seconds}s", StreamCloseWaitTimeout.TotalSeconds); + } + } + + private static void AppendCapped(StringBuilder sb, string line, int? maxChars) + { + if (maxChars.HasValue && sb.Length >= maxChars.Value) return; + sb.Append(line); + if (maxChars.HasValue && sb.Length > maxChars.Value) + { + var keep = Math.Max(0, maxChars.Value - TruncMsg.Length); + sb.Length = keep; + sb.Append(TruncMsg); + } + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessOutputLine.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessOutputLine.cs new file mode 100644 index 0000000..6dcf7b5 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessOutputLine.cs @@ -0,0 +1,14 @@ +namespace DnsmasqWebUI.Infrastructure.Services.Common.Process; + +/// Which stream a line came from when reading process output asynchronously. +public enum ProcessOutputStream +{ + StdOut, + StdErr, +} + +/// Single line of process output (stdout or stderr) with timestamp. Used by . +public record ProcessOutputLine( + ProcessOutputStream Stream, + string Line, + DateTime TimestampUtc); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessRunner.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessRunner.cs index 8bcdc11..9531f1a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessRunner.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Process/ProcessRunner.cs @@ -1,12 +1,12 @@ using System.Diagnostics; -using System.Text; +using System.Threading.Channels; using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions; using DnsmasqWebUI.Models.Contracts; using Microsoft.Extensions.Logging; namespace DnsmasqWebUI.Infrastructure.Services.Common.Process; -/// Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService. +/// Runs shell commands via /bin/sh with async output capture and timeout. Supports run-to-completion and start-then-stream. public sealed class ProcessRunner : IProcessRunner { private const int MaxCommandPrefixLength = 80; @@ -27,76 +27,10 @@ public sealed class ProcessRunner : IProcessRunner var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "..."; _logger.LogDebug("Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix); - var stdout = new StringBuilder(); - var stderr = new StringBuilder(); - using var process = new System.Diagnostics.Process - { - StartInfo = new System.Diagnostics.ProcessStartInfo - { - FileName = "/bin/sh", - Arguments = "-c \"" + command.Replace("\"", "\\\"") + "\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - } - }; - - var truncMsg = "\n\n(output truncated)\n"; - process.OutputDataReceived += (_, e) => - { - if (e.Data == null) return; - if (maxOutputChars.HasValue && stdout.Length >= maxOutputChars.Value) return; - stdout.AppendLine(e.Data); - if (maxOutputChars.HasValue && stdout.Length > maxOutputChars.Value) - { - var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length); - stdout.Length = keep; - stdout.Append(truncMsg); - } - }; - process.ErrorDataReceived += (_, e) => - { - if (e.Data == null) return; - if (maxOutputChars.HasValue && stderr.Length >= maxOutputChars.Value) return; - stderr.AppendLine(e.Data); - if (maxOutputChars.HasValue && stderr.Length > maxOutputChars.Value) - { - var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length); - stderr.Length = keep; - stderr.Append(truncMsg); - } - }; - try { - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(timeout); - try - { - await process.WaitForExitAsync(timeoutCts.Token); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested) - { - _logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds); - try { process.Kill(); } catch { /* best effort */ } - var err = stderr.ToString(); - if (!string.IsNullOrEmpty(err)) err += "\n"; - err += $"Command timed out after {timeout.TotalSeconds} seconds."; - return new ProcessRunResult(null, stdout.ToString(), err, true); - } - - var exitCode = process.HasExited ? process.ExitCode : -1; - _logger.LogDebug("Command completed, exit code={ExitCode}", exitCode); - return new ProcessRunResult( - exitCode, - stdout.ToString(), - stderr.ToString(), - false); + await using var handle = await StartAsync(trimmed, maxOutputChars, ct); + return await handle.WaitForExitAsync(timeout, ct); } catch (Exception ex) { @@ -104,4 +38,36 @@ public sealed class ProcessRunner : IProcessRunner return new ProcessRunResult(null, "", "", false, ex.Message); } } + + public async Task StartAsync(string command, int? maxOutputChars = null, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(command)) + throw new ArgumentException("Command must be non-null and non-empty.", nameof(command)); + + var trimmed = command.Trim(); + var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "..."; + _logger.LogDebug("Starting command (length={Length}): {CommandPrefix}", trimmed.Length, prefix); + + var process = new System.Diagnostics.Process + { + StartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "/bin/sh", + Arguments = "-c \"" + trimmed.Replace("\"", "\\\"") + "\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + var channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = false, SingleWriter = true }); + var handle = new ProcessHandle(process, channel, maxOutputChars, _logger); + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + return await Task.FromResult(handle); + } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs index b0cc30c..b7dfe09 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs @@ -40,10 +40,10 @@ public class ReloadService : IReloadService try { - var result = await _processRunner.RunAsync(command, TimeSpan.FromSeconds(30), ct); + var result = await _processRunner.RunAsync(command, _options.RestartTimeout, ct); var stderr = result.Stderr; if (result.TimedOut) - stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + "Reload command timed out after 30 seconds."; + stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + $"Reload command timed out after {_options.RestartTimeoutSeconds} seconds."; if (result.ExceptionMessage != null) stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage; diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs index bfb3928..5e406c5 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs @@ -68,7 +68,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession return EffectiveConfigSaveResult.NoChanges(); var result = await _saveService.SaveAsync(_pending.ToList(), ct); - if (result.Saved && result.Reloaded) + if (result.Saved && result.Restarted) ExitEditModeDiscard(); return result; } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs index 7d4d7c5..0192391 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs @@ -66,22 +66,22 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService "Failed to write config."); } - var reload = await _reloadService.ReloadAsync(ct); + var restartResult = await _reloadService.ReloadAsync(ct); var backupCreated = File.Exists(backupPath); - if (!reload.Success) - _logger.LogWarning("Config saved but reload failed: exit {ExitCode}, stderr: {Stderr}", reload.ExitCode, reload.StdErr); + if (!restartResult.Success) + _logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr); return new EffectiveConfigSaveResult( BackupCreated: backupCreated, BackupPath: backupCreated ? backupPath : null, Saved: true, - Reloaded: reload.Success, - ReloadExitCode: reload.ExitCode, - ReloadStdOut: reload.StdOut, - ReloadStdErr: reload.StdErr, - ErrorCode: reload.Success ? null : "reload_failed", - UserMessage: reload.Success ? "Saved and reloaded." : "Saved, but reload failed."); + Restarted: restartResult.Success, + RestartExitCode: restartResult.ExitCode, + RestartStdOut: restartResult.StdOut, + RestartStdErr: restartResult.StdErr, + ErrorCode: restartResult.Success ? null : "restart_failed", + UserMessage: restartResult.Success ? "Saved and dnsmasq restarted." : "Saved, but the restart command failed."); } /// @@ -106,19 +106,19 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService File.Copy(backupPath, managedPath, overwrite: true); _logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath); - var reload = await _reloadService.ReloadAsync(ct); + var restartResult = await _reloadService.ReloadAsync(ct); - if (!reload.Success) - _logger.LogWarning("Restore completed but reload failed: exit {ExitCode}, stderr: {Stderr}", reload.ExitCode, reload.StdErr); + if (!restartResult.Success) + _logger.LogWarning("Restore completed but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr); return new EffectiveConfigRestoreResult( Restored: true, - Reloaded: reload.Success, - ReloadExitCode: reload.ExitCode, - ReloadStdErr: reload.StdErr, - UserMessage: reload.Success - ? "Backup restored and dnsmasq reloaded." - : "Backup restored, but reload still failed."); + Restarted: restartResult.Success, + RestartExitCode: restartResult.ExitCode, + RestartStdErr: restartResult.StdErr, + UserMessage: restartResult.Success + ? "Backup restored and dnsmasq restarted." + : "Backup restored, but the restart command still failed."); } private static string BuildBackupPath(string managedPath) => diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Logs/LogsService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Logs/LogsService.cs index e72870c..b7937fa 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Logs/LogsService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Logs/LogsService.cs @@ -54,8 +54,21 @@ public sealed class LogsService : ILogsService { using var scope = _scopeFactory.CreateScope(); var processRunner = scope.ServiceProvider.GetRequiredService(); - var result = await processRunner.RunAsync(cmd, TimeSpan.FromSeconds(10), MaxCommandOutputChars, ct); - var raw = result.Stdout + (result.TimedOut ? "\n(Command timed out.)" : ""); + var result = await processRunner.RunAsync(cmd, _options.Value.LogsTimeout, MaxCommandOutputChars, ct); + + if (result.TimedOut || result.ExceptionMessage is not null) + { + var err = "(Logs command failed"; + if (result.TimedOut) err += ": timed out"; + else err += ": " + result.ExceptionMessage; + err += ")\n"; + if (!string.IsNullOrWhiteSpace(result.Stderr)) + err += result.Stderr + "\n"; + await PushChunkedAsync("DnsmasqLogsUpdate", "replace", err, ct); + return; + } + + var raw = result.Stdout; string mode; string content; diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs index 6e8cd3d..c77a139 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs @@ -43,4 +43,30 @@ public class DnsmasqOptions /// Optional command for recent logs (e.g. "journalctl -u dnsmasq -n 100 --no-pager"). Output shown on Dnsmasq page. public string? LogsCommand { get; set; } + + // --- Timeouts (seconds) for the commands above --- + + /// Timeout in seconds for / . Default 30. + public int RestartTimeoutSeconds { get; set; } = 15; + + /// Timeout in seconds for . Default 5. + public int StatusTimeoutSeconds { get; set; } = 5; + + /// Timeout in seconds for . Default 5. + public int StatusShowTimeoutSeconds { get; set; } = 5; + + /// Timeout in seconds for . Default 10. + public int LogsTimeoutSeconds { get; set; } = 10; + + /// as . + public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds); + + /// as . + public TimeSpan StatusTimeout => TimeSpan.FromSeconds(StatusTimeoutSeconds); + + /// as . + public TimeSpan StatusShowTimeout => TimeSpan.FromSeconds(StatusShowTimeoutSeconds); + + /// as . + public TimeSpan LogsTimeout => TimeSpan.FromSeconds(LogsTimeoutSeconds); } diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs index dbe7eda..a4ff80e 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs @@ -34,6 +34,17 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator maxTimeoutSeconds) + failures.Add($"Dnsmasq:RestartTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.RestartTimeoutSeconds}."); + if (options.StatusTimeoutSeconds < minTimeoutSeconds || options.StatusTimeoutSeconds > maxTimeoutSeconds) + failures.Add($"Dnsmasq:StatusTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusTimeoutSeconds}."); + if (options.StatusShowTimeoutSeconds < minTimeoutSeconds || options.StatusShowTimeoutSeconds > maxTimeoutSeconds) + failures.Add($"Dnsmasq:StatusShowTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.StatusShowTimeoutSeconds}."); + if (options.LogsTimeoutSeconds < minTimeoutSeconds || options.LogsTimeoutSeconds > maxTimeoutSeconds) + failures.Add($"Dnsmasq:LogsTimeoutSeconds must be between {minTimeoutSeconds} and {maxTimeoutSeconds}. Current value: {options.LogsTimeoutSeconds}."); + if (failures.Count == 0) return ValidateOptionsResult.Success; diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs index b97d85f..4a74244 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs @@ -1,14 +1,14 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; -/// Result of restoring the managed config from a backup file, then reloading dnsmasq. +/// Result of restoring the managed config from a backup file, then running the restart command. /// True when the backup was copied over the managed file. -/// True when dnsmasq reload succeeded after restore. -/// Reload process exit code; -1 when not run or failed to start. -/// Standard error from reload command. +/// True when the restart command succeeded after restore. +/// Restart command exit code; -1 when not run or failed to start. +/// Standard error from the restart command. /// Short message for the user. public record EffectiveConfigRestoreResult( bool Restored, - bool Reloaded, - int ReloadExitCode, - string? ReloadStdErr, + bool Restarted, + int RestartExitCode, + string? RestartStdErr, string? UserMessage); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs index bb4dfd5..0e47b1b 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs @@ -1,23 +1,23 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; -/// Result of the effective-config save flow (backup, write, reload). Used by UI to drive state and show restore/continue options. +/// Result of the effective-config save flow (backup, write, restart). Used by UI to drive state and show restore/continue options. /// True when a backup file was created before write. /// Path to the backup file when one was created; null otherwise. /// True when the managed config was written successfully. -/// True when dnsmasq reload succeeded after write. -/// Reload process exit code; -1 when not run or failed to start. -/// Standard output from reload command. -/// Standard error from reload command. -/// Machine-readable code: no_changes, missing_managed_path, write_failed, reload_failed, or null when success. +/// True when the restart command succeeded after write (config changes require restart, not SIGHUP). +/// Restart command process exit code; -1 when not run or failed to start. +/// Standard output from the restart command. +/// Standard error from the restart command. +/// Machine-readable code: no_changes, missing_managed_path, write_failed, restart_failed, or null when success. /// Short message for the user. public record EffectiveConfigSaveResult( bool BackupCreated, string? BackupPath, bool Saved, - bool Reloaded, - int ReloadExitCode, - string? ReloadStdOut, - string? ReloadStdErr, + bool Restarted, + int RestartExitCode, + string? RestartStdOut, + string? RestartStdErr, string? ErrorCode, string? UserMessage) {