From a776f445b6f540b11f0f5de5b6532ce8c432cedd Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sun, 1 Mar 2026 19:21:09 +1000 Subject: [PATCH] Assembly-scanned services, options validator marker, UI tweaks, and config fixes - Register ISettingsModalService, INotificationService, IUpdateCheckService via assembly scanning (marker interfaces) - Add IApplicationOptionsValidator 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 --- docker-compose.test.yml | 9 +- scripts/extract-option-help.sh | 2 +- scripts/prepare-test-mount.sh | 21 +- ...masqConfIncludeParserHostsAndLeaseTests.cs | 2 +- ...sqConfIncludeParserOfficialExampleTests.cs | 4 +- ...sqConfIncludeParserServerAndResolvTests.cs | 12 +- .../DnsmasqConfigSetServiceTests.cs | 6 +- .../EffectiveDnsmasqConfigTests.cs | 3 +- .../Fields/EffectiveConfigFieldDisplay.razor | 76 ++++++- .../EffectiveConfigMultiValueItem.razor | 2 +- .../Fields/EffectiveConfigMultiValueRow.razor | 15 ++ .../Modals/EffectiveConfigSaveFlow.razor | 92 ++++++++- .../Modals/EffectiveConfigSaveModal.razor | 194 +++++++++++++++--- .../Modals/EffectiveConfigSaveModal.razor.css | 18 ++ .../Components/Layout/MainLayout.razor | 2 + .../Components/Layout/NavMenu.razor | 9 +- .../Components/Layout/NavMenu.razor.css | 4 + .../Components/Pages/Dnsmasq.razor | 39 ++-- .../Components/Shared/NotificationHost.razor | 46 +++++ .../Shared/NotificationHost.razor.css | 14 ++ .../Components/Shared/NotificationToast.razor | 44 ++++ .../Shared/NotificationToast.razor.css | 88 ++++++++ .../Controllers/StatusController.cs | 2 +- .../ServiceCollectionExtensions.cs | 27 +++ .../Helpers/Config/DnsmasqConfKeys.cs | 2 - .../Helpers/Config/DnsmasqOptionTooltips.cs | 10 +- .../Config/EffectiveConfigFieldBuilder.cs | 3 +- .../Config/EffectiveConfigOptionKindMap.cs | 7 +- .../Helpers/Config/EffectiveConfigSections.cs | 3 +- .../Services/Dnsmasq/Config/ConfigSetCache.cs | 15 +- .../Dnsmasq/Config/DnsmasqConfigService.cs | 13 +- .../Services/Dnsmasq/Reload/ReloadService.cs | 10 +- .../IEffectiveConfigEditSession.cs | 2 +- .../IEffectiveConfigSaveService.cs | 16 ++ .../EffectiveConfigEditSession.cs | 36 +++- .../EffectiveConfigSaveService.cs | 138 +++++++++++++ ...pplicationLifecycleLoggingHostedService.cs | 3 +- .../IApplicationOptionsValidator.cs | 11 + .../Abstractions/INotificationService.cs | 43 ++++ .../UI/Notifications/NotificationService.cs | 42 ++++ .../Abstractions/ISettingsModalService.cs | 4 +- .../Abstractions/IUpdateCheckService.cs | 5 +- .../Models/Config/DnsmasqOptions.cs | 5 +- .../Models/Config/DnsmasqOptionsValidator.cs | 4 +- .../ConfigOptionHelpRequestEventArgs.cs | 2 +- .../EffectiveConfigRestoreResult.cs | 14 ++ .../EffectiveConfigSaveResult.cs | 27 +++ .../EffectiveConfig/EffectiveConfigSources.cs | 3 +- .../EffectiveConfig/EffectiveDnsmasqConfig.cs | 5 +- src/DnsmasqWebUI/Program.cs | 9 +- src/DnsmasqWebUI/appsettings.json | 1 + testdata/dnsmasq-test-minimal.conf | 7 + 52 files changed, 1016 insertions(+), 155 deletions(-) create mode 100644 src/DnsmasqWebUI/Components/Shared/NotificationHost.razor create mode 100644 src/DnsmasqWebUI/Components/Shared/NotificationHost.razor.css create mode 100644 src/DnsmasqWebUI/Components/Shared/NotificationToast.razor create mode 100644 src/DnsmasqWebUI/Components/Shared/NotificationToast.razor.css create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Registration/Abstractions/IApplicationOptionsValidator.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/Abstractions/INotificationService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/NotificationService.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs create mode 100644 testdata/dnsmasq-test-minimal.conf diff --git a/docker-compose.test.yml b/docker-compose.test.yml index b96a220..0ae6cff 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -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) diff --git a/scripts/extract-option-help.sh b/scripts/extract-option-help.sh index 8b42883..0213b68 100755 --- a/scripts/extract-option-help.sh +++ b/scripts/extract-option-help.sh @@ -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", diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index e20f915..258eeec 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -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" diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserHostsAndLeaseTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserHostsAndLeaseTests.cs index 823ef22..d7a1d84 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserHostsAndLeaseTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserHostsAndLeaseTests.cs @@ -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); diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserOfficialExampleTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserOfficialExampleTests.cs index b2edac3..aea45a3 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserOfficialExampleTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserOfficialExampleTests.cs @@ -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); diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserServerAndResolvTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserServerAndResolvTests.cs index 268d358..0badecf 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserServerAndResolvTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserServerAndResolvTests.cs @@ -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 { diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs index 92e53f8..688ae72 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs @@ -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); diff --git a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs index 4f280ae..86838d8 100644 --- a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs +++ b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs @@ -12,7 +12,8 @@ public class EffectiveDnsmasqConfigTests NoHosts: false, AddnHostsPaths: Array.Empty(), HostsdirPath: null, - ServerLocalValues: Array.Empty(), + ServerValues: Array.Empty(), + LocalValues: Array.Empty(), RevServerValues: Array.Empty(), AddressValues: Array.Empty(), Interfaces: Array.Empty(), diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index d2169be..9a85c5c 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -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(); - + var items = _hasPendingChange && _effectiveValue is IReadOnlyList 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); + } else { @@ -93,14 +103,17 @@ else _customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName); _fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create(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()) + : 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 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 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? AsStringList(object? value) + { + return value as IReadOnlyList; + } + + 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()).SequenceEqual(newList ?? Array.Empty(), StringComparer.Ordinal); + return Equals(oldValue, newValue); + } + private enum ExitAction { None, Confirm, Cancel, Revert } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor index bc0bc2b..95e04a5 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor @@ -4,7 +4,7 @@ @Value - + @code { diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor index 0a34dc4..095d387 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor @@ -8,6 +8,10 @@ { + } else @@ -27,6 +31,10 @@ { (none) } + } @@ -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 = ""; diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor index ca64464..c4598f9 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor @@ -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) { - + } @code { + public enum SaveUiState + { + Review, + Saving, + WriteFailed, + ReloadFailed, + RestoreInProgress, + RestoreFailed + } + [Parameter] public bool ShowModal { get; set; } [Parameter] public IReadOnlyList 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); } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor index ce49040..a7d14df 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor @@ -8,73 +8,187 @@ +
An unhandled error has occurred. diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index 56ada22..5e42e66 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -54,14 +54,7 @@ else { } ] diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css index fc38f4b..11b3e18 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css @@ -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; } diff --git a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor index 5f2f4b1..7880409 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor @@ -6,8 +6,10 @@ @inject IReloadClient ReloadClient @inject IClientSettingsService ClientSettingsService @inject ISettingsModalService SettingsModalService +@inject INotificationService NotificationService @inject IOptions AppOptions @implements IDisposable +@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions Dnsmasq | @AppTitle @@ -67,14 +69,6 @@ else if (_status != null) } @(_reloading ? "Reloading…" : "Reload config") - @if (!string.IsNullOrEmpty(_reloadMessage)) - { - @_reloadMessage - } - @if (!_reloadSuccess && _reloadDetail != null) - { -
@_reloadDetail
- }
} @@ -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(); - 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; diff --git a/src/DnsmasqWebUI/Components/Shared/NotificationHost.razor b/src/DnsmasqWebUI/Components/Shared/NotificationHost.razor new file mode 100644 index 0000000..0b10442 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Shared/NotificationHost.razor @@ -0,0 +1,46 @@ +@namespace DnsmasqWebUI.Components.Shared +@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions +@inject INotificationService NotificationService +@implements IDisposable + +@if (_toasts.Count > 0) +{ +
+ @foreach (var item in _toasts) + { + + } +
+} + +@code { + private List _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; + } +} diff --git a/src/DnsmasqWebUI/Components/Shared/NotificationHost.razor.css b/src/DnsmasqWebUI/Components/Shared/NotificationHost.razor.css new file mode 100644 index 0000000..953b5f9 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Shared/NotificationHost.razor.css @@ -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; +} diff --git a/src/DnsmasqWebUI/Components/Shared/NotificationToast.razor b/src/DnsmasqWebUI/Components/Shared/NotificationToast.razor new file mode 100644 index 0000000..91dda67 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Shared/NotificationToast.razor @@ -0,0 +1,44 @@ +@namespace DnsmasqWebUI.Components.Shared +@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions + + + +@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 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); +} diff --git a/src/DnsmasqWebUI/Components/Shared/NotificationToast.razor.css b/src/DnsmasqWebUI/Components/Shared/NotificationToast.razor.css new file mode 100644 index 0000000..29f21b2 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Shared/NotificationToast.razor.css @@ -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; +} diff --git a/src/DnsmasqWebUI/Controllers/StatusController.cs b/src/DnsmasqWebUI/Controllers/StatusController.cs index 45b467b..ef465f2 100644 --- a/src/DnsmasqWebUI/Controllers/StatusController.cs +++ b/src/DnsmasqWebUI/Controllers/StatusController.cs @@ -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), diff --git a/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs b/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs index 02b937e..66bf379 100644 --- a/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/DnsmasqWebUI/Extensions/DependencyInjection/ServiceCollectionExtensions.cs @@ -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 /// (, , , , etc.) /// and registers each with the configured lifetime. Hosted services are registered via AddHostedService<T>. + /// Also registers all implementations as . /// Skips open generics; requires exactly one public implementation per interface (scoped/singleton). /// public static IServiceCollection AddApplicationServices(this IServiceCollection services) { foreach (var (markerInterface, register) in ApplicationRegistrations) ScanAndRegister(services, markerInterface, register); + AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly()); + return services; + } + + /// + /// Scans the given assembly for types implementing and registers each + /// as singleton so the options framework picks them up. + /// + 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 so options validation finds it + var optionsType = markerInterface!.GetGenericArguments()[0]; + var validateOptionsType = validateOptionsDef.MakeGenericType(optionsType); + services.AddSingleton(validateOptionsType, impl); + } return services; } diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqConfKeys.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqConfKeys.cs index a5a0ebf..170c8b8 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqConfKeys.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqConfKeys.cs @@ -148,6 +148,4 @@ public static class DnsmasqConfKeys // --- Niche / platform (Linux conntrack mark for UBus/query filtering) --- public const string Conntrack = "conntrack"; - /// Keys collected for effective config "server/local" multi-value (order preserved). - public static readonly string[] ServerLocalKeys = { Server, Local }; } diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs index 55a93f5..8ccef15 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/DnsmasqOptionTooltips.cs @@ -8,13 +8,6 @@ namespace DnsmasqWebUI.Infrastructure.Helpers.Config; /// public static class DnsmasqOptionTooltips { - /// Display label used in EffectiveConfigFieldBuilder for server/local multi-value. - public const string ServerLocalLabel = "server / local"; - - /// Option name to option-help file key. "server / local" → "server"; others unchanged. - public static string GetOptionHelpKey(string optionName) => - string.Equals(optionName, ServerLocalLabel, StringComparison.Ordinal) ? "server" : optionName; - private static readonly FrozenDictionary Tooltips = new Dictionary(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.", diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs index 74c658b..22e0d08 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigFieldBuilder.cs @@ -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)); diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs index 8b2a996..113cc2a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigOptionKindMap.cs @@ -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 /// /// 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. /// 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, diff --git a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs index ad09dcc..8aa2b79 100644 --- a/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs +++ b/src/DnsmasqWebUI/Infrastructure/Helpers/Config/EffectiveConfigSections.cs @@ -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, diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs index 6465f66..5f7644a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/ConfigSetCache.cs @@ -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)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.RevServer); var addressValues = (IReadOnlyList)ParseOptionValue(paths, pathToLines, DnsmasqConfKeys.Address); var interfaces = (IReadOnlyList)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(), HostsdirPath: null, - ServerLocalValues: Array.Empty(), RevServerValues: Array.Empty(), AddressValues: Array.Empty(), Interfaces: Array.Empty(), + ServerValues: Array.Empty(), LocalValues: Array.Empty(), RevServerValues: Array.Empty(), AddressValues: Array.Empty(), Interfaces: Array.Empty(), ListenAddresses: Array.Empty(), ExceptInterfaces: Array.Empty(), DhcpRanges: Array.Empty(), DhcpHostLines: Array.Empty(), DhcpOptionLines: Array.Empty(), DhcpMatchValues: Array.Empty(), DhcpBootValues: Array.Empty(), DhcpIgnoreValues: Array.Empty(), @@ -721,7 +724,7 @@ public sealed class ConfigSetCache : IConfigSetCache, IDisposable private static EffectiveConfigSources CreateDefaultEffectiveConfigSources() => new( NoHosts: null, AddnHostsPaths: Array.Empty(), HostsdirPath: null, - ServerLocalValues: Array.Empty(), RevServerValues: Array.Empty(), AddressValues: Array.Empty(), + ServerValues: Array.Empty(), LocalValues: Array.Empty(), RevServerValues: Array.Empty(), AddressValues: Array.Empty(), Interfaces: Array.Empty(), ListenAddresses: Array.Empty(), ExceptInterfaces: Array.Empty(), DhcpRanges: Array.Empty(), DhcpHostLines: Array.Empty(), diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs index 66a911c..e906367 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs @@ -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 list) + return string.Join(", ", list); return value.ToString() ?? ""; } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs index 85b1957..b0cc30c 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Reload/ReloadService.cs @@ -22,9 +22,13 @@ public class ReloadService : IReloadService public async Task 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."; diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs index 6f6154e..4c2f13f 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs @@ -21,5 +21,5 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService void TrackCommit(EffectiveConfigEditCommittedArgs args); void RevertChange(string sectionId, string optionName); - Task ApplyAsync(CancellationToken ct = default); + Task ApplyAsync(CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs new file mode 100644 index 0000000..403fe3b --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs @@ -0,0 +1,16 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +/// Orchestrates effective-config save: backup, write, reload; and restore from backup. Keeps IDnsmasqConfigService focused on read/write mechanics. +public interface IEffectiveConfigSaveService : IApplicationScopedService +{ + /// Creates a timestamped backup (if managed file exists), applies changes, then runs reload. Returns structured result for UI. + Task SaveAsync( + IReadOnlyList changes, + CancellationToken ct = default); + + /// Overwrites the managed config with the backup file, then runs reload. Returns structured result for UI. + Task RestoreAsync(string backupPath, CancellationToken ct = default); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs index bd039fc..bfb3928 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs @@ -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 _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 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? AsStringList(object? value) + { + return value as IReadOnlyList; + } + + 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()).SequenceEqual(newList ?? Array.Empty(), StringComparer.Ordinal); + return Equals(oldValue, newValue); } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs new file mode 100644 index 0000000..7d4d7c5 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs @@ -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 _logger; + + public EffectiveConfigSaveService( + IDnsmasqConfigSetService configSetService, + IDnsmasqConfigService configService, + IReloadService reloadService, + ILogger logger) + { + _configSetService = configSetService; + _configService = configService; + _reloadService = reloadService; + _logger = logger; + } + + /// + public async Task SaveAsync( + IReadOnlyList 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."); + } + + /// + public async Task 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); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs index 5665aee..350a169 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Logs/ApplicationLifecycleLoggingHostedService.cs @@ -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); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Registration/Abstractions/IApplicationOptionsValidator.cs b/src/DnsmasqWebUI/Infrastructure/Services/Registration/Abstractions/IApplicationOptionsValidator.cs new file mode 100644 index 0000000..49e030b --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Registration/Abstractions/IApplicationOptionsValidator.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +/// +/// Marker interface for options validators that are registered via assembly scanning. +/// Implement this (instead of directly) so only intended validators are registered. +/// +public interface IApplicationOptionsValidator : IValidateOptions where TOptions : class +{ +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/Abstractions/INotificationService.cs b/src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/Abstractions/INotificationService.cs new file mode 100644 index 0000000..87e3c04 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/Abstractions/INotificationService.cs @@ -0,0 +1,43 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions; + +/// Scoped service to show toast notifications (success, error, warning, info). NotificationHost in MainLayout subscribes and renders toasts. Registered via assembly scanning (). +public interface INotificationService : IApplicationScopedService +{ + /// Raised when the list of toasts changes. NotificationHost subscribes and calls StateHasChanged. + event EventHandler? NotificationsChanged; + + /// Shows a success toast (e.g. "Config saved and dnsmasq reloaded."). Auto-dismisses after default duration. + void ShowSuccess(string message); + + /// Shows an error toast. + void ShowError(string message); + + /// Shows a warning toast. + void ShowWarning(string message); + + /// Shows an info toast. + void ShowInfo(string message); + + /// Returns a snapshot of current toasts for rendering. Caller should re-render when fires. + IReadOnlyList GetSnapshot(); + + /// Removes the toast with the given id. Raises . + void Dismiss(int id); +} + +/// Kind of notification for styling and icon. +public enum NotificationKind +{ + Success, + Error, + Warning, + Info +} + +/// Single toast item. +/// Unique id for dismiss. +/// Success, Error, Warning, Info. +/// Text to show. +public record NotificationItem(int Id, NotificationKind Kind, string Message); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/NotificationService.cs b/src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/NotificationService.cs new file mode 100644 index 0000000..c7c9dce --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/UI/Notifications/NotificationService.cs @@ -0,0 +1,42 @@ +using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Services.UI.Notifications; + +/// +public sealed class NotificationService : INotificationService +{ + private readonly List _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 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); + } + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/UI/Settings/Abstractions/ISettingsModalService.cs b/src/DnsmasqWebUI/Infrastructure/Services/UI/Settings/Abstractions/ISettingsModalService.cs index 7691fd1..6e2f58e 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/UI/Settings/Abstractions/ISettingsModalService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/UI/Settings/Abstractions/ISettingsModalService.cs @@ -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 and hosts the modal. /// Subscribe to to refresh when settings are saved/closed. +/// Registered via assembly scanning (). /// -public interface ISettingsModalService +public interface ISettingsModalService : IApplicationScopedService { /// Raised when is called. MainLayout subscribes and shows the modal. event Action? OpenRequested; diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Updates/Abstractions/IUpdateCheckService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Updates/Abstractions/IUpdateCheckService.cs index 365da31..d6772a2 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Updates/Abstractions/IUpdateCheckService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Updates/Abstractions/IUpdateCheckService.cs @@ -1,10 +1,13 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + namespace DnsmasqWebUI.Infrastructure.Services.Updates.Abstractions; /// /// Service that checks GitHub for a newer release. Runs on a configurable interval (hosted service) /// and can be triggered manually. UI subscribes to to refresh when state updates. +/// Registered via assembly scanning (). /// -public interface IUpdateCheckService +public interface IUpdateCheckService : IApplicationSingleton { /// Raised when a check completes (background or manual). Subscribe and call StateHasChanged from the UI. event EventHandler? ResultChanged; diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs index 05c6314..6e8cd3d 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptions.cs @@ -29,7 +29,10 @@ public class DnsmasqOptions /// 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. public string? SystemHostsPath { get; set; } - /// 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. + /// Command to run to apply config changes (e.g. "systemctl restart dnsmasq"). When set, this is used instead of so that .conf file changes take effect (dnsmasq does not re-read config on SIGHUP). If unset, is used. + public string? RestartCommand { get; set; } + + /// Command to run after config changes when 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. public string? ReloadCommand { get; set; } /// 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. diff --git a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs index 28805b7..dbe7eda 100644 --- a/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs +++ b/src/DnsmasqWebUI/Models/Config/DnsmasqOptionsValidator.cs @@ -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; /// /// 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 (). /// -public sealed class DnsmasqOptionsValidator : IValidateOptions +public sealed class DnsmasqOptionsValidator : IApplicationOptionsValidator { public ValidateOptionsResult Validate(string? name, DnsmasqOptions options) { diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ConfigOptionHelpRequestEventArgs.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ConfigOptionHelpRequestEventArgs.cs index f6d1b4e..829922b 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ConfigOptionHelpRequestEventArgs.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ConfigOptionHelpRequestEventArgs.cs @@ -10,7 +10,7 @@ public sealed class ConfigOptionHelpRequestEventArgs /// Option-help file key (e.g. "server", "no-hosts"). public string HelpKey { get; init; } = ""; - /// Display label for the modal title (e.g. "server / local"). + /// Display label for the modal title (e.g. "server" or "listen-address"). public string OptionLabel { get; init; } = ""; /// Id of the label element that triggered the request; used to position the modal and match leave events. diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs new file mode 100644 index 0000000..b97d85f --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigRestoreResult.cs @@ -0,0 +1,14 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// Result of restoring the managed config from a backup file, then reloading dnsmasq. +/// True when the backup was copied over the managed file. +/// True when dnsmasq reload succeeded after restore. +/// Reload process exit code; -1 when not run or failed to start. +/// Standard error from reload command. +/// Short message for the user. +public record EffectiveConfigRestoreResult( + bool Restored, + bool Reloaded, + int ReloadExitCode, + string? ReloadStdErr, + string? UserMessage); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs new file mode 100644 index 0000000..bb4dfd5 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs @@ -0,0 +1,27 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// Result of the effective-config save flow (backup, write, reload). Used by UI to drive state and show restore/continue options. +/// True when a backup file was created before write. +/// Path to the backup file when one was created; null otherwise. +/// True when the managed config was written successfully. +/// True when dnsmasq reload succeeded after write. +/// Reload process exit code; -1 when not run or failed to start. +/// Standard output from reload command. +/// Standard error from reload command. +/// Machine-readable code: no_changes, missing_managed_path, write_failed, reload_failed, or null when success. +/// Short message for the user. +public record EffectiveConfigSaveResult( + bool BackupCreated, + string? BackupPath, + bool Saved, + bool Reloaded, + int ReloadExitCode, + string? ReloadStdOut, + string? ReloadStdErr, + string? ErrorCode, + string? UserMessage) +{ + /// Result when there are no pending changes to apply. + public static EffectiveConfigSaveResult NoChanges() => + new(false, null, false, false, -1, null, null, "no_changes", "No pending changes."); +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs index 8de1bc2..06b609d 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSources.cs @@ -16,7 +16,8 @@ public record EffectiveConfigSources( ConfigValueSource? HostsdirPath, // --- Multi-value (ARG_DUP): source per value --- - IReadOnlyList ServerLocalValues, + IReadOnlyList ServerValues, + IReadOnlyList LocalValues, IReadOnlyList RevServerValues, IReadOnlyList AddressValues, IReadOnlyList Interfaces, diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs index 84ddd52..5ccd81d 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveDnsmasqConfig.cs @@ -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. /// public record EffectiveDnsmasqConfig( @@ -20,7 +20,8 @@ public record EffectiveDnsmasqConfig( string? HostsdirPath, // --- Multi-value (ARG_DUP): all occurrences in order --- - IReadOnlyList ServerLocalValues, + IReadOnlyList ServerValues, + IReadOnlyList LocalValues, IReadOnlyList RevServerValues, IReadOnlyList AddressValues, IReadOnlyList Interfaces, diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index a3d8e11..8e6dac8 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -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() builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName)) .ValidateOnStart(); -builder.Services.AddSingleton, DnsmasqOptionsValidator>(); -// ---- Application services ---- +// ---- Application services (assembly-scanned: IApplicationScopedService, IApplicationSingleton, IValidateOptions<>, etc.) ---- builder.Services.AddApplicationServices(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); builder.Services.AddHttpContextAccessor(); builder.Services.AddDnsmasqApiHttpClients(); diff --git a/src/DnsmasqWebUI/appsettings.json b/src/DnsmasqWebUI/appsettings.json index a882f9c..1164e29 100644 --- a/src/DnsmasqWebUI/appsettings.json +++ b/src/DnsmasqWebUI/appsettings.json @@ -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", diff --git a/testdata/dnsmasq-test-minimal.conf b/testdata/dnsmasq-test-minimal.conf new file mode 100644 index 0000000..c452b61 --- /dev/null +++ b/testdata/dnsmasq-test-minimal.conf @@ -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