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 @@
-
@(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
ToggleIgnore(e)" title="Add ignore flag so dnsmasq will not assign this client a lease" /> |
|
— |
- |
+ |
}
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)
{