mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Process runner, save/restore flow, status script, listen-address IPv6
- Process: Add IProcessHandle, ProcessOutputLine, StartAsync; RunAsync via StartAsync+WaitForExitAsync. Wait for stdout/stderr stream close before reading to fix empty restart output. ProcessTimeouts; ReloadService/StatusController/LogsService use config timeouts. - Effective config: Rename reload→restart in save/restore result types and UI. Save modal: show restart output, danger alert, Stop editing/Continue editing (yellow), footer one-line; fallback exit code when no output; ensure pre visibility (CSS). Restore failed: combined output helper. - Status script: When dnsmasq dead, run dnsmasq --test and show config error in status block (Docker harness; DNSMASQ_CONF). - listen-address: Allow IPv6 (ValidateDraftValue accepts InterNetworkV6). - Docker test: RestartCommand uses DnsmasqOptions timeout; DnsmasqOptions RestartTimeoutSeconds.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
|
||||
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add</button>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
|
||||
@foreach (var prefix in _prefixes)
|
||||
@@ -35,10 +35,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore defaults</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@if (_saving) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> } else { <i class="bi bi-floppy-fill me-1" aria-hidden="true"></i> }@(_saving ? "Saving…" : "Save")</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,14 +27,14 @@
|
||||
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
||||
}
|
||||
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
@if (_status.LogsPath != null)
|
||||
{
|
||||
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
|
||||
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm"><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download."><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
|
||||
}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<div class="d-flex align-items-center gap-1 @CssClass">
|
||||
<code class="flex-grow-1 text-break @CodeCssClass">@Text</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle">Copy</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle"><i class="bi bi-clipboard me-1" aria-hidden="true"></i>Copy</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
+8
-1
@@ -26,7 +26,7 @@
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
|
||||
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" />
|
||||
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -198,4 +198,11 @@
|
||||
_showSaveModal = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CloseSaveModalAndEndEdit()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
Session.ExitEditModeDiscard();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,24 +15,27 @@
|
||||
{
|
||||
<div class="ec-save-modal-loading text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
|
||||
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and reloading…" : "Restoring backup and reloading…")</p>
|
||||
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving and restarting…" : "Restoring backup and restarting…")</p>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
||||
{
|
||||
<div class="alert alert-danger mb-0">
|
||||
<strong>Save failed.</strong> @SaveResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
|
||||
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.ReloadStdErr</pre>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-reload-failed">
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>Config was saved, but reload failed.</strong> You can restore the backup or keep editing.
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">
|
||||
<strong>Config was saved, but the restart command failed.</strong> You can restore the backup or keep editing.
|
||||
</div>
|
||||
</div>
|
||||
@if (SaveResult.BackupPath != null)
|
||||
{
|
||||
@@ -40,28 +43,23 @@
|
||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
||||
</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
|
||||
{
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary mb-2 @(_stderrExpanded ? "active" : "")" @onclick="() => _stderrExpanded = !_stderrExpanded" aria-expanded="@_stderrExpanded">
|
||||
@(_stderrExpanded ? "Hide" : "Show") reload stderr
|
||||
</button>
|
||||
@if (_stderrExpanded)
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0">@SaveResult.ReloadStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Command output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestartFailedOutput(SaveResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
|
||||
{
|
||||
<div class="alert alert-warning mb-0">
|
||||
@RestoreResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(RestoreResult.ReloadStdErr))
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@RestoreResult.ReloadStdErr</pre>
|
||||
}
|
||||
<div class="ec-save-modal-restore-failed">
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">@RestoreResult.UserMessage</div>
|
||||
</div>
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Command output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestoreFailedOutput(RestoreResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -122,7 +120,7 @@
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Revert this change" aria-label="Revert @change.OptionName">
|
||||
Revert
|
||||
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -130,24 +128,26 @@
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer ec-save-modal-footer">
|
||||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult?.BackupPath != null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)">Restore backup</button>
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="Close">Continue editing</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
}
|
||||
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)">Save</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,13 +166,32 @@
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
[Parameter] public EventCallback<string> 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)";
|
||||
}
|
||||
|
||||
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
|
||||
private static IReadOnlyList<string>? AsStringList(object? v)
|
||||
{
|
||||
@@ -219,4 +238,10 @@
|
||||
{
|
||||
await OnRestore.InvokeAsync(backupPath);
|
||||
}
|
||||
|
||||
private async Task CloseAndEndEdit()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnCloseAndEndEdit.InvokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -146,8 +146,8 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save">Save</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -97,7 +97,7 @@ else
|
||||
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" title="Add ignore flag so dnsmasq will not assign this client a lease" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
|
||||
<td class="text-muted small">—</td>
|
||||
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
|
||||
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button></td>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -203,7 +203,7 @@ else
|
||||
@if (editable)
|
||||
{
|
||||
<div class="table-card-row mt-2">
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button>
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -212,11 +212,11 @@ else
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="Add">Add host</button>
|
||||
<button class="btn btn-primary" @onclick="Save" disabled="@_saving">Save</button>
|
||||
<button class="btn btn-primary" @onclick="Add"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add host</button>
|
||||
<button class="btn btn-primary" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
@if (_status.ReloadCommandConfigured)
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving">Reload config</button>
|
||||
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload config</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -253,7 +253,7 @@ else
|
||||
{
|
||||
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -286,8 +286,8 @@ else
|
||||
<td>@e.ClientId</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add static</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)"><i class="bi bi-slash-circle me-1" aria-hidden="true"></i>Ignore</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -309,8 +309,8 @@ else
|
||||
<div class="table-card-row"><span class="table-card-label">Name</span> @e.Name</div>
|
||||
<div class="table-card-row"><span class="table-card-label">Client ID</span> @e.ClientId</div>
|
||||
<div class="table-card-row mt-2 d-flex flex-wrap gap-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add static</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)"><i class="bi bi-slash-circle me-1" aria-hidden="true"></i>Ignore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,7 +318,7 @@ else
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@@ -67,6 +67,10 @@ else if (_status != null)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>
|
||||
}
|
||||
@(_reloading ? "Reloading…" : "Reload config")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ else
|
||||
}
|
||||
|
||||
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
|
||||
@if (_readOnlyHosts?.Count > 0)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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: ".
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Handle to a started process. Call <see cref="ReadOutputAsync"/> for streaming output,
|
||||
/// and/or <see cref="WaitForExitAsync"/> to wait for completion and get final result.
|
||||
/// Disposing the handle kills the process if still running.
|
||||
/// </summary>
|
||||
public interface IProcessHandle : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Stream output lines as they are produced. Completes when the process exits and all output is read.</summary>
|
||||
IAsyncEnumerable<ProcessOutputLine> ReadOutputAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>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 <see cref="ProcessRunResult.TimedOut"/> true.</summary>
|
||||
Task<ProcessRunResult> WaitForExitAsync(TimeSpan? timeout = null, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Kill the process.</summary>
|
||||
Task KillAsync(CancellationToken ct = default);
|
||||
}
|
||||
+4
-1
@@ -3,7 +3,7 @@ using DnsmasqWebUI.Models.Contracts;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||
|
||||
/// <summary>Runs a shell command with timeout and cancellation. Used for status, reload, status show, and logs commands.</summary>
|
||||
/// <summary>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.</summary>
|
||||
public interface IProcessRunner : IApplicationScopedService
|
||||
{
|
||||
/// <summary>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.</summary>
|
||||
@@ -11,4 +11,7 @@ public interface IProcessRunner : IApplicationScopedService
|
||||
|
||||
/// <summary>Same as RunAsync but caps stdout/stderr length. When exceeded, truncates and appends a notice.</summary>
|
||||
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Starts a shell command and returns a handle for streaming output and/or waiting for exit. Command must be non-null and non-empty.</summary>
|
||||
Task<IProcessHandle> StartAsync(string command, int? maxOutputChars = null, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Handle to a started process; buffers output and exposes streaming and wait-for-exit.</summary>
|
||||
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<ProcessOutputLine> _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<ProcessOutputLine> 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<ProcessOutputLine> ReadOutputAsync([EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
await foreach (var line in _channel.Reader.ReadAllAsync(ct))
|
||||
yield return line;
|
||||
}
|
||||
|
||||
public async Task<ProcessRunResult> 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;
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Common.Process;
|
||||
|
||||
/// <summary>Which stream a line came from when reading process output asynchronously.</summary>
|
||||
public enum ProcessOutputStream
|
||||
{
|
||||
StdOut,
|
||||
StdErr,
|
||||
}
|
||||
|
||||
/// <summary>Single line of process output (stdout or stderr) with timestamp. Used by <see cref="Abstractions.IProcessHandle.ReadOutputAsync"/>.</summary>
|
||||
public record ProcessOutputLine(
|
||||
ProcessOutputStream Stream,
|
||||
string Line,
|
||||
DateTime TimestampUtc);
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService.</summary>
|
||||
/// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Supports run-to-completion and start-then-stream.</summary>
|
||||
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<IProcessHandle> 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<ProcessOutputLine>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
+18
-18
@@ -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.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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) =>
|
||||
|
||||
@@ -54,8 +54,21 @@ public sealed class LogsService : ILogsService
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var processRunner = scope.ServiceProvider.GetRequiredService<IProcessRunner>();
|
||||
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;
|
||||
|
||||
@@ -43,4 +43,30 @@ public class DnsmasqOptions
|
||||
|
||||
/// <summary>Optional command for recent logs (e.g. "journalctl -u dnsmasq -n 100 --no-pager"). Output shown on Dnsmasq page.</summary>
|
||||
public string? LogsCommand { get; set; }
|
||||
|
||||
// --- Timeouts (seconds) for the commands above ---
|
||||
|
||||
/// <summary>Timeout in seconds for <see cref="RestartCommand"/> / <see cref="ReloadCommand"/>. Default 30.</summary>
|
||||
public int RestartTimeoutSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>Timeout in seconds for <see cref="StatusCommand"/>. Default 5.</summary>
|
||||
public int StatusTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Timeout in seconds for <see cref="StatusShowCommand"/>. Default 5.</summary>
|
||||
public int StatusShowTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Timeout in seconds for <see cref="LogsCommand"/>. Default 10.</summary>
|
||||
public int LogsTimeoutSeconds { get; set; } = 10;
|
||||
|
||||
/// <summary><see cref="RestartTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
||||
public TimeSpan RestartTimeout => TimeSpan.FromSeconds(RestartTimeoutSeconds);
|
||||
|
||||
/// <summary><see cref="StatusTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
||||
public TimeSpan StatusTimeout => TimeSpan.FromSeconds(StatusTimeoutSeconds);
|
||||
|
||||
/// <summary><see cref="StatusShowTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
||||
public TimeSpan StatusShowTimeout => TimeSpan.FromSeconds(StatusShowTimeoutSeconds);
|
||||
|
||||
/// <summary><see cref="LogsTimeoutSeconds"/> as <see cref="TimeSpan"/>.</summary>
|
||||
public TimeSpan LogsTimeout => TimeSpan.FromSeconds(LogsTimeoutSeconds);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<Dnsma
|
||||
// managed hosts file (ManagedHostsFileName in the same directory as main config). Hosts UI is available when
|
||||
// MainConfigPath is set (managed hosts path is then derived from it and ManagedHostsFileName).
|
||||
|
||||
const int minTimeoutSeconds = 1;
|
||||
const int maxTimeoutSeconds = 600;
|
||||
if (options.RestartTimeoutSeconds < minTimeoutSeconds || options.RestartTimeoutSeconds > 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;
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>Result of restoring the managed config from a backup file, then reloading dnsmasq.</summary>
|
||||
/// <summary>Result of restoring the managed config from a backup file, then running the restart command.</summary>
|
||||
/// <param name="Restored">True when the backup was copied over the managed file.</param>
|
||||
/// <param name="Reloaded">True when dnsmasq reload succeeded after restore.</param>
|
||||
/// <param name="ReloadExitCode">Reload process exit code; -1 when not run or failed to start.</param>
|
||||
/// <param name="ReloadStdErr">Standard error from reload command.</param>
|
||||
/// <param name="Restarted">True when the restart command succeeded after restore.</param>
|
||||
/// <param name="RestartExitCode">Restart command exit code; -1 when not run or failed to start.</param>
|
||||
/// <param name="RestartStdErr">Standard error from the restart command.</param>
|
||||
/// <param name="UserMessage">Short message for the user.</param>
|
||||
public record EffectiveConfigRestoreResult(
|
||||
bool Restored,
|
||||
bool Reloaded,
|
||||
int ReloadExitCode,
|
||||
string? ReloadStdErr,
|
||||
bool Restarted,
|
||||
int RestartExitCode,
|
||||
string? RestartStdErr,
|
||||
string? UserMessage);
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
|
||||
/// <summary>Result of the effective-config save flow (backup, write, reload). Used by UI to drive state and show restore/continue options.</summary>
|
||||
/// <summary>Result of the effective-config save flow (backup, write, restart). Used by UI to drive state and show restore/continue options.</summary>
|
||||
/// <param name="BackupCreated">True when a backup file was created before write.</param>
|
||||
/// <param name="BackupPath">Path to the backup file when one was created; null otherwise.</param>
|
||||
/// <param name="Saved">True when the managed config was written successfully.</param>
|
||||
/// <param name="Reloaded">True when dnsmasq reload succeeded after write.</param>
|
||||
/// <param name="ReloadExitCode">Reload process exit code; -1 when not run or failed to start.</param>
|
||||
/// <param name="ReloadStdOut">Standard output from reload command.</param>
|
||||
/// <param name="ReloadStdErr">Standard error from reload command.</param>
|
||||
/// <param name="ErrorCode">Machine-readable code: no_changes, missing_managed_path, write_failed, reload_failed, or null when success.</param>
|
||||
/// <param name="Restarted">True when the restart command succeeded after write (config changes require restart, not SIGHUP).</param>
|
||||
/// <param name="RestartExitCode">Restart command process exit code; -1 when not run or failed to start.</param>
|
||||
/// <param name="RestartStdOut">Standard output from the restart command.</param>
|
||||
/// <param name="RestartStdErr">Standard error from the restart command.</param>
|
||||
/// <param name="ErrorCode">Machine-readable code: no_changes, missing_managed_path, write_failed, restart_failed, or null when success.</param>
|
||||
/// <param name="UserMessage">Short message for the user.</param>
|
||||
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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user