Assembly-scanned services, options validator marker, UI tweaks, and config fixes

- Register ISettingsModalService, INotificationService, IUpdateCheckService via assembly scanning (marker interfaces)
- Add IApplicationOptionsValidator<TOptions> marker; scan only those for IValidateOptions registration
- Fix ApplicationLifecycleLoggingHostedService: log RestartCommand/ReloadCommand/StatusCommand (no ApplyCommand)
- NavMenu: spin animation for update-check button icon when checking; single icon with conditional nav-footer-spin class
- Add NotificationHost/NotificationToast components and NotificationService for toasts
- Effective config save flow, restore result, save service; various EffectiveConfig and DnsmasqOptions/validator updates
- Test and script updates; docker-compose.test.yml, appsettings, DnsmasqConfKeys/tooltips/sections
This commit is contained in:
2026-03-01 19:21:09 +10:00
parent b0ce7afb58
commit a776f445b6
52 changed files with 1016 additions and 155 deletions
+6 -3
View File
@@ -1,7 +1,8 @@
# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
# Some clients also make periodic DNS requests so the DNS cache and logs show activity.
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
# Prepare mount: ./scripts/prepare-test-mount.sh [--minimal-conf | --prepare-only | ...]
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
# Set TEST_DNSMASQ_CONF to override main config path (default: /data/dnsmasq-test.conf; use /data/dnsmasq-test-minimal.conf for minimal).
#
# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images).
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
@@ -18,9 +19,11 @@ services:
cap_add:
- NET_ADMIN
environment:
DNSMASQ_CONF: /data/dnsmasq-test.conf
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
DNSMASQ_CONF: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
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__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 -1
View File
@@ -50,7 +50,7 @@ except ImportError:
OUT_DIR = "/out"
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections. server + local = one UI row.
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections.
OPTION_KEYS = [
"no-hosts",
"addn-hosts",
+20 -1
View File
@@ -21,6 +21,7 @@ RECREATE=false
CLEAR=false
STOP=false
TIDY=false
MINIMAL_CONF=false
usage() {
echo "Usage: $0 [OPTIONS] [--]"
@@ -43,6 +44,8 @@ usage() {
echo " --clear Clear mount dir completely before sync. Use for a clean run."
echo ""
echo "Compose behaviour:"
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
echo " config has few readonly options; good for testing reload/restart failure flows."
echo " --prepare-only Only prepare the mount; do not run docker compose."
echo " Use to inspect or edit the mount before starting containers."
echo " --build Pass --build to docker compose (rebuild images before starting)."
@@ -85,6 +88,9 @@ usage() {
echo ""
echo " $0 --tidy"
echo " Stop harness and clear testdata-mount for a clean next run."
echo ""
echo " $0 --minimal-conf"
echo " Use dnsmasq-test-minimal.conf (single file, few options) and start."
}
while [ $# -gt 0 ]; do
@@ -125,6 +131,10 @@ while [ $# -gt 0 ]; do
RECREATE=true
shift
;;
--minimal-conf)
MINIMAL_CONF=true
shift
;;
--stop)
STOP=true
shift
@@ -210,7 +220,11 @@ if [ "$PREPARE_ONLY" = true ]; then
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
esac
echo "To start the harness: TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
START_CMD="TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
if [ "$MINIMAL_CONF" = true ]; then
START_CMD="TESTDATA_MOUNT=$MOUNT_EXPORT TEST_DNSMASQ_CONF=/data/dnsmasq-test-minimal.conf docker compose -f $COMPOSE_FILE up -d [--build]"
fi
echo "To start the harness: $START_CMD"
exit 0
fi
@@ -220,6 +234,11 @@ case "$MOUNT_DIR" in
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
esac
if [ "$MINIMAL_CONF" = true ]; then
export TEST_DNSMASQ_CONF="/data/dnsmasq-test-minimal.conf"
echo "Using minimal config: $TEST_DNSMASQ_CONF"
fi
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
if [ "$BUILD" = true ]; then
COMPOSE_CMD="$COMPOSE_CMD --build"
@@ -26,7 +26,7 @@ public class DnsmasqConfIncludeParserHostsAndLeaseTests
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
@@ -45,7 +45,8 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
{
var mainPath = GetOfficialExamplePath();
var paths = new[] { mainPath };
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
@@ -53,6 +54,7 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
Assert.Empty(servers);
Assert.Empty(locals);
Assert.Empty(dhcpOptionForce);
Assert.Empty(ipset);
Assert.Empty(nftset);
@@ -23,11 +23,13 @@ public class DnsmasqConfIncludeParserServerAndResolvTests
{
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
File.WriteAllText(f2, $"server={v3}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
Assert.Equal(3, result.Count);
Assert.Equal(v1, result[0]);
Assert.Equal(v2, result[1]);
Assert.Equal(v3, result[2]);
var resultServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Server);
var resultLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Local);
Assert.Equal(2, resultServer.Count);
Assert.Equal(v1, resultServer[0]);
Assert.Equal(v3, resultServer[1]);
Assert.Single(resultLocal);
Assert.Equal(v2, resultLocal[0]);
}
finally
{
@@ -25,7 +25,8 @@ public class DnsmasqConfigSetServiceTests
Assert.Empty(config.AddnHostsPaths);
Assert.NotNull(sources);
Assert.Empty(sources.AddnHostsPaths);
Assert.Empty(sources.ServerLocalValues);
Assert.Empty(sources.ServerValues);
Assert.Empty(sources.LocalValues);
Assert.Empty(sources.DhcpRanges);
Assert.Null(sources.NoHosts);
Assert.Null(sources.Port);
@@ -59,7 +60,8 @@ public class DnsmasqConfigSetServiceTests
Assert.Equal(cacheSize, config.CacheSize);
Assert.Single(config.AddnHostsPaths);
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
Assert.Empty(config.ServerLocalValues);
Assert.Empty(config.ServerValues);
Assert.Empty(config.LocalValues);
Assert.Empty(config.DhcpRanges);
Assert.NotNull(sources);
@@ -12,7 +12,8 @@ public class EffectiveDnsmasqConfigTests
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
HostsdirPath: null,
ServerLocalValues: Array.Empty<string>(),
ServerValues: Array.Empty<string>(),
LocalValues: Array.Empty<string>(),
RevServerValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
@@ -4,7 +4,11 @@
@using DnsmasqWebUI.Models.Config
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@using System.Net
@using System.Net.Sockets
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject INotificationService NotificationService
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
@if (_customFragment is { } fragment)
@@ -40,8 +44,14 @@
}
else if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId" />
var items = _hasPendingChange && _effectiveValue is IReadOnlyList<string> pendingList
? pendingList.Select(s => new ValueWithSource(s, null)).ToList()
: Descriptor.GetItems();
var allReadonly = items != null && items.Count > 0 && items.All(i => i.Source?.IsReadOnly == true);
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@(allReadonly ? items?.FirstOrDefault()?.Source : null)" ShowEditableBadge="@(!allReadonly)" HasPendingChange="@_hasPendingChange" FieldKey="@FieldKey"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
else
{
@@ -93,14 +103,17 @@ else
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
_optionHelpKey = Descriptor.OptionName;
var pending = Session?.PendingChanges.FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
var descriptorValue = Descriptor.IsMultiValue
? (Descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
: Descriptor.GetValue();
_hasPendingChange = pending != null;
_effectiveValue = pending != null ? pending.NewValue : Descriptor.GetValue();
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
if (!_wasActive && IsActive)
_valueAtActivation = Descriptor.GetValue();
_valueAtActivation = descriptorValue;
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
_ = InvokeAsync(CommitIfChangedAsync);
if (_wasActive && !IsActive)
@@ -119,17 +132,25 @@ else
await CommitIfChangedAsync();
}
private async Task CommitIfChangedAsync()
private async Task<bool> CommitIfChangedAsync()
{
var newValue = _draftValue ?? _effectiveValue;
if (!Equals(_valueAtActivation, newValue))
if (!ValuesEqual(_valueAtActivation, newValue))
{
var validationError = ValidateDraftValue(newValue);
if (validationError != null)
{
NotificationService.ShowError(validationError);
return false;
}
var source = Descriptor.GetSource();
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, newValue, source?.FilePath);
if (Ui != null)
await Ui.CommitFieldAsync(args);
}
_draftValue = null;
return true;
}
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
@@ -143,12 +164,19 @@ else
{
if (!IsActive) return;
_draftValue = value;
await CommitIfChangedAsync();
if (!await CommitIfChangedAsync() && Ui != null)
await Ui.ActivateFieldAsync(FieldKey);
}
private void HandleMultiValueChanged(IReadOnlyList<string> values)
{
_draftValue = values.ToList();
}
private async Task HandleConfirmEdit()
{
await CommitIfChangedAsync();
if (!await CommitIfChangedAsync())
return;
_exitAction = ExitAction.Confirm;
if (Ui != null)
await Ui.DeactivateFieldAsync();
@@ -171,5 +199,35 @@ else
await Ui.DeactivateFieldAsync();
}
private string? ValidateDraftValue(object? value)
{
if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal))
{
var values = AsStringList(value) ?? (value is string s ? new[] { s } : null);
if (values == null) return null;
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.";
}
}
return null;
}
private static IReadOnlyList<string>? AsStringList(object? value)
{
return value as IReadOnlyList<string>;
}
private static bool ValuesEqual(object? oldValue, object? newValue)
{
var oldList = AsStringList(oldValue);
var newList = AsStringList(newValue);
if (oldList != null || newList != null)
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
return Equals(oldValue, newValue);
}
private enum ExitAction { None, Confirm, Cancel, Revert }
}
@@ -4,7 +4,7 @@
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
@Value
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@(Source?.IsReadOnly != true && IsEditMode)" EditableAsButton="false" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value" IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="false" EditableAsButton="false" />
</span>
@code {
@@ -8,6 +8,10 @@
{
<span class="ec-field-value ec-field-value-multi" @onclick:stopPropagation="true">
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
}
else
@@ -27,6 +31,10 @@
{
<span class="text-muted">(none)</span>
}
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
</span>
}
</li>
@@ -41,6 +49,13 @@
[Parameter] public bool IsActiveEditor { get; set; }
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
[Parameter] public ConfigValueSource? Source { get; set; }
[Parameter] public bool ShowEditableBadge { get; set; }
[Parameter] public bool HasPendingChange { get; set; }
[Parameter] public string FieldKey { get; set; } = "";
[Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
private string _editLines = "";
@@ -2,13 +2,36 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IEffectiveConfigSaveService SaveService
@inject INotificationService NotificationService
@if (ShowModal)
{
<EffectiveConfigSaveModal PendingChanges="@PendingChanges" Status="@Status" Session="@Session" OnClose="@OnClose" OnSave="@HandleSave" OnReverted="@HandleReverted" />
<EffectiveConfigSaveModal
PendingChanges="@PendingChanges"
Status="@Status"
Session="@Session"
SaveState="@_saveState"
SaveResult="@_saveResult"
RestoreResult="@_restoreResult"
OnClose="@OnClose"
OnSave="@HandleSave"
OnRestore="@HandleRestore"
OnReverted="@HandleReverted" />
}
@code {
public enum SaveUiState
{
Review,
Saving,
WriteFailed,
ReloadFailed,
RestoreInProgress,
RestoreFailed
}
[Parameter] public bool ShowModal { get; set; }
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
@@ -16,6 +39,20 @@
[Parameter] public EventCallback OnSaveCompleted { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
private SaveUiState _saveState = SaveUiState.Review;
private EffectiveConfigSaveResult? _saveResult;
private EffectiveConfigRestoreResult? _restoreResult;
protected override void OnParametersSet()
{
if (!ShowModal)
{
_saveState = SaveUiState.Review;
_saveResult = null;
_restoreResult = null;
}
}
private async Task HandleReverted()
{
await InvokeAsync(StateHasChanged);
@@ -24,15 +61,60 @@
private async Task HandleSave()
{
if (Session.PendingChanges.Count == 0) return;
try
_saveState = SaveUiState.Saving;
_saveResult = null;
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await Session.ApplyAsync();
_saveResult = result;
if (result.Saved && result.Reloaded)
{
await Session.ApplyAsync();
var message = result.UserMessage ?? "Config saved and dnsmasq reloaded.";
NotificationService.ShowSuccess(message);
_saveState = SaveUiState.Review;
_saveResult = null;
await OnClose.InvokeAsync();
await OnSaveCompleted.InvokeAsync();
return;
}
catch (Exception)
if (result.Saved && !result.Reloaded)
_saveState = SaveUiState.ReloadFailed;
else if (!result.Saved)
_saveState = SaveUiState.WriteFailed;
else
_saveState = SaveUiState.Review;
await InvokeAsync(StateHasChanged);
}
private async Task HandleRestore(string backupPath)
{
if (string.IsNullOrEmpty(backupPath)) return;
_saveState = SaveUiState.RestoreInProgress;
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await SaveService.RestoreAsync(backupPath);
_restoreResult = result;
if (result.Restored && result.Reloaded)
{
// TODO: show error to user (e.g. toast or modal message)
var message = result.UserMessage ?? "Backup restored and dnsmasq reloaded.";
NotificationService.ShowSuccess(message);
_saveState = SaveUiState.Review;
_saveResult = null;
_restoreResult = null;
await OnClose.InvokeAsync();
await OnSaveCompleted.InvokeAsync();
return;
}
_saveState = SaveUiState.RestoreFailed;
await InvokeAsync(StateHasChanged);
}
}
@@ -8,73 +8,187 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="ec-save-modal-title">Save effective config changes</h5>
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
<button type="button" class="btn-close" @onclick="Close" aria-label="Close" disabled="@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)"></button>
</div>
<div class="modal-body ec-save-modal-body">
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don’t want to keep.</p>
@if (ManagedFilePath != null)
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
{
<p class="ec-save-modal-target mb-3">
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
</p>
}
@if (PendingChanges.Count == 0)
{
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
No changes to save. Revert all above or close to keep editing.
<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>
</div>
}
else
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
{
<div class="ec-save-modal-changes list-group list-group-flush">
@foreach (var c in PendingChanges)
<div class="alert alert-danger mb-0">
<strong>Save failed.</strong> @SaveResult.UserMessage
@if (!string.IsNullOrEmpty(SaveResult.ReloadStdErr))
{
var change = c;
<div class="list-group-item ec-save-modal-item d-flex align-items-center gap-3 py-3 px-0 border-0 border-bottom">
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
<span class="ec-save-modal-diff text-body">
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
<span class="text-muted mx-1" aria-hidden="true">→</span>
<span>@FormatValue(change.NewValue)</span>
</span>
@if (change.CurrentSourceFilePath != null)
{
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
}
</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Remove this change from the list" aria-label="Revert @change.OptionName">
Remove
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.ReloadStdErr</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>
@if (SaveResult.BackupPath != null)
{
<p class="ec-save-modal-backup-path mb-2">
<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>
}
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>
}
else
{
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
@if (ManagedFilePath != null)
{
<p class="ec-save-modal-target mb-3">
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
</p>
}
@if (PendingChanges.Count == 0)
{
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
No pending changes. Close to continue editing.
</div>
}
else
{
<div class="ec-save-modal-changes list-group list-group-flush">
@foreach (var c in PendingChanges)
{
var change = c;
var oldList = AsStringList(change.OldValue);
var newList = AsStringList(change.NewValue);
var isMultiValue = oldList != null || newList != null;
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
<div class="d-flex align-items-center gap-2">
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
@if (!isMultiValue)
{
<span class="ec-save-modal-diff text-body">
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
<span class="text-muted mx-1" aria-hidden="true">→</span>
<span>@FormatValue(change.NewValue)</span>
</span>
}
</div>
@if (isMultiValue)
{
var added = (newList ?? Array.Empty<string>()).Except(oldList ?? Array.Empty<string>()).ToList();
var removed = (oldList ?? Array.Empty<string>()).Except(newList ?? Array.Empty<string>()).ToList();
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
@foreach (var val in removed)
{
<div class="text-muted text-decoration-line-through">− @val</div>
}
@foreach (var val in added)
{
<div class="text-success">+ @val</div>
}
</div>
}
@if (change.CurrentSourceFilePath != null)
{
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
}
</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
</button>
</div>
}
</div>
}
}
</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" disabled="@(PendingChanges.Count == 0)">Save</button>
@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>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
{
<button type="button" class="btn btn-secondary" @onclick="Close">Continue editing</button>
}
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
{
<button type="button" class="btn btn-secondary" @onclick="Close">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>
}
</div>
</div>
</div>
<div class="modal-backdrop fade show" style="display: block;" @onclick="Close"></div>
<div class="modal-backdrop fade show" style="display: block;" @onclick="OnBackdropClick"></div>
</div>
@code {
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
[Parameter] public EffectiveConfigSaveResult? SaveResult { get; set; }
[Parameter] public EffectiveConfigRestoreResult? RestoreResult { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnSave { get; set; }
/// <summary>Invoked after reverting a change so the parent can re-render.</summary>
[Parameter] public EventCallback<string> OnRestore { get; set; }
[Parameter] public EventCallback OnReverted { get; set; }
private bool _stderrExpanded;
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
private string? ManagedFilePath => Status?.ManagedFilePath;
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
private static IReadOnlyList<string>? AsStringList(object? v)
{
if (v == null) return null;
if (v is IReadOnlyList<string> ro) return ro;
if (v is List<string> list) return list;
if (v is string[] arr) return arr;
return null;
}
private static string FormatValue(object? v)
{
if (v == null) return "(not set)";
if (v is bool b) return b ? "on" : "off";
var list = AsStringList(v);
if (list != null) return list.Count == 0 ? "(none)" : string.Join(", ", list);
return v.ToString() ?? "(not set)";
}
@@ -84,8 +198,15 @@
await OnReverted.InvokeAsync();
}
private async Task OnBackdropClick()
{
if (!AllowClose) return;
await Close();
}
private async Task Close()
{
if (!AllowClose) return;
await OnClose.InvokeAsync();
}
@@ -93,4 +214,9 @@
{
await OnSave.InvokeAsync();
}
private async Task Restore(string backupPath)
{
await OnRestore.InvokeAsync(backupPath);
}
}
@@ -41,3 +41,21 @@
.ec-save-modal-empty {
font-size: 0.9rem;
}
.ec-save-modal-loading {
min-height: 6rem;
}
.ec-save-modal-backup-path {
font-size: 0.9rem;
}
.ec-save-modal-backup-code {
word-break: break-all;
}
.ec-save-modal-pre {
font-size: 0.8125rem;
max-height: 12rem;
overflow: auto;
}
@@ -1,5 +1,6 @@
@inherits LayoutComponentBase
@namespace DnsmasqWebUI.Components.Layout
@using DnsmasqWebUI.Components.Shared
@inject ISettingsModalService SettingsModalService
@implements IDisposable
@@ -17,6 +18,7 @@
</div>
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
<NotificationHost />
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
@@ -54,14 +54,7 @@
else
{
<button type="button" class="nav-footer-link nav-footer-check-btn" @onclick="ForceCheckAsync" disabled="@UpdateCheckService.CheckInProgress" title="@GetCheckButtonTooltip()" aria-label="@GetCheckButtonTooltip()">
@if (UpdateCheckService.CheckInProgress)
{
<i class="bi bi-arrow-repeat nav-footer-spin" aria-hidden="true"></i>
}
else
{
<i class="bi bi-arrow-repeat" aria-hidden="true"></i>
}
<i class="bi bi-arrow-repeat nav-footer-check-icon @(UpdateCheckService.CheckInProgress ? "nav-footer-spin" : null)" aria-hidden="true"></i>
</button>
}
]</span>
@@ -190,6 +190,10 @@
color: rgba(255, 255, 255, 0.9) !important;
}
.nav-footer-check-icon {
display: inline-block; /* required for transform animation */
}
.nav-footer-spin {
animation: nav-footer-spin 0.8s linear infinite;
}
+12 -27
View File
@@ -6,8 +6,10 @@
@inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@inject ISettingsModalService SettingsModalService
@inject INotificationService NotificationService
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
<PageTitle>Dnsmasq | @AppTitle</PageTitle>
@@ -67,14 +69,6 @@ else if (_status != null)
}
@(_reloading ? "Reloading…" : "Reload config")
</button>
@if (!string.IsNullOrEmpty(_reloadMessage))
{
<span class="@(_reloadSuccess ? "text-success" : "text-danger") small">@_reloadMessage</span>
}
@if (!_reloadSuccess && _reloadDetail != null)
{
<pre class="pre-block mt-2 mb-0" style="max-height: 10rem;">@_reloadDetail</pre>
}
</div>
}
@@ -92,9 +86,6 @@ else if (_status != null)
private string? _error;
private bool _loading = true;
private bool _reloading;
private string? _reloadMessage;
private string? _reloadDetail;
private bool _reloadSuccess;
private readonly CancellationTokenSource _cts = new();
protected override void OnInitialized()
@@ -158,28 +149,30 @@ else if (_status != null)
await InvokeAsync(StateHasChanged);
}
catch (OperationCanceledException) { }
catch (ObjectDisposedException)
{
// Component disposed (e.g. navigated away) while callback was pending; ignore
}
}
private async Task ReloadDnsmasq()
{
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
_reloading = true;
_reloadMessage = null;
_reloadDetail = null;
try
{
var token = _cts.Token;
var result = await ReloadClient.ReloadAsync(token);
if (result.Success)
{
_reloadMessage = "Reloaded.";
_reloadSuccess = true;
NotificationService.ShowSuccess("Reloaded.");
}
else
{
_reloadMessage = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + ".";
_reloadDetail = BuildReloadDetail(result);
_reloadSuccess = false;
var msg = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + ".";
if (!string.IsNullOrEmpty(result.StdErr))
msg += " " + result.StdErr;
NotificationService.ShowError(msg);
}
try
{
@@ -193,7 +186,7 @@ else if (_status != null)
catch (OperationCanceledException) { }
catch (Exception ex)
{
if (_reloadMessage == null) { _reloadMessage = ex.Message; _reloadSuccess = false; }
NotificationService.ShowError("Reload failed: " + ex.Message);
}
finally
{
@@ -201,14 +194,6 @@ else if (_status != null)
}
}
private static string? BuildReloadDetail(ReloadResult r)
{
var parts = new List<string>();
if (!string.IsNullOrEmpty(r.StdOut)) parts.Add("stdout:\n" + r.StdOut);
if (!string.IsNullOrEmpty(r.StdErr)) parts.Add("stderr:\n" + r.StdErr);
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
}
public void Dispose()
{
SettingsModalService.SettingsChanged -= OnSettingsChanged;
@@ -0,0 +1,46 @@
@namespace DnsmasqWebUI.Components.Shared
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject INotificationService NotificationService
@implements IDisposable
@if (_toasts.Count > 0)
{
<div class="notification-host" aria-live="polite">
@foreach (var item in _toasts)
{
<NotificationToast @key="item.Id"
Id="@item.Id"
Kind="@item.Kind"
Message="@item.Message"
AutoDismissMs="4500"
OnDismissRequested="Dismiss" />
}
</div>
}
@code {
private List<NotificationItem> _toasts = new();
protected override void OnInitialized()
{
NotificationService.NotificationsChanged += OnNotificationsChanged;
_toasts = NotificationService.GetSnapshot().ToList();
}
private void OnNotificationsChanged(object? sender, EventArgs e)
{
_toasts = NotificationService.GetSnapshot().ToList();
InvokeAsync(StateHasChanged);
}
private async Task Dismiss(int id)
{
NotificationService.Dismiss(id);
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
NotificationService.NotificationsChanged -= OnNotificationsChanged;
}
}
@@ -0,0 +1,14 @@
.notification-host {
position: fixed;
top: var(--app-spacing-md, 1rem);
right: var(--app-spacing-md, 1rem);
z-index: 1100;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.notification-host > * {
pointer-events: auto;
}
@@ -0,0 +1,44 @@
@namespace DnsmasqWebUI.Components.Shared
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
<div class="notification-toast notification-toast-@Kind.ToString().ToLowerInvariant()" role="alert">
<span class="notification-toast-accent" aria-hidden="true"></span>
<span class="notification-toast-icon" aria-hidden="true">@Icon</span>
<span class="notification-toast-message">@Message</span>
<button type="button" class="notification-toast-dismiss" aria-label="Dismiss" @onclick="Dismiss">×</button>
</div>
@code {
[Parameter] public NotificationKind Kind { get; set; }
[Parameter] public string Message { get; set; } = "";
[Parameter] public int Id { get; set; }
[Parameter] public int AutoDismissMs { get; set; } = 4500;
[Parameter] public EventCallback<int> OnDismissRequested { get; set; }
private string Icon => Kind switch
{
NotificationKind.Success => "✓",
NotificationKind.Error => "✕",
NotificationKind.Warning => "⚠",
NotificationKind.Info => "ℹ",
_ => "•"
};
protected override void OnAfterRender(bool firstRender)
{
if (firstRender && AutoDismissMs > 0)
_ = ScheduleAutoDismiss();
}
private async Task ScheduleAutoDismiss()
{
try
{
await Task.Delay(AutoDismissMs);
await InvokeAsync(() => OnDismissRequested.InvokeAsync(Id));
}
catch (TaskCanceledException) { }
}
private async Task Dismiss() => await OnDismissRequested.InvokeAsync(Id);
}
@@ -0,0 +1,88 @@
/* Toast: compact popover with left accent, icon, message, dismiss. Modern, distinct. */
.notification-toast {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 16rem;
max-width: 28rem;
padding: 0.75rem 1rem;
padding-left: 0.5rem;
background: var(--notification-bg);
color: var(--notification-fg);
border-radius: var(--app-radius-lg, 0.5rem);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
font-size: 0.9375rem;
line-height: 1.4;
}
.notification-toast-accent {
width: 4px;
align-self: stretch;
border-radius: 2px;
background: var(--notification-accent);
flex-shrink: 0;
}
.notification-toast-icon {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.875rem;
}
.notification-toast-message {
flex: 1;
min-width: 0;
}
.notification-toast-dismiss {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
color: inherit;
opacity: 0.75;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
border-radius: var(--app-radius, 0.375rem);
}
.notification-toast-dismiss:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.08);
}
/* Success */
.notification-toast-success {
--notification-bg: #f0fdf4;
--notification-fg: #166534;
--notification-accent: #22c55e;
}
/* Error */
.notification-toast-error {
--notification-bg: #fef2f2;
--notification-fg: #b91c1c;
--notification-accent: #ef4444;
}
/* Warning */
.notification-toast-warning {
--notification-bg: #fffbeb;
--notification-fg: #b45309;
--notification-accent: #f59e0b;
}
/* Info */
.notification-toast-info {
--notification-bg: #eff6ff;
--notification-fg: #1d4ed8;
--notification-accent: #3b82f6;
}
@@ -84,7 +84,7 @@ public class StatusController : ControllerBase
LeasesPathConfigured: !string.IsNullOrEmpty(effectiveLeasesPath),
LeasesPathExists: !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath),
ConfigFiles: set.Files.Count > 0 ? set.Files : null,
ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.ReloadCommand),
ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.RestartCommand) || !string.IsNullOrWhiteSpace(_options.ReloadCommand),
StatusCommandConfigured: !string.IsNullOrWhiteSpace(_options.StatusCommand),
StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand),
LogsConfigured: !string.IsNullOrWhiteSpace(_options.LogsCommand),
@@ -4,6 +4,7 @@ using DnsmasqWebUI.Infrastructure.Client.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Extensions.DependencyInjection;
@@ -59,12 +60,38 @@ public static class ServiceCollectionExtensions
/// Scans the assembly for types implementing application marker interfaces
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationScopedConcrete"/>, <see cref="IApplicationSingleton"/>, <see cref="IApplicationHostedService"/>, etc.)
/// and registers each with the configured lifetime. Hosted services are registered via <c>AddHostedService&lt;T&gt;</c>.
/// Also registers all <see cref="IApplicationOptionsValidator{TOptions}"/> implementations as <see cref="IValidateOptions{TOptions}"/>.
/// Skips open generics; requires exactly one public implementation per interface (scoped/singleton).
/// </summary>
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
{
foreach (var (markerInterface, register) in ApplicationRegistrations)
ScanAndRegister(services, markerInterface, register);
AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly());
return services;
}
/// <summary>
/// Scans the given assembly for types implementing <see cref="IApplicationOptionsValidator{TOptions}"/> and registers each
/// as <see cref="IValidateOptions{TOptions}"/> singleton so the options framework picks them up.
/// </summary>
public static IServiceCollection AddOptionsValidatorsFromAssembly(this IServiceCollection services, Assembly assembly)
{
var markerDef = typeof(IApplicationOptionsValidator<>);
var validateOptionsDef = typeof(IValidateOptions<>);
var validators = assembly.GetTypes()
.Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition)
.Select(t => (Impl: t, MarkerInterface: t.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == markerDef)))
.Where(x => x.MarkerInterface != null)
.ToList();
foreach (var (impl, markerInterface) in validators)
{
// Register as IValidateOptions<TOptions> so options validation finds it
var optionsType = markerInterface!.GetGenericArguments()[0];
var validateOptionsType = validateOptionsDef.MakeGenericType(optionsType);
services.AddSingleton(validateOptionsType, impl);
}
return services;
}
@@ -148,6 +148,4 @@ public static class DnsmasqConfKeys
// --- Niche / platform (Linux conntrack mark for UBus/query filtering) ---
public const string Conntrack = "conntrack";
/// <summary>Keys collected for effective config "server/local" multi-value (order preserved).</summary>
public static readonly string[] ServerLocalKeys = { Server, Local };
}
@@ -8,13 +8,6 @@ namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
/// </summary>
public static class DnsmasqOptionTooltips
{
/// <summary>Display label used in EffectiveConfigFieldBuilder for server/local multi-value.</summary>
public const string ServerLocalLabel = "server / local";
/// <summary>Option name to option-help file key. "server / local" → "server"; others unchanged.</summary>
public static string GetOptionHelpKey(string optionName) =>
string.Equals(optionName, ServerLocalLabel, StringComparison.Ordinal) ? "server" : optionName;
private static readonly FrozenDictionary<string, string> Tooltips = new Dictionary<string, string>(StringComparer.Ordinal)
{
// --- Hosts ---
@@ -32,7 +25,8 @@ public static class DnsmasqOptionTooltips
[DnsmasqConfKeys.DomainNeeded] = "Never forward plain names (without a domain) to upstream.",
[DnsmasqConfKeys.Port] = "Listen on this port for DNS queries (default 53).",
[DnsmasqConfKeys.LogQueries] = "Log DNS queries. Optional value: extra, proto, or auth.",
[ServerLocalLabel] = "Upstream DNS servers (server=) and local-only domains (local=). Order is preserved.",
[DnsmasqConfKeys.Server] = "Upstream DNS servers. Order is preserved.",
[DnsmasqConfKeys.Local] = "Local-only domains (queries not forwarded). Order is preserved.",
[DnsmasqConfKeys.RevServer] = "Reverse DNS server (e.g. rev-server=1.2.3.0/24,192.168.0.1). Can repeat.",
[DnsmasqConfKeys.Address] = "Map a domain or hostname to an IP (e.g. for ad blocking or local names). Can repeat.",
[DnsmasqConfKeys.ResolvFile] = "File(s) to read upstream server addresses from (e.g. from DHCP). Can repeat.",
@@ -52,7 +52,8 @@ public static class EffectiveConfigFieldBuilder
list.AddDescriptor(registry, DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null);
// Resolver / DNS
list.AddDescriptor(registry, DnsmasqOptionTooltips.ServerLocalLabel, status, null, null, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues));
list.AddDescriptor(registry, DnsmasqConfKeys.Server, status, null, null, Items(ec => ec?.ServerValues, src => src?.ServerValues));
list.AddDescriptor(registry, DnsmasqConfKeys.Local, status, null, null, Items(ec => ec?.LocalValues, src => src?.LocalValues));
list.AddDescriptor(registry, DnsmasqConfKeys.RevServer, status, null, null, Items(ec => ec?.RevServerValues, src => src?.RevServerValues));
list.AddDescriptor(registry, DnsmasqConfKeys.Address, status, null, null, Items(ec => ec?.AddressValues, src => src?.AddressValues));
list.AddDescriptor(registry, DnsmasqConfKeys.ResolvFile, status, null, null, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles));
@@ -36,7 +36,8 @@ public static class EffectiveConfigOptionKindMap
[DnsmasqConfKeys.AddnHosts] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.Hostsdir] = EffectiveConfigFieldKind.Single,
[DnsmasqConfKeys.ReadEthers] = EffectiveConfigFieldKind.Single,
[DnsmasqOptionTooltips.ServerLocalLabel] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.Server] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.Local] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.RevServer] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.Address] = EffectiveConfigFieldKind.Multi,
[DnsmasqConfKeys.ResolvFile] = EffectiveConfigFieldKind.Multi,
@@ -160,7 +161,7 @@ public static class EffectiveConfigOptionKindMap
/// <summary>
/// Maps dnsmasq option names to parser behavior (LastWins / Flag / Multi). Used by ConfigSetCache so it
/// dispatches to GetLastValue, GetFlag, or GetMultiValue from one place. Special cases (NoHosts value,
/// AddnHosts, server/local keys array, DhcpLeaseFilePath) remain explicit in the cache.
/// AddnHosts, DhcpLeaseFilePath) remain explicit in the cache.
/// </summary>
public static class EffectiveConfigParserBehaviorMap
{
@@ -171,6 +172,8 @@ public static class EffectiveConfigParserBehaviorMap
[DnsmasqConfKeys.AddnHosts] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.Hostsdir] = EffectiveConfigParserBehavior.LastWins,
[DnsmasqConfKeys.ReadEthers] = EffectiveConfigParserBehavior.Flag,
[DnsmasqConfKeys.Server] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.Local] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.RevServer] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.Address] = EffectiveConfigParserBehavior.Multi,
[DnsmasqConfKeys.ResolvFile] = EffectiveConfigParserBehavior.Multi,
@@ -32,7 +32,8 @@ public static class EffectiveConfigSections
DnsmasqConfKeys.ReadEthers,
]),
new SectionDef(SectionResolver, "Resolver / DNS", [
DnsmasqOptionTooltips.ServerLocalLabel,
DnsmasqConfKeys.Server,
DnsmasqConfKeys.Local,
DnsmasqConfKeys.RevServer,
DnsmasqConfKeys.Address,
DnsmasqConfKeys.ResolvFile,
@@ -244,7 +244,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths, pathToLines);
var (hostsdirVal, hostsdirDir) = ((string?, string?))ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Hostsdir);
var hostsdirPath = string.IsNullOrWhiteSpace(hostsdirVal) ? null : DnsmasqConfIncludeParser.ResolvePath(hostsdirVal?.Trim(), hostsdirDir);
var serverLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys);
var serverValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Server);
var localValues = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, pathToLines, DnsmasqConfKeys.Local);
var revServer = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.RevServer);
var addressValues = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Address);
var interfaces = (IReadOnlyList<string>)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Interface);
@@ -395,7 +396,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
return new EffectiveDnsmasqConfig(
noHosts, addnHosts, hostsdirPath,
serverLocal, revServer, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines,
serverValues, localValues, revServer, addressValues, interfaces, listenAddresses, exceptInterfaces, dhcpRanges, dhcpHostLines, dhcpOptionLines,
dhcpMatchValues, dhcpBootValues, dhcpIgnoreValues, dhcpVendorclassValues, dhcpUserclassValues, raParamValues, slaacValues, pxeServiceValues, trustAnchorValues, resolvFiles,
rebindDomainOk, bogusNxdomain, ignoreAddress, alias, filterRr, cacheRr, authServer, noDhcpInterface, noDhcpv4Interface, noDhcpv6Interface,
domainValues, cnameValues, mxHostValues, srvValues, ptrRecordValues, txtRecordValues, naptrRecordValues, hostRecordValues, dynamicHostValues, interfaceNameValues,
@@ -417,7 +418,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
var (_, noHostsSource) = ((bool, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.NoHosts, managedFilePath);
var addnHostsWithSource = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(paths, pathToLines, managedFilePath, managedHostsFilePath);
var (_, hostsdirPathSource) = ((string?, ConfigValueSource?))ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Hostsdir, managedFilePath);
var serverLocalWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.ServerLocalKeys, managedFilePath);
var serverWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Server, managedFilePath);
var localWithSource = DnsmasqConfIncludeParser.GetMultiValueFromConfigFilesWithSource(paths, pathToLines, DnsmasqConfKeys.Local, managedFilePath);
var revServerWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.RevServer, managedFilePath);
var addressWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Address, managedFilePath);
var interfacesWithSource = (IReadOnlyList<(string Value, ConfigValueSource Source)>)ParseOptionWithSource(paths, pathToLines, DnsmasqConfKeys.Interface, managedFilePath);
@@ -537,7 +539,8 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
return new EffectiveConfigSources(
noHostsSource, addnHostsWithSource.Select(t => new PathWithSource(t.Path, t.Source)).ToList(), hostsdirPathSource,
serverLocalWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
serverWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
localWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
revServerWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
addressWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
interfacesWithSource.Select(t => new ValueWithSource(t.Value, t.Source)).ToList(),
@@ -690,7 +693,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
private static EffectiveDnsmasqConfig CreateDefaultEffectiveConfig() =>
new(
NoHosts: false, AddnHostsPaths: Array.Empty<string>(), HostsdirPath: null,
ServerLocalValues: Array.Empty<string>(), RevServerValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
ServerValues: Array.Empty<string>(), LocalValues: Array.Empty<string>(), RevServerValues: Array.Empty<string>(), AddressValues: Array.Empty<string>(), Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(), ExceptInterfaces: Array.Empty<string>(), DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(), DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(), DhcpBootValues: Array.Empty<string>(), DhcpIgnoreValues: Array.Empty<string>(),
@@ -721,7 +724,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable
private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() =>
new(
NoHosts: null, AddnHostsPaths: Array.Empty<PathWithSource>(), HostsdirPath: null,
ServerLocalValues: Array.Empty<ValueWithSource>(), RevServerValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
ServerValues: Array.Empty<ValueWithSource>(), LocalValues: Array.Empty<ValueWithSource>(), RevServerValues: Array.Empty<ValueWithSource>(), AddressValues: Array.Empty<ValueWithSource>(),
Interfaces: Array.Empty<ValueWithSource>(), ListenAddresses: Array.Empty<ValueWithSource>(),
ExceptInterfaces: Array.Empty<ValueWithSource>(), DhcpRanges: Array.Empty<ValueWithSource>(),
DhcpHostLines: Array.Empty<ValueWithSource>(),
@@ -194,11 +194,13 @@ public class DnsmasqConfigService : IDnsmasqConfigService
{
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(c.OptionName);
var isFlag = behavior == EffectiveConfigParserBehavior.Flag;
var confKey = c.OptionName;
bool MatchesOption(DnsmasqConfLine line)
{
if (line is not OtherLine o) return false;
var raw = o.RawLine.Trim();
return raw == c.OptionName || raw.StartsWith(c.OptionName + "=", StringComparison.Ordinal);
return raw == confKey || raw.StartsWith(confKey + "=", StringComparison.Ordinal);
}
if (behavior == EffectiveConfigParserBehavior.Multi && TryGetMultiValues(c.NewValue, out var values))
@@ -213,7 +215,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count;
for (var i = 0; i < values.Count; i++)
{
var lineText = string.IsNullOrEmpty(values[i]) ? c.OptionName : c.OptionName + "=" + values[i];
var lineKey = confKey;
var lineText = string.IsNullOrEmpty(values[i]) ? lineKey : lineKey + "=" + values[i];
var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText };
maxLineNumber++;
list.Insert(insertIdx + i, lineObj);
@@ -229,11 +232,11 @@ public class DnsmasqConfigService : IDnsmasqConfigService
}
string rawLine;
if (isFlag)
rawLine = c.OptionName;
rawLine = confKey;
else
{
var v = ToConfValue(c.NewValue);
rawLine = string.IsNullOrEmpty(v) ? c.OptionName : c.OptionName + "=" + v;
rawLine = string.IsNullOrEmpty(v) ? confKey : confKey + "=" + v;
}
var newLine = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = rawLine };
maxLineNumber++;
@@ -271,6 +274,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService
{
if (value == null) return "";
if (value is bool b) return b ? "1" : "0";
if (value is IReadOnlyList<string> list)
return string.Join(", ", list);
return value.ToString() ?? "";
}
@@ -22,9 +22,13 @@ public class ReloadService : IReloadService
public async Task<ReloadResult> ReloadAsync(CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(_options.ReloadCommand))
var command = !string.IsNullOrWhiteSpace(_options.RestartCommand)
? _options.RestartCommand
: _options.ReloadCommand;
if (string.IsNullOrWhiteSpace(command))
{
_logger.LogDebug("Reload command not configured");
_logger.LogDebug("Reload/restart command not configured");
return new ReloadResult(true, 0, null, "Reload command not configured");
}
@@ -36,7 +40,7 @@ public class ReloadService : IReloadService
try
{
var result = await _processRunner.RunAsync(_options.ReloadCommand, TimeSpan.FromSeconds(30), ct);
var result = await _processRunner.RunAsync(command, TimeSpan.FromSeconds(30), ct);
var stderr = result.Stderr;
if (result.TimedOut)
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + "Reload command timed out after 30 seconds.";
@@ -21,5 +21,5 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService
void TrackCommit(EffectiveConfigEditCommittedArgs args);
void RevertChange(string sectionId, string optionName);
Task ApplyAsync(CancellationToken ct = default);
Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default);
}
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
/// <summary>Orchestrates effective-config save: backup, write, reload; and restore from backup. Keeps IDnsmasqConfigService focused on read/write mechanics.</summary>
public interface IEffectiveConfigSaveService : IApplicationScopedService
{
/// <summary>Creates a timestamped backup (if managed file exists), applies changes, then runs reload. Returns structured result for UI.</summary>
Task<EffectiveConfigSaveResult> SaveAsync(
IReadOnlyList<PendingEffectiveConfigChange> changes,
CancellationToken ct = default);
/// <summary>Overwrites the managed config with the backup file, then runs reload. Returns structured result for UI.</summary>
Task<EffectiveConfigRestoreResult> RestoreAsync(string backupPath, CancellationToken ct = default);
}
@@ -1,4 +1,3 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
@@ -6,12 +5,12 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
{
private readonly IDnsmasqConfigService _configService;
private readonly IEffectiveConfigSaveService _saveService;
private readonly List<PendingEffectiveConfigChange> _pending = new();
public EffectiveConfigEditSession(IDnsmasqConfigService configService)
public EffectiveConfigEditSession(IEffectiveConfigSaveService saveService)
{
_configService = configService;
_saveService = saveService;
}
public bool IsEditMode { get; private set; }
@@ -49,7 +48,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
c.SectionId == args.SectionId && c.OptionName == args.OptionName);
if (existing != null)
_pending.Remove(existing);
if (!Equals(args.OldValue, args.NewValue))
if (!ValuesEqual(args.OldValue, args.NewValue))
_pending.Add(new PendingEffectiveConfigChange(
args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath));
ActiveFieldKey = null;
@@ -63,11 +62,28 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession
_pending.Remove(existing);
}
public async Task ApplyAsync(CancellationToken ct = default)
public async Task<EffectiveConfigSaveResult> ApplyAsync(CancellationToken ct = default)
{
if (_pending.Count == 0) return;
var changes = _pending.ToList();
await _configService.ApplyEffectiveConfigChangesAsync(changes, ct);
ExitEditModeDiscard();
if (_pending.Count == 0)
return EffectiveConfigSaveResult.NoChanges();
var result = await _saveService.SaveAsync(_pending.ToList(), ct);
if (result.Saved && result.Reloaded)
ExitEditModeDiscard();
return result;
}
private static IReadOnlyList<string>? AsStringList(object? value)
{
return value as IReadOnlyList<string>;
}
private static bool ValuesEqual(object? oldValue, object? newValue)
{
var oldList = AsStringList(oldValue);
var newList = AsStringList(newValue);
if (oldList != null || newList != null)
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
return Equals(oldValue, newValue);
}
}
@@ -0,0 +1,138 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService
{
private readonly IDnsmasqConfigSetService _configSetService;
private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService;
private readonly ILogger<EffectiveConfigSaveService> _logger;
public EffectiveConfigSaveService(
IDnsmasqConfigSetService configSetService,
IDnsmasqConfigService configService,
IReloadService reloadService,
ILogger<EffectiveConfigSaveService> logger)
{
_configSetService = configSetService;
_configService = configService;
_reloadService = reloadService;
_logger = logger;
}
/// <inheritdoc />
public async Task<EffectiveConfigSaveResult> SaveAsync(
IReadOnlyList<PendingEffectiveConfigChange> changes,
CancellationToken ct = default)
{
if (changes.Count == 0)
return EffectiveConfigSaveResult.NoChanges();
var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
{
_logger.LogWarning("Save skipped: managed config path is not configured");
return new EffectiveConfigSaveResult(
false, null, false, false, -1, null, null,
"missing_managed_path", "Managed config path is not configured.");
}
var managedPath = set.ManagedFilePath!;
var backupPath = BuildBackupPath(managedPath);
try
{
CreateBackupIfSourceExists(managedPath, backupPath);
await _configService.ApplyEffectiveConfigChangesAsync(changes, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to write effective config");
var backupExists = File.Exists(backupPath);
return new EffectiveConfigSaveResult(
backupExists,
backupExists ? backupPath : null,
false,
false,
-1,
null,
ex.Message,
"write_failed",
"Failed to write config.");
}
var reload = 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);
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.");
}
/// <inheritdoc />
public async Task<EffectiveConfigRestoreResult> RestoreAsync(string backupPath, CancellationToken ct = default)
{
var set = await _configSetService.GetConfigSetAsync(ct);
if (string.IsNullOrWhiteSpace(set.ManagedFilePath))
{
_logger.LogWarning("Restore skipped: managed config path is not configured");
return new EffectiveConfigRestoreResult(
false, false, -1, null, "Managed config path is not configured.");
}
if (!File.Exists(backupPath))
{
_logger.LogWarning("Restore skipped: backup file not found: {Path}", backupPath);
return new EffectiveConfigRestoreResult(
false, false, -1, null, "Backup file not found.");
}
var managedPath = set.ManagedFilePath!;
File.Copy(backupPath, managedPath, overwrite: true);
_logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath);
var reload = await _reloadService.ReloadAsync(ct);
if (!reload.Success)
_logger.LogWarning("Restore completed but reload failed: exit {ExitCode}, stderr: {Stderr}", reload.ExitCode, reload.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.");
}
private static string BuildBackupPath(string managedPath) =>
$"{managedPath}.bak.{DateTime.UtcNow:yyyyMMdd-HHmmss}";
private static void CreateBackupIfSourceExists(string managedPath, string backupPath)
{
if (!File.Exists(managedPath))
return;
var dir = Path.GetDirectoryName(backupPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
File.Copy(managedPath, backupPath, overwrite: false);
}
}
@@ -38,7 +38,7 @@ public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHoste
_logger.LogInformation(
"Application started: version={Version}, env={Environment}, os={OS}, arch={Arch}. " +
"MainConfig={MainConfig}, managedConfig={ManagedConfig}, managedHosts={ManagedHosts}, systemHosts={SystemHosts}. " +
"ReloadCommand={ReloadCmd}, StatusCommand={StatusCmd}",
"RestartCommand={RestartCmd}, ReloadCommand={ReloadCmd}, StatusCommand={StatusCmd}",
version,
_env.EnvironmentName,
RuntimeInformation.OSDescription.Trim(),
@@ -47,6 +47,7 @@ public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHoste
managedConfigPath,
managedHostsPath,
_dnsmasq.SystemHostsPath ?? "(not set)",
string.IsNullOrWhiteSpace(_dnsmasq.RestartCommand) ? "(not set)" : _dnsmasq.RestartCommand,
string.IsNullOrWhiteSpace(_dnsmasq.ReloadCommand) ? "(not set)" : _dnsmasq.ReloadCommand,
string.IsNullOrWhiteSpace(_dnsmasq.StatusCommand) ? "(not set)" : _dnsmasq.StatusCommand);
@@ -0,0 +1,11 @@
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
/// <summary>
/// Marker interface for options validators that are registered via assembly scanning.
/// Implement this (instead of <see cref="IValidateOptions{TOptions}"/> directly) so only intended validators are registered.
/// </summary>
public interface IApplicationOptionsValidator<TOptions> : IValidateOptions<TOptions> where TOptions : class
{
}
@@ -0,0 +1,43 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions;
/// <summary>Scoped service to show toast notifications (success, error, warning, info). NotificationHost in MainLayout subscribes and renders toasts. Registered via assembly scanning (<see cref="IApplicationScopedService"/>).</summary>
public interface INotificationService : IApplicationScopedService
{
/// <summary>Raised when the list of toasts changes. NotificationHost subscribes and calls StateHasChanged.</summary>
event EventHandler? NotificationsChanged;
/// <summary>Shows a success toast (e.g. "Config saved and dnsmasq reloaded."). Auto-dismisses after default duration.</summary>
void ShowSuccess(string message);
/// <summary>Shows an error toast.</summary>
void ShowError(string message);
/// <summary>Shows a warning toast.</summary>
void ShowWarning(string message);
/// <summary>Shows an info toast.</summary>
void ShowInfo(string message);
/// <summary>Returns a snapshot of current toasts for rendering. Caller should re-render when <see cref="NotificationsChanged"/> fires.</summary>
IReadOnlyList<NotificationItem> GetSnapshot();
/// <summary>Removes the toast with the given id. Raises <see cref="NotificationsChanged"/>.</summary>
void Dismiss(int id);
}
/// <summary>Kind of notification for styling and icon.</summary>
public enum NotificationKind
{
Success,
Error,
Warning,
Info
}
/// <summary>Single toast item.</summary>
/// <param name="Id">Unique id for dismiss.</param>
/// <param name="Kind">Success, Error, Warning, Info.</param>
/// <param name="Message">Text to show.</param>
public record NotificationItem(int Id, NotificationKind Kind, string Message);
@@ -0,0 +1,42 @@
using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services.UI.Notifications;
/// <inheritdoc cref="INotificationService" />
public sealed class NotificationService : INotificationService
{
private readonly List<NotificationItem> _items = new();
private int _nextId = 1;
public event EventHandler? NotificationsChanged;
public void ShowSuccess(string message) => Add(NotificationKind.Success, message);
public void ShowError(string message) => Add(NotificationKind.Error, message);
public void ShowWarning(string message) => Add(NotificationKind.Warning, message);
public void ShowInfo(string message) => Add(NotificationKind.Info, message);
public IReadOnlyList<NotificationItem> GetSnapshot()
{
lock (_items)
return _items.ToList();
}
public void Dismiss(int id)
{
lock (_items)
{
var removed = _items.RemoveAll(i => i.Id == id);
if (removed > 0)
NotificationsChanged?.Invoke(this, EventArgs.Empty);
}
}
private void Add(NotificationKind kind, string message)
{
lock (_items)
{
_items.Add(new NotificationItem(_nextId++, kind, message ?? ""));
NotificationsChanged?.Invoke(this, EventArgs.Empty);
}
}
}
@@ -1,4 +1,5 @@
using DnsmasqWebUI.Models.Client;
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
@@ -6,8 +7,9 @@ namespace DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
/// Scoped service to open the app-level settings modal from anywhere (e.g. NavMenu, pages).
/// MainLayout subscribes to <see cref="OpenRequested"/> and hosts the modal.
/// Subscribe to <see cref="SettingsChanged"/> to refresh when settings are saved/closed.
/// Registered via assembly scanning (<see cref="IApplicationScopedService"/>).
/// </summary>
public interface ISettingsModalService
public interface ISettingsModalService : IApplicationScopedService
{
/// <summary>Raised when <see cref="Open"/> is called. MainLayout subscribes and shows the modal.</summary>
event Action<SettingsModalContext, string>? OpenRequested;
@@ -1,10 +1,13 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
namespace DnsmasqWebUI.Infrastructure.Services.Updates.Abstractions;
/// <summary>
/// Service that checks GitHub for a newer release. Runs on a configurable interval (hosted service)
/// and can be triggered manually. UI subscribes to <see cref="ResultChanged"/> to refresh when state updates.
/// Registered via assembly scanning (<see cref="IApplicationSingleton"/>).
/// </summary>
public interface IUpdateCheckService
public interface IUpdateCheckService : IApplicationSingleton
{
/// <summary>Raised when a check completes (background or manual). Subscribe and call StateHasChanged from the UI.</summary>
event EventHandler? ResultChanged;
@@ -29,7 +29,10 @@ public class DnsmasqOptions
/// <summary>Optional path to the system hosts file (e.g. /etc/hosts). When set, shown in the UI as read-only so users can see those entries. The app never writes to it; editing is via the managed hosts file only. When unset, the system hosts row is not shown.</summary>
public string? SystemHostsPath { get; set; }
/// <summary>Command to run after config changes (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). Runs in the same environment as the app; if app is in a container and dnsmasq is on the host, this runs in the container and will not reload host dnsmasq unless you use a host-side relay.</summary>
/// <summary>Command to run to apply config changes (e.g. "systemctl restart dnsmasq"). When set, this is used instead of <see cref="ReloadCommand"/> so that .conf file changes take effect (dnsmasq does not re-read config on SIGHUP). If unset, <see cref="ReloadCommand"/> is used.</summary>
public string? RestartCommand { get; set; }
/// <summary>Command to run after config changes when <see cref="RestartCommand"/> is not set (e.g. "systemctl reload dnsmasq" or "pkill -HUP -x dnsmasq"). SIGHUP only re-reads hosts/addn-hosts etc., not .conf files. Runs in the same environment as the app.</summary>
public string? ReloadCommand { get; set; }
/// <summary>Optional command to check dnsmasq service state (e.g. "systemctl is-active dnsmasq" or "pgrep -x dnsmasq"). Runs in the same environment as the app; if app is in a container, this checks for dnsmasq in the container, not on the host.</summary>
@@ -1,3 +1,4 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Models.Config;
@@ -5,8 +6,9 @@ namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Validates required dnsmasq options at startup. If config is missing or default paths don't point at existing files,
/// the application exits with a detailed error instead of failing later at runtime.
/// Registered via assembly scanning (<see cref="IApplicationOptionsValidator{TOptions}"/>).
/// </summary>
public sealed class DnsmasqOptionsValidator : IValidateOptions<DnsmasqOptions>
public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator<DnsmasqOptions>
{
public ValidateOptionsResult Validate(string? name, DnsmasqOptions options)
{
@@ -10,7 +10,7 @@ public sealed class ConfigOptionHelpRequestEventArgs
/// <summary>Option-help file key (e.g. "server", "no-hosts").</summary>
public string HelpKey { get; init; } = "";
/// <summary>Display label for the modal title (e.g. "server / local").</summary>
/// <summary>Display label for the modal title (e.g. "server" or "listen-address").</summary>
public string OptionLabel { get; init; } = "";
/// <summary>Id of the label element that triggered the request; used to position the modal and match leave events.</summary>
@@ -0,0 +1,14 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>Result of restoring the managed config from a backup file, then reloading dnsmasq.</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="UserMessage">Short message for the user.</param>
public record EffectiveConfigRestoreResult(
bool Restored,
bool Reloaded,
int ReloadExitCode,
string? ReloadStdErr,
string? UserMessage);
@@ -0,0 +1,27 @@
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>
/// <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="UserMessage">Short message for the user.</param>
public record EffectiveConfigSaveResult(
bool BackupCreated,
string? BackupPath,
bool Saved,
bool Reloaded,
int ReloadExitCode,
string? ReloadStdOut,
string? ReloadStdErr,
string? ErrorCode,
string? UserMessage)
{
/// <summary>Result when there are no pending changes to apply.</summary>
public static EffectiveConfigSaveResult NoChanges() =>
new(false, null, false, false, -1, null, null, "no_changes", "No pending changes.");
}
@@ -16,7 +16,8 @@ public record EffectiveConfigSources(
ConfigValueSource? HostsdirPath,
// --- Multi-value (ARG_DUP): source per value ---
IReadOnlyList<ValueWithSource> ServerLocalValues,
IReadOnlyList<ValueWithSource> ServerValues,
IReadOnlyList<ValueWithSource> LocalValues,
IReadOnlyList<ValueWithSource> RevServerValues,
IReadOnlyList<ValueWithSource> AddressValues,
IReadOnlyList<ValueWithSource> Interfaces,
@@ -10,7 +10,7 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
///
/// Single-value (ARG_ONE): last occurrence wins; can be overridden by writing to the managed file.
/// Flags: set if any file contains the option (key-only line).
/// Multi-value (ARG_DUP): addn-hosts, server/local, address, interface, listen-address, except-interface,
/// Multi-value (ARG_DUP): addn-hosts, server, local, address, interface, listen-address, except-interface,
/// dhcp-range, dhcp-host, dhcp-option, resolv-file — all values in order; each has source in EffectiveConfigSources.
/// </summary>
public record EffectiveDnsmasqConfig(
@@ -20,7 +20,8 @@ public record EffectiveDnsmasqConfig(
string? HostsdirPath,
// --- Multi-value (ARG_DUP): all occurrences in order ---
IReadOnlyList<string> ServerLocalValues,
IReadOnlyList<string> ServerValues,
IReadOnlyList<string> LocalValues,
IReadOnlyList<string> RevServerValues,
IReadOnlyList<string> AddressValues,
IReadOnlyList<string> Interfaces,
+1 -8
View File
@@ -4,10 +4,6 @@ using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Extensions.DependencyInjection;
using DnsmasqWebUI.Extensions.Hosting;
using DnsmasqWebUI.Infrastructure.Helpers.Http;
using DnsmasqWebUI.Infrastructure.Services.UI.Settings;
using DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Updates;
using DnsmasqWebUI.Infrastructure.Services.Updates.Abstractions;
using Microsoft.Extensions.Options;
// When not in Development, use the app's directory (not CWD) so static assets work when run via symlink or from any CWD.
@@ -36,12 +32,9 @@ builder.Services.AddOptions<UpdateCheckOptions>()
builder.Services.AddOptions<DnsmasqOptions>()
.Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
.ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsValidator>();
// ---- Application services ----
// ---- Application services (assembly-scanned: IApplicationScopedService, IApplicationSingleton, IValidateOptions<>, etc.) ----
builder.Services.AddApplicationServices();
builder.Services.AddScoped<ISettingsModalService, SettingsModalService>();
builder.Services.AddSingleton<IUpdateCheckService, UpdateCheckService>();
builder.Services.AddHttpContextAccessor();
builder.Services.AddDnsmasqApiHttpClients();
+1
View File
@@ -44,6 +44,7 @@
"ManagedFileName": "zz-dnsmasq-webui.conf",
"ManagedHostsFileName": "zz-dnsmasq-webui.hosts",
"SystemHostsPath": "/etc/hosts",
"RestartCommand": "systemctl restart dnsmasq",
"ReloadCommand": "systemctl reload dnsmasq",
"StatusCommand": "systemctl is-active dnsmasq",
"StatusShowCommand": "systemctl status dnsmasq --no-pager",
+7
View File
@@ -0,0 +1,7 @@
# Minimal dnsmasq config for the test harness (use with --minimal-conf).
# Single file, no conf-dir; app adds conf-file= for managed config on startup.
# Use for testing reload/restart failure flows (fewer readonly options to edit).
port=53
log-facility=/data/dnsmasq.log
log-queries