diff --git a/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/PendingManagedHostsChangeTests.cs b/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/PendingManagedHostsChangeTests.cs new file mode 100644 index 0000000..fd17978 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/PendingManagedHostsChangeTests.cs @@ -0,0 +1,38 @@ +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Tests.Models.EffectiveConfig; + +public class PendingManagedHostsChangeTests +{ + [Fact] + public void HasChanges_WhenEntriesDiffer_ReturnsTrue() + { + var oldEntries = new List { new() { Id = "1", Address = "192.168.1.1", Names = new List { "host1" } } }; + var newEntries = new List { new() { Id = "1", Address = "192.168.1.2", Names = new List { "host1" } } }; + var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts"); + Assert.True(pending.HasChanges); + } + + [Fact] + public void HasChanges_WhenEntriesSame_ReturnsFalse() + { + var entries = new List { new() { Id = "1", Address = "192.168.1.1", Names = new List { "host1" } } }; + var clone = entries.Select(e => new HostEntry { Id = e.Id, Address = e.Address, Names = new List(e.Names) }).ToList(); + var pending = new PendingManagedHostsChange(entries, clone, "/etc/hosts"); + Assert.False(pending.HasChanges); + } + + [Fact] + public void HasChanges_WhenCountDiffers_ReturnsTrue() + { + var oldEntries = new List { new() { Id = "1", Address = "192.168.1.1", Names = new List() } }; + var newEntries = new List + { + new() { Id = "1", Address = "192.168.1.1", Names = new List() }, + new() { Id = "2", Address = "192.168.1.2", Names = new List() } + }; + var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts"); + Assert.True(pending.HasChanges); + } +} diff --git a/src/DnsmasqWebUI.Tests/Serialization/DnsmasqConfTextTests.cs b/src/DnsmasqWebUI.Tests/Serialization/DnsmasqConfTextTests.cs new file mode 100644 index 0000000..7ec5de4 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/DnsmasqConfTextTests.cs @@ -0,0 +1,58 @@ +using DnsmasqWebUI.Infrastructure.Serialization; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; + +namespace DnsmasqWebUI.Tests.Serialization; + +public class DnsmasqConfTextTests +{ + [Fact] + public void DirectivePrefix_ReturnsOptionEquals() + { + Assert.Equal("dhcp-host=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.DhcpHost)); + Assert.Equal("server=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.Server)); + } + + [Fact] + public void DirectiveLine_WhenValueNullOrEmpty_ReturnsOptionOnly() + { + Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", null)); + Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", "")); + } + + [Fact] + public void DirectiveLine_WhenValueProvided_ReturnsOptionEqualsValue() + { + Assert.Equal("dhcp-host=11:22:33:44:55:66,192.168.1.1", DnsmasqConfText.DirectiveLine("dhcp-host", "11:22:33:44:55:66,192.168.1.1")); + } + + [Fact] + public void StripDirectivePrefix_WhenLineStartsWithPrefix_ReturnsRest() + { + var line = "dhcp-host=mac,ip"; + Assert.Equal("mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line)); + } + + [Fact] + public void StripDirectivePrefix_WhenLineDoesNotStartWithPrefix_ReturnsFullLine() + { + var line = "other=value"; + Assert.Equal("other=value", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line)); + } + + [Fact] + public void StripDirectivePrefix_WhenLineIsCommented_PreservesLeadingHashInResult() + { + Assert.Equal("#mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=mac,ip")); + Assert.Equal("##mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=mac,ip")); + } + + [Fact] + public void HasDirectivePrefix_MatchesOptionAndCommentedForms() + { + Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "dhcp-host=value")); + Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=value")); + Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=value")); + Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "server=8.8.8.8")); + Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "other")); + } +} diff --git a/src/DnsmasqWebUI.Tests/Serialization/EffectiveConfigDirectiveSerializerTests.cs b/src/DnsmasqWebUI.Tests/Serialization/EffectiveConfigDirectiveSerializerTests.cs new file mode 100644 index 0000000..b05dd52 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/EffectiveConfigDirectiveSerializerTests.cs @@ -0,0 +1,66 @@ +using DnsmasqWebUI.Infrastructure.Serialization; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Tests.Serialization; + +public class EffectiveConfigDirectiveSerializerTests +{ + private readonly EffectiveConfigDirectiveSerializer _sut = new(); + + [Fact] + public void SerializeSingle_Flag_True_WritesBareKey() + { + var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, true); + Assert.Equal(DnsmasqConfKeys.Conntrack, result); + } + + [Fact] + public void SerializeSingle_Flag_False_WritesEmpty() + { + var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, false); + Assert.Equal("", result); + } + + [Fact] + public void SerializeSingle_SingleValue_WritesKeyEqualsValue() + { + var result = _sut.SerializeSingle(DnsmasqConfKeys.Port, 5353); + Assert.Equal("port=5353", result); + } + + [Fact] + public void SerializeSingle_KeyOnlyOrValue_Empty_WritesBareKey() + { + var result = _sut.SerializeSingle(DnsmasqConfKeys.UseStaleCache, ""); + Assert.Equal(DnsmasqConfKeys.UseStaleCache, result); + } + + [Fact] + public void SerializeMulti_MultiValue_WritesEachDirective() + { + var result = _sut.SerializeMulti(DnsmasqConfKeys.Server, ["1.1.1.1", "8.8.8.8"]); + Assert.Equal(["server=1.1.1.1", "server=8.8.8.8"], result); + } + + [Fact] + public void SerializeMulti_MultiKeyOnlyOrValue_WritesMixedForms() + { + var result = _sut.SerializeMulti(DnsmasqConfKeys.Leasequery, ["", "net:tag"]); + Assert.Equal(["leasequery", "leasequery=net:tag"], result); + } + + [Fact] + public void SerializeSingle_InversePair_Enabled_WritesPositiveKey() + { + var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Enabled); + Assert.Equal(DnsmasqConfKeys.Do0x20Encode, result); + } + + [Fact] + public void SerializeSingle_InversePair_Disabled_WritesNegativeKey() + { + var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled); + Assert.Equal(DnsmasqConfKeys.No0x20Encode, result); + } +} diff --git a/src/DnsmasqWebUI.Tests/Serialization/OptionHandlers/DhcpHostOptionValueHandlerTests.cs b/src/DnsmasqWebUI.Tests/Serialization/OptionHandlers/DhcpHostOptionValueHandlerTests.cs new file mode 100644 index 0000000..542ba65 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/OptionHandlers/DhcpHostOptionValueHandlerTests.cs @@ -0,0 +1,106 @@ +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dhcp; + +namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers; + +public class DhcpHostOptionValueHandlerTests +{ + private readonly DhcpHostOptionValueHandler _sut = new(); + + [Fact] + public void OptionName_EqualsDnsmasqConfKeysDhcpHost() + { + Assert.Equal(DnsmasqConfKeys.DhcpHost, _sut.OptionName); + } + + [Fact] + public void SerializeValue_EmitsValueOnlyForm() + { + var entry = new DhcpHostEntry + { + MacAddresses = new List { "11:22:33:44:55:66" }, + Address = "192.168.1.10" + }; + var value = _sut.SerializeValue(entry); + Assert.Equal("11:22:33:44:55:66, 192.168.1.10", value); + } + + [Fact] + public void SerializeValue_IgnoreEntry_EmitsIgnoreToken() + { + var entry = new DhcpHostEntry + { + MacAddresses = new List { "aa:bb:cc:dd:ee:ff" }, + Ignore = true + }; + var value = _sut.SerializeValue(entry); + Assert.Contains("ignore", value); + Assert.Contains("aa:bb:cc:dd:ee:ff", value); + } + + [Fact] + public void SerializeValue_EmptyEntry_ReturnsEmptyString() + { + var entry = new DhcpHostEntry(); + var value = _sut.SerializeValue(entry); + Assert.Equal("", value); + } + + [Fact] + public void TryParseValue_RoundTripsStructuredFields() + { + var input = new DhcpHostEntry + { + MacAddresses = new List { "11:22:33:44:55:66" }, + Address = "192.168.1.10", + Extra = new List { "id:01:02:03", "set:test" }, + Comment = "note", + IsComment = true + }; + var value = _sut.SerializeValue(input); + var ok = _sut.TryParseValue(value, 1, out var output); + Assert.True(ok); + Assert.NotNull(output); + Assert.True(output!.IsComment); + Assert.Contains("id:01:02:03", output.Extra!); + Assert.Equal("note", output.Comment); + } + + [Fact] + public void TryParseValue_IsComment_RoundTrips() + { + var entry = new DhcpHostEntry + { + MacAddresses = new List { "aa:bb:cc:dd:ee:ff" }, + Address = "192.168.1.1", + IsComment = true + }; + var value = _sut.SerializeValue(entry); + Assert.StartsWith("#", value); + var ok = _sut.TryParseValue(value, 1, out var roundTripped); + Assert.True(ok); + Assert.NotNull(roundTripped); + Assert.True(roundTripped!.IsComment); + Assert.Single(roundTripped.MacAddresses!); + Assert.Equal("aa:bb:cc:dd:ee:ff", roundTripped.MacAddresses[0]); + Assert.Equal("192.168.1.1", roundTripped.Address); + } + + [Fact] + public void TryParseValue_Comment_EmitsTrailingComment_AndRoundTrips() + { + var entry = new DhcpHostEntry + { + MacAddresses = new List { "aa:bb:cc:dd:ee:ff" }, + Address = "192.168.1.1", + Comment = " my note " + }; + var value = _sut.SerializeValue(entry); + Assert.Contains(" # ", value); + var ok = _sut.TryParseValue(value, 1, out var roundTripped); + Assert.True(ok); + Assert.NotNull(roundTripped); + Assert.Equal("my note", roundTripped!.Comment?.Trim()); + } +} diff --git a/src/DnsmasqWebUI.Tests/Serialization/OptionHandlers/StructuredOptionValueHandlerRegistryTests.cs b/src/DnsmasqWebUI.Tests/Serialization/OptionHandlers/StructuredOptionValueHandlerRegistryTests.cs new file mode 100644 index 0000000..130e037 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/OptionHandlers/StructuredOptionValueHandlerRegistryTests.cs @@ -0,0 +1,126 @@ +using System.Reflection; +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers; +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dhcp; + +namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers; + +/// Ensures structured handlers use DnsmasqConfKeys and registry has no duplicate option names. +public class StructuredOptionValueHandlerRegistryTests +{ + [Fact] + public void DhcpHostOptionValueHandler_OptionName_EqualsDnsmasqConfKeysDhcpHost() + { + var handler = new DhcpHostOptionValueHandler(); + Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName); + } + + [Fact] + public void DhcpHostOptionValueHandler_OptionName_IsFromDnsmasqConfKeys() + { + var handler = new DhcpHostOptionValueHandler(); + var validKeys = GetDnsmasqConfKeysValues(); + Assert.Contains(handler.OptionName, validKeys); + } + + [Fact] + public void Registry_WithDhcpHostHandler_ReturnsHandlerForDhcpHost() + { + var handler = new DhcpHostOptionValueHandler(); + var registry = new StructuredOptionValueHandlerRegistry(new[] { handler }); + var resolved = registry.Get(DnsmasqConfKeys.DhcpHost); + Assert.NotNull(resolved); + Assert.Equal(DnsmasqConfKeys.DhcpHost, resolved.OptionName); + } + + [Fact] + public void Registry_IsStructured_WhenHandlerExists_ReturnsTrue() + { + var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]); + Assert.True(registry.IsStructured(DnsmasqConfKeys.DhcpHost)); + Assert.False(registry.IsStructured(DnsmasqConfKeys.Server)); + } + + [Fact] + public void Registry_GetRequired_WhenHandlerExists_ReturnsTypedHandler() + { + var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]); + var handler = registry.GetRequired(DnsmasqConfKeys.DhcpHost); + Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName); + } + + [Fact] + public void Registry_GetRequired_WhenOptionIsNotStructured_Throws() + { + var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]); + var ex = Assert.Throws(() => registry.GetRequired(DnsmasqConfKeys.Server)); + Assert.Contains("not declared as a structured option", ex.Message); + } + + [Fact] + public void Registry_DuplicateOptionNames_Throws() + { + var ex = Assert.Throws(() => + new StructuredOptionValueHandlerRegistry([ + new DhcpHostOptionValueHandler(), + new DuplicateDhcpHostHandler() + ])); + Assert.Contains("Duplicate structured option handlers", ex.Message); + } + + [Fact] + public void Semantics_DhcpHost_HasStructuredValueTypeMatchingHandler() + { + var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(DnsmasqConfKeys.DhcpHost); + Assert.NotNull(structuredType); + Assert.Equal(typeof(DhcpHostEntry), structuredType); + var handler = new DhcpHostOptionValueHandler(); + Assert.Equal(structuredType, handler.ValueType); + } + + [Fact] + public void Registry_HandlerWithoutStructuredMetadata_Throws() + { + var ex = Assert.Throws(() => + new StructuredOptionValueHandlerRegistry([new InvalidServerHandler()])); + Assert.Contains("no StructuredValueType", ex.Message); + } + + private static HashSet GetDnsmasqConfKeysValues() + { + var set = new HashSet(StringComparer.Ordinal); + foreach (var field in typeof(DnsmasqConfKeys).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.FieldType == typeof(string) && field.GetValue(null) is string value) + set.Add(value); + } + return set; + } + + private sealed class DuplicateDhcpHostHandler : IStructuredOptionValueHandler + { + public string OptionName => DnsmasqConfKeys.DhcpHost; + public Type ValueType => typeof(DhcpHostEntry); + public string SerializeLine(DhcpHostEntry value) => ""; + public string SerializeValue(DhcpHostEntry value) => ""; + public bool TryParseValue(string text, int lineNumber, out DhcpHostEntry? value) + { + value = null; + return false; + } + } + + private sealed class InvalidServerHandler : IStructuredOptionValueHandler + { + public string OptionName => DnsmasqConfKeys.Server; + public Type ValueType => typeof(string); + public string SerializeLine(string value) => ""; + public string SerializeValue(string value) => value; + public bool TryParseValue(string text, int lineNumber, out string? value) + { + value = text; + return true; + } + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs index c8ad890..02ac4f3 100644 --- a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Config/DnsmasqConfigServiceApplyChangesTests.cs @@ -194,6 +194,43 @@ public class DnsmasqConfigServiceApplyChangesTests } } + [Fact] + public async Task ApplyEffectiveConfigChangesAsync_SingleValueNull_RemovesExistingLine() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + var managedName = "zz-managed.conf"; + var managedPath = Path.Combine(dir, managedName); + ConfigSetCache? cache = null; + try + { + File.WriteAllText(mainPath, "port=53\n"); + File.WriteAllText(managedPath, "port=54\n"); + var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName }); + cache = new ConfigSetCache(options, NullLogger.Instance); + cache.Invalidate(); + var setService = new DnsmasqConfigSetService(cache); + var configService = new DnsmasqConfigService(setService, cache, NullLogger.Instance); + + var changes = new List + { + new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Port, "54", null, null) + }; + await configService.ApplyEffectiveConfigChangesAsync(changes); + + var content = await File.ReadAllTextAsync(managedPath); + var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList(); + Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.Port || l.StartsWith($"{DnsmasqConfKeys.Port}=", StringComparison.Ordinal)); + } + finally + { + cache?.Dispose(); + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + [Fact] public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey() { diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditSessionChangedEventTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditSessionChangedEventTests.cs new file mode 100644 index 0000000..9209021 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigEditSessionChangedEventTests.cs @@ -0,0 +1,70 @@ +using System.Linq; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Hosts; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DnsmasqWebUI.Tests.Services.EffectiveConfig; + +public class EffectiveConfigEditSessionChangedEventTests +{ + [Fact] + public void TrackCommit_RaisesChanged() + { + var saveService = new StubSaveService(); + var session = new EffectiveConfigEditSession(saveService); + session.EnterEditMode(); + var raised = false; + session.Changed += () => raised = true; + session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null)); + Assert.True(raised); + } + + [Fact] + public void EnterEditMode_RaisesChanged() + { + var saveService = new StubSaveService(); + var session = new EffectiveConfigEditSession(saveService); + var raised = false; + session.Changed += () => raised = true; + session.EnterEditMode(); + Assert.True(raised); + } + + [Fact] + public void RevertChange_RaisesChanged() + { + var saveService = new StubSaveService(); + var session = new EffectiveConfigEditSession(saveService); + session.EnterEditMode(); + session.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null)); + var raised = false; + session.Changed += () => raised = true; + session.RevertChange("resolver", "port"); + Assert.True(raised); + } + + [Fact] + public void TrackManagedHostsChange_StoresChange_AndRevertManagedHostsChange_RemovesIt() + { + var saveService = new StubSaveService(); + var session = new EffectiveConfigEditSession(saveService); + session.EnterEditMode(); + var oldEntries = new List { new() { Address = "1.2.3.4", Names = new List { "a" } } }; + var newEntries = new List { new() { Address = "1.2.3.4", Names = new List { "a", "b" } } }; + var change = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts"); + session.TrackManagedHostsChange(change); + Assert.Single(session.PendingChanges.OfType()); + session.RevertManagedHostsChange(); + Assert.Empty(session.PendingChanges.OfType()); + } + + private sealed class StubSaveService : IEffectiveConfigSaveService + { + public Task SaveAsync(IReadOnlyList changes, CancellationToken ct = default) => + Task.FromResult(EffectiveConfigSaveResult.NoChanges()); + public Task RestoreAsync(IReadOnlyList backups, CancellationToken ct = default) => + Task.FromResult(new EffectiveConfigRestoreResult(false, false, -1, null, "Stub")); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs index d80db4b..e2fa940 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceCapabilityTests.cs @@ -1,7 +1,9 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; +using DnsmasqWebUI.Models.Hosts; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; @@ -43,15 +45,16 @@ public class EffectiveConfigSaveServiceCapabilityTests setService, configService, cache, + new StubHostsFileService(), new UnexpectedValidationService(), semanticValidationService, new UnexpectedReloadService(), versionService, NullLogger.Instance); - var changes = new List + var changes = new List { - new(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null) + new PendingOptionChange(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null) }; var result = await saveService.SaveAsync(changes); @@ -107,4 +110,10 @@ public class EffectiveConfigSaveServiceCapabilityTests public Task ReloadAsync(CancellationToken ct = default) => throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported."); } + + private sealed class StubHostsFileService : IHostsFileService + { + public Task> ReadAsync(CancellationToken ct = default) => Task.FromResult>(new List()); + public Task WriteAsync(IReadOnlyList entries, CancellationToken ct = default) => Task.CompletedTask; + } } diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceMultiBackupTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceMultiBackupTests.cs new file mode 100644 index 0000000..68a6aa8 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSaveServiceMultiBackupTests.cs @@ -0,0 +1,216 @@ +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; +using DnsmasqWebUI.Models.Hosts; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; +using DnsmasqWebUI.Models.Config; +using DnsmasqWebUI.Models.Contracts; +using DnsmasqWebUI.Models.Dhcp; +using DnsmasqWebUI.Models.Dnsmasq; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Tests.Services.EffectiveConfig; + +/// +/// Tests for multi-file backup/restore and managed-hosts-only save. +/// +public class EffectiveConfigSaveServiceMultiBackupTests +{ + [Fact] + public async Task SaveAsync_WhenOnlyManagedHostsChange_BacksUpManagedHostsFile_WritesHosts_ValidatesAndReloads() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hosts-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + ConfigSetCache? cache = null; + try + { + File.WriteAllText(mainPath, "port=53\n"); + var options = Options.Create(new DnsmasqOptions + { + MainConfigPath = mainPath, + ManagedFileName = "zz-managed.conf", + ManagedHostsFileName = "zz-dnsmasq-webui.hosts" + }); + cache = new ConfigSetCache(options, NullLogger.Instance); + var setService = new DnsmasqConfigSetService(cache); + var configService = new DnsmasqConfigService(setService, cache, NullLogger.Instance); + + var set = await setService.GetConfigSetAsync(); + var managedHostsPath = set.ManagedHostsFilePath!; + File.WriteAllText(managedHostsPath, "192.168.1.1 host1\n"); + + var writtenEntries = new List>(); + var capturingHostsService = new CapturingHostsFileService(writtenEntries); + + var versionService = new StubVersionService(CreateSupportedVersionInfo()); + var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty())); + var saveService = new EffectiveConfigSaveService( + setService, + configService, + cache, + capturingHostsService, + new StubValidationService(success: true), + semanticValidationService, + new StubReloadService(success: true), + versionService, + NullLogger.Instance); + + var oldEntries = new List { new() { Address = "192.168.1.1", Names = new List { "host1" }, Id = "192.168.1.1|host1" } }; + var newEntries = new List + { + new() { Address = "192.168.1.1", Names = new List { "host1" }, Id = "192.168.1.1|host1" }, + new() { Address = "192.168.1.2", Names = new List { "host2" }, Id = "192.168.1.2|host2" } + }; + var changes = new List + { + new PendingManagedHostsChange(oldEntries, newEntries, managedHostsPath) + }; + + var result = await saveService.SaveAsync(changes); + + Assert.True(result.Saved); + Assert.True(result.Validated); + Assert.True(result.Restarted); + Assert.Null(result.ErrorCode); + Assert.Single(result.Backups); + Assert.Equal(DnsmasqManagedTargetKind.ManagedHosts, result.Backups[0].Kind); + Assert.Equal(managedHostsPath, result.Backups[0].TargetPath); + Assert.True(File.Exists(result.Backups[0].BackupPath)); + Assert.Single(writtenEntries); + Assert.Equal(2, writtenEntries[0].Count); + } + finally + { + cache?.Dispose(); + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task RestoreAsync_WhenBackupsContainTwoTargets_RestoresBothThenReloads() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-restore-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(mainPath, "port=53\n"); + var target1 = Path.Combine(dir, "target1.conf"); + var target2 = Path.Combine(dir, "target2.hosts"); + var backup1 = Path.Combine(dir, "backup1.bak"); + var backup2 = Path.Combine(dir, "backup2.bak"); + File.WriteAllText(target1, "original1"); + File.WriteAllText(target2, "original2"); + File.WriteAllText(backup1, "restored1"); + File.WriteAllText(backup2, "restored2"); + + var backups = new List + { + new(DnsmasqManagedTargetKind.ManagedConfig, target1, backup1), + new(DnsmasqManagedTargetKind.ManagedHosts, target2, backup2) + }; + + var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "x.conf" }); + var cache = new ConfigSetCache(options, NullLogger.Instance); + var setService = new DnsmasqConfigSetService(cache); + try + { + var saveService = new EffectiveConfigSaveService( + setService, + new StubConfigService(), + cache, + new StubHostsFileService(), + new StubValidationService(success: true), + new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty())), + new StubReloadService(success: true), + new StubVersionService(CreateSupportedVersionInfo()), + NullLogger.Instance); + + var result = await saveService.RestoreAsync(backups); + + Assert.True(result.Restored); + Assert.True(result.Restarted); + Assert.Equal("restored1", File.ReadAllText(target1)); + Assert.Equal("restored2", File.ReadAllText(target2)); + } + finally + { + cache.Dispose(); + if (Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + } + } + + private static DnsmasqVersionInfo CreateSupportedVersionInfo() + { + var capabilities = new DnsmasqCompileCapabilities( + Dhcp: true, Tftp: true, Dnssec: true, Dbus: false, + new HashSet(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" }); + return new DnsmasqVersionInfo( + new Version(2, 91), new Version(2, 91), + ProbeSucceeded: true, IsSupported: true, "dnsmasq --version", null, capabilities); + } + + private sealed class CapturingHostsFileService : IHostsFileService + { + private readonly List> _written; + + public CapturingHostsFileService(List> written) => _written = written; + + public Task> ReadAsync(CancellationToken ct = default) => + Task.FromResult>(new List()); + + public Task WriteAsync(IReadOnlyList entries, CancellationToken ct = default) + { + _written.Add(entries); + return Task.CompletedTask; + } + } + + private sealed class StubVersionService : IDnsmasqVersionService + { + private readonly DnsmasqVersionInfo _info; + public StubVersionService(DnsmasqVersionInfo info) => _info = info; + public Task GetVersionInfoAsync(CancellationToken ct = default) => Task.FromResult(_info); + } + + private sealed class StubValidationService : IConfigValidationService + { + private readonly bool _success; + public StubValidationService(bool success) => _success = success; + public Task ValidateAsync(CancellationToken ct = default) => + Task.FromResult(new ConfigValidationResult(_success, true, _success ? 0 : 1, null, null, null)); + } + + private sealed class StubReloadService : IReloadService + { + private readonly bool _success; + public StubReloadService(bool success) => _success = success; + public Task ReloadAsync(CancellationToken ct = default) => + Task.FromResult(new ReloadResult(_success, _success ? 0 : 1, null, null)); + } + + private sealed class StubHostsFileService : IHostsFileService + { + public Task> ReadAsync(CancellationToken ct = default) => + Task.FromResult>(new List()); + public Task WriteAsync(IReadOnlyList entries, CancellationToken ct = default) => Task.CompletedTask; + } + + private sealed class StubConfigService : IDnsmasqConfigService + { + public Task ApplyEffectiveConfigChangesAsync(IReadOnlyList changes, CancellationToken ct = default) => Task.CompletedTask; + public Task> ReadDhcpHostsAsync(CancellationToken ct = default) => Task.FromResult>(Array.Empty()); + public Task WriteDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default) => Task.CompletedTask; + public Task ReadManagedConfigAsync(CancellationToken ct = default) => Task.FromResult(new ManagedConfigContent(Array.Empty(), "")); + public Task WriteManagedConfigAsync(IReadOnlyList lines, CancellationToken ct = default) => Task.CompletedTask; + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs index f24b2f7..b6c724e 100644 --- a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigSemanticsWiringTests.cs @@ -16,9 +16,9 @@ public class EffectiveConfigSemanticsWiringTests public void Registry_WiresSemanticValidator_ForDnsRr() { var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DnsRrSemanticHandler()])); - var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr); + var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr); Assert.NotNull(factory); - var descriptor = factory!(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null); + var descriptor = factory!(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null); Assert.NotNull(descriptor.Validator); Assert.Null(descriptor.Validator!.ValidateItem("example.com,16,01:02", Array.Empty())); Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty())); @@ -28,11 +28,11 @@ public class EffectiveConfigSemanticsWiringTests public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics() { var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()])); - var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery); + var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery); Assert.NotNull(factory); var descriptor = factory!( - EffectiveConfigFieldBuilder.SectionDhcp, + EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery, status: null, getItems: _ => null); diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs index 8df9bdf..e8e9ac1 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigValueDisplayBase.cs @@ -31,7 +31,7 @@ public abstract class EffectiveConfigValueDisplayBase : ComponentBase /// Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk). protected object? GetEffectiveDisplayValue() { - var pending = Session?.PendingChanges.FirstOrDefault(c => + var pending = Session?.PendingChanges.OfType().FirstOrDefault(c => string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); return pending != null ? pending.NewValue : Descriptor.GetValue(); diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigPanels.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigPanels.razor index 79db1df..11340dd 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigPanels.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigPanels.razor @@ -1,12 +1,13 @@ @namespace DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @using DnsmasqWebUI.Models.Dnsmasq @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig -@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry +@inject IEffectiveConfigDescriptorProvider DescriptorProvider
@{ var views = EffectiveConfigViews.GetViewsForContext(Context); - var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry); + var descriptorsBySection = Status != null ? DescriptorProvider.GetBySection(Status, views) : new Dictionary>(); var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm); var term = SearchTerm?.Trim() ?? ""; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor index 477b30d..df81e64 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor @@ -5,6 +5,7 @@ @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation @inject IEffectiveConfigEditSession Session @inject IJSRuntime JSRuntime +@implements IDisposable @if (Status != null) { @@ -58,6 +59,7 @@ protected override void OnInitialized() { + Session.Changed += HandleSessionChanged; _uiContext = new EffectiveConfigUiContext { RequestOptionHelpAsync = HandleOptionHelpRequested, @@ -74,6 +76,13 @@ }; } + private void HandleSessionChanged() => _ = InvokeAsync(StateHasChanged); + + public void Dispose() + { + Session.Changed -= HandleSessionChanged; + } + private async Task OnSearchTermChanged(string value) { _searchTerm = value ?? ""; @@ -191,7 +200,8 @@ private void RunCrossOptionEvaluator() { - var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges); + var optionChanges = Session.PendingChanges.OfType().ToList(); + var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, optionChanges); Session.SetCrossOptionIssues(issues); } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor index 559fc71..45a166a 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/Do0x20EncodeDisplay.razor @@ -52,7 +52,7 @@ else return; } - var pending = Session?.PendingChanges.FirstOrDefault(c => + var pending = Session?.PendingChanges.OfType().FirstOrDefault(c => string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); if (pending?.OldValue is ExplicitToggleState previousState) diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index b6ee156..0dd9822 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -137,7 +137,7 @@ else _fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create(this, HandleValueChanged)); _labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName); _optionHelpKey = Descriptor.OptionName; - var pending = Session?.PendingChanges.FirstOrDefault(c => + var pending = Session?.PendingChanges.OfType().FirstOrDefault(c => string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) && string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase)); var descriptorValue = Descriptor.IsMultiValue diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor index 484f652..9b5fb6e 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveFlow.razor @@ -18,7 +18,7 @@ OnClose="@OnClose" OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))" OnSave="@HandleSave" - OnRestore="@HandleRestore" + OnRestore="@HandleRestoreBackups" OnReverted="@HandleReverted" OnCloseAndEndEdit="@HandleCloseAndEndEdit" /> } @@ -36,7 +36,7 @@ } [Parameter] public bool ShowModal { get; set; } - [Parameter] public IReadOnlyList PendingChanges { get; set; } = null!; + [Parameter] public IReadOnlyList PendingChanges { get; set; } = null!; [Parameter] public DnsmasqServiceStatus? Status { get; set; } [Parameter] public EventCallback OnClose { get; set; } /// Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator). @@ -107,15 +107,15 @@ await InvokeAsync(StateHasChanged); } - private async Task HandleRestore(string backupPath) + private async Task HandleRestoreBackups(IReadOnlyList backups) { - if (string.IsNullOrEmpty(backupPath)) return; + if (backups == null || backups.Count == 0) return; _saveState = SaveUiState.RestoreInProgress; _restoreResult = null; await InvokeAsync(StateHasChanged); - var result = await SaveService.RestoreAsync(backupPath); + var result = await SaveService.RestoreAsync(backups); _restoreResult = result; if (result.Restored && result.Restarted) diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor index 8645f93..8195930 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/EffectiveConfigSaveModal.razor @@ -24,13 +24,19 @@
- Config was saved, but validation failed. Restart was not attempted. You can restore the backup or keep editing. + Changes were saved, but validation failed. Restart was not attempted. You can restore the backups or keep editing.
- @if (SaveResult.BackupPath != null) + @if (SaveResult.Backups.Count > 0) { -

- Backup: @SaveResult.BackupPath +

+ Backups created: +

    + @foreach (var b in SaveResult.Backups) + { +
  • @System.IO.Path.GetFileName(b.TargetPath) → @System.IO.Path.GetFileName(b.BackupPath)
  • + } +

}
@@ -41,11 +47,25 @@ } else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null) { -
- Save failed. @SaveResult.UserMessage - @if (!string.IsNullOrEmpty(SaveResult.RestartStdErr)) +
+
+ +
+ Save failed. @SaveResult.UserMessage + @(SaveResult.Backups?.Count > 0 ? " You can restore the backups to revert any partial write, or continue editing." : "") +
+
+ @if (SaveResult.Backups?.Count > 0) { -
@SaveResult.RestartStdErr
+

+ Backups created: +

    + @foreach (var b in SaveResult.Backups) + { +
  • @System.IO.Path.GetFileName(b.TargetPath) → @System.IO.Path.GetFileName(b.BackupPath)
  • + } +
+

}
} @@ -55,13 +75,19 @@
- Config was saved, but the restart command failed. You can restore the backup or keep editing. + Changes were saved, but the restart command failed. You can restore the backups or keep editing.
- @if (SaveResult.BackupPath != null) + @if (SaveResult.Backups.Count > 0) { -

- Backup: @SaveResult.BackupPath +

+ Backups created: +

    + @foreach (var b in SaveResult.Backups) + { +
  • @System.IO.Path.GetFileName(b.TargetPath) → @System.IO.Path.GetFileName(b.BackupPath)
  • + } +

}
@@ -115,10 +141,16 @@
}

These changes will be written to your config. Remove any you don't want to keep.

- @if (ManagedFilePath != null) + @if (TargetFileNames.Count > 0) { -

- Target file: @ManagedFilePath +

+ Targets: +

    + @foreach (var name in TargetFileNames) + { +
  • @name
  • + } +

} @if (PendingChanges.Count == 0) @@ -132,80 +164,97 @@
@foreach (var c in PendingChanges) { - var change = c; - var oldList = AsStringList(change.OldValue); - var newList = AsStringList(change.NewValue); - var isMultiValue = oldList != null || newList != null; -
-
-
- - @if (!isMultiValue) - { - - @FormatValue(change.OldValue, change.OptionName) - - @FormatValue(change.NewValue, change.OptionName) - - } -
- @if (isMultiValue) - { - var oldSeq = oldList ?? Array.Empty(); - var newSeq = newList ?? Array.Empty(); - var added = newSeq.Except(oldSeq).ToList(); - var removed = oldSeq.Except(newSeq).ToList(); - var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal); -
- @if (orderOrDuplicatesChanged) + @if (c is PendingOptionChange change) + { + var oldList = AsStringList(change.OldValue); + var newList = AsStringList(change.NewValue); + var isMultiValue = oldList != null || newList != null; +
+
+
+ + @if (!isMultiValue) { -
Order or duplicates changed:
-
Before: @string.Join(", ", oldSeq)
-
After: @string.Join(", ", newSeq)
- } - else - { - @foreach (var val in removed) - { -
− @val
- } - @foreach (var val in added) - { -
+ @val
- } + + @FormatValue(change.OldValue, change.OptionName) + + @FormatValue(change.NewValue, change.OptionName) + }
- } - @if (change.CurrentSourceFilePath != null) - { -
Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)
- } + @if (isMultiValue) + { + var oldSeq = oldList ?? Array.Empty(); + var newSeq = newList ?? Array.Empty(); + var added = newSeq.Except(oldSeq).ToList(); + var removed = oldSeq.Except(newSeq).ToList(); + var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal); +
+ @if (orderOrDuplicatesChanged) + { +
Order or duplicates changed:
+
Before: @string.Join(", ", oldSeq)
+
After: @string.Join(", ", newSeq)
+ } + else + { + @foreach (var val in removed) + { +
− @val
+ } + @foreach (var val in added) + { +
+ @val
+ } + } +
+ } + @if (change.CurrentSourceFilePath != null) + { +
Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)
+ } +
+
- -
+ } + else if (c is PendingManagedHostsChange hostChange) + { +
+
+
+ + @hostChange.OldEntries.Count entries → @hostChange.NewEntries.Count entries +
+
@System.IO.Path.GetFileName(hostChange.ManagedHostsFilePath)
+
+ +
+ } }
} }
@code { - [Parameter] public IReadOnlyList PendingChanges { get; set; } = null!; + [Parameter] public IReadOnlyList PendingChanges { get; set; } = null!; [Parameter] public DnsmasqServiceStatus? Status { get; set; } [Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!; [Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; } @@ -227,13 +276,26 @@ [Parameter] public EventCallback OnClose { get; set; } [Parameter] public EventCallback OnContinueEditing { get; set; } [Parameter] public EventCallback OnSave { get; set; } - [Parameter] public EventCallback OnRestore { get; set; } + [Parameter] public EventCallback> OnRestore { get; set; } [Parameter] public EventCallback OnReverted { get; set; } [Parameter] public EventCallback OnCloseAndEndEdit { get; set; } private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress; - private string? ManagedFilePath => Status?.ManagedFilePath; + private IReadOnlyList TargetFileNames => GetTargetFileNames(); + + private IReadOnlyList GetTargetFileNames() + { + var list = new List(); + if (Status == null) return list; + var hasOptions = PendingChanges.OfType().Any(); + if (hasOptions && !string.IsNullOrEmpty(Status.ManagedFilePath)) + list.Add(System.IO.Path.GetFileName(Status.ManagedFilePath)); + var hostsChange = PendingChanges.OfType().FirstOrDefault(); + if (hostsChange != null) + list.Add(System.IO.Path.GetFileName(hostsChange.ManagedHostsFilePath)); + return list; + } private IReadOnlyList ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList(); private IReadOnlyList ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList(); @@ -300,12 +362,18 @@ return s; } - private async Task Revert(PendingEffectiveConfigChange change) + private async Task RevertOption(PendingOptionChange change) { Session.RevertChange(change.SectionId, change.OptionName); await OnReverted.InvokeAsync(); } + private async Task RevertManagedHosts() + { + Session.RevertManagedHostsChange(); + await OnReverted.InvokeAsync(); + } + private async Task OnBackdropClick() { if (!AllowClose) return; @@ -329,9 +397,10 @@ await OnSave.InvokeAsync(); } - private async Task Restore(string backupPath) + private async Task RestoreAll() { - await OnRestore.InvokeAsync(backupPath); + if (SaveResult == null || SaveResult.Backups.Count == 0) return; + await OnRestore.InvokeAsync(SaveResult.Backups); } private async Task CloseAndEndEdit() diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index 26324dc..ae85e7e 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -2,12 +2,19 @@ @page "/leases" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata +@using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions @inject IStatusClient StatusClient @inject IDhcpHostsClient DhcpHostsClient @inject ILeasesClient LeasesClient @inject IReloadClient ReloadClient @inject IClientSettingsService ClientSettingsService @inject ISettingsModalService SettingsModalService +@inject IEffectiveConfigPageEditor PageEditor +@inject IEffectiveConfigEditSession Session +@inject IStructuredOptionValueHandlerRegistry HandlerRegistry @inject IOptions AppOptions @implements IDisposable @@ -67,16 +74,17 @@ else - @foreach (var e in _visibleStaticEntries) + @foreach (var row in _visibleStaticHostRows) { + var e = row.Entry; var editable = IsEditable(e); - + @e.LineNumber @if (editable) { - + - + @if (_leases != null && _leases.Count > 0) { @@ -87,10 +95,10 @@ else } - - + + - + @foreach (var p in LeaseDatalistOptions) { @@ -98,10 +106,10 @@ else } - - + + — - + } else { @@ -121,10 +129,11 @@ else
- @foreach (var e in _visibleStaticEntries) + @foreach (var row in _visibleStaticHostRows) { + var e = row.Entry; var editable = IsEditable(e); -
+
# @e.LineNumber
@if (!string.IsNullOrEmpty(e.SourcePath)) @@ -139,7 +148,7 @@ else
@if (editable) { - + } else { @@ -152,7 +161,7 @@ else @if (editable) {
- + @if (_leases != null && _leases.Count > 0) { @@ -171,17 +180,17 @@ else
Name - @if (editable) { } else { @e.Name } + @if (editable) { } else { @e.Name }
Address - @if (editable) { } else { @e.Address } + @if (editable) { } else { @e.Address }
Lease @if (editable) { - + @foreach (var p in LeaseDatalistOptions) { @@ -197,17 +206,17 @@ else
Ignore
- @if (editable) { } else { @(e.Ignore ? "Yes" : "No") } + @if (editable) { } else { @(e.Ignore ? "Yes" : "No") }
Comment - @if (editable) { } else { @e.Comment } + @if (editable) { } else { @e.Comment }
@if (editable) {
- +
}
@@ -217,7 +226,6 @@ else
- @if (_status.ReloadCommandConfigured) { @@ -333,6 +341,7 @@ else private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; private List _entries = new(); + private List _staticValueStrings = new(); private IReadOnlyList? _leases; private string _filterKeyword = ""; private string _leaseFilterKeyword = ""; @@ -342,6 +351,17 @@ else private int _leasesRefreshIntervalSeconds = 5; private string? _message; private bool _saving; + private IStructuredOptionValueHandler _dhcpHostHandler = null!; + + private EffectiveConfigFieldRef DhcpHostField => PageEditor.Field(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpHost); + + private List<(int Index, string ValueString, DhcpHostEntry Entry)> _staticHostRows => + Enumerable.Range(0, Math.Min(_entries.Count, _staticValueStrings.Count)) + .Select(i => (i, _staticValueStrings[i], _entries[i])) + .ToList(); + + private IEnumerable<(int Index, string ValueString, DhcpHostEntry Entry)> _visibleStaticHostRows => + _staticHostRows.Where(x => !x.Entry.IsDeleted && StaticEntryMatchesKeyword(x.Entry, _filterKeyword)); private IEnumerable _visibleStaticEntries => _entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword)); @@ -375,6 +395,15 @@ else { SetMac(e, value); TryFillFromLeaseByMac(e, value); + SyncRowForEntry(e); + } + + private void SyncRowForEntry(DhcpHostEntry e) + { + var idx = _entries.IndexOf(e); + if (idx < 0 || idx >= _staticValueStrings.Count) return; + var row = (idx, _staticValueStrings[idx], e); + SyncRowToSession(row); } private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value) @@ -406,7 +435,15 @@ else protected override void OnInitialized() { + _dhcpHostHandler = HandlerRegistry.GetRequired(DnsmasqConfKeys.DhcpHost); SettingsModalService.SettingsChanged += OnSettingsChanged; + Session.Changed += OnSessionChanged; + } + + private void OnSessionChanged() + { + RefreshStaticHostsFromSession(); + _ = InvokeAsync(StateHasChanged); } protected override async Task OnAfterRenderAsync(bool firstRender) @@ -416,6 +453,20 @@ else StateHasChanged(); } + private void RefreshStaticHostsFromSession() + { + if (_status == null) return; + var values = PageEditor.GetEffectiveMultiValues(_status, DhcpHostField).ToList(); + _staticValueStrings = values; + _entries = values + .Select((v, i) => _dhcpHostHandler.TryParseValue(v, i + 1, out var parsed) && parsed != null + ? parsed + : new DhcpHostEntry { LineNumber = i + 1, IsComment = true }) + .ToList(); + foreach (var e in _entries) + e.IsEditable = true; + } + private async Task RefreshStatusAsync() { try @@ -435,8 +486,7 @@ else await ClientSettingsService.LoadSettingsAsync(); _leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value; _status = await StatusClient.GetStatusAsync(); - var entries = await DhcpHostsClient.GetDhcpHostsAsync(); - _entries = entries?.ToList() ?? new List(); + RefreshStaticHostsFromSession(); await LoadLeases(showLoading: true); if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null) StartLeasesRefreshTimer(); @@ -495,7 +545,7 @@ else private void Add() { var nextAddress = GetNextAvailableAddress(); - _entries.Add(new DhcpHostEntry + var entry = new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, @@ -504,7 +554,13 @@ else Name = "*", Address = nextAddress, IsEditable = true - }); + }; + if (_status != null) + { + PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler.SerializeValue(entry)); + PageEditor.Activate(DhcpHostField); + } + RefreshStaticHostsFromSession(); StateHasChanged(); } @@ -552,21 +608,35 @@ else return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}"; } - private void Delete(DhcpHostEntry e) + private void DeleteRow((int Index, string ValueString, DhcpHostEntry Entry) row) { - e.IsDeleted = true; + if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return; + var newList = _staticValueStrings.ToList(); + newList.RemoveAt(row.Index); + PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList); + RefreshStaticHostsFromSession(); StateHasChanged(); } - private void ToggleValid(DhcpHostEntry e) + private void ToggleValid((int Index, string ValueString, DhcpHostEntry Entry) row) { - e.IsComment = !e.IsComment; - StateHasChanged(); + row.Entry.IsComment = !row.Entry.IsComment; + SyncRowToSession(row); } - private void ToggleIgnore(DhcpHostEntry e) + private void ToggleIgnore((int Index, string ValueString, DhcpHostEntry Entry) row) { - e.Ignore = !e.Ignore; + row.Entry.Ignore = !row.Entry.Ignore; + SyncRowToSession(row); + } + + private void SyncRowToSession((int Index, string ValueString, DhcpHostEntry Entry) row) + { + if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return; + var newList = _staticValueStrings.ToList(); + newList[row.Index] = _dhcpHostHandler!.SerializeValue(row.Entry); + PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList); + RefreshStaticHostsFromSession(); StateHasChanged(); } @@ -588,7 +658,8 @@ else private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore) { - _entries.Add(new DhcpHostEntry + if (_status == null) return; + var entry = new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, @@ -601,36 +672,13 @@ else Lease = "infinite", Extra = new List(), Comment = null - }); + }; + PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler!.SerializeValue(entry)); + PageEditor.Activate(DhcpHostField); + RefreshStaticHostsFromSession(); StateHasChanged(); } - private async Task Save() - { - _saving = true; - _error = null; - _message = null; - try - { - var result = await DhcpHostsClient.SaveDhcpHostsAsync(_entries); - if (result.Reload.Success) - _message = "Saved and dnsmasq reloaded."; - else if (!string.IsNullOrEmpty(result.Reload.StdErr)) - _error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr; - else - _message = "Saved."; - await Load(); - } - catch (Exception ex) - { - _error = ex.Message; - } - finally - { - _saving = false; - } - } - private async Task Reload() { _saving = true; @@ -656,6 +704,7 @@ else public void Dispose() { + Session.Changed -= OnSessionChanged; SettingsModalService.SettingsChanged -= OnSettingsChanged; _leasesRefreshTimer?.Dispose(); _leasesRefreshTimer = null; diff --git a/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor index 758e6a5..0417843 100644 --- a/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor +++ b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor @@ -1,7 +1,11 @@ @page "/dns-records" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata @inject IStatusClient StatusClient +@inject IEffectiveConfigPageEditor PageEditor @inject IOptions AppOptions DNS records | @AppTitle @@ -20,6 +24,30 @@ else
+
+

Quick add

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ @if (_recordError != null) + { +

@_recordError

+ } +
All CNAME @@ -34,6 +62,80 @@ else @code { private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; + private string _cnameAlias = ""; + private string _cnameTarget = ""; + private string _hostRecordName = ""; + private string _hostRecordIp = ""; + private string _txtName = ""; + private string _txtValue = ""; + private string? _recordError; + + private static EffectiveConfigFieldRef CnameField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.Cname); + private static EffectiveConfigFieldRef HostRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.HostRecord); + private static EffectiveConfigFieldRef TxtRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.TxtRecord); + + private void AddCname() + { + _recordError = null; + if (string.IsNullOrWhiteSpace(_cnameAlias) || string.IsNullOrWhiteSpace(_cnameTarget)) + { + PageEditor.SetFieldIssues(CnameField(EffectiveConfigSections.SectionDnsRecords), [new FieldIssue(CnameField(EffectiveConfigSections.SectionDnsRecords).FieldKey, "Alias and target are required.", FieldIssueSeverity.Error, null)]); + _recordError = "Alias and target are required."; + return; + } + var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Cname); + PageEditor.ClearFieldIssues(field); + var current = PageEditor.GetEffectiveMultiValues(_status!, field); + var next = current.Append($"{_cnameAlias.Trim()},{_cnameTarget.Trim()}").ToList(); + PageEditor.ReplaceMultiValues(_status!, field, next); + PageEditor.Activate(field); + _cnameAlias = ""; + _cnameTarget = ""; + StateHasChanged(); + } + + private void AddHostRecord() + { + _recordError = null; + var hostField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.HostRecord); + if (string.IsNullOrWhiteSpace(_hostRecordName) || string.IsNullOrWhiteSpace(_hostRecordIp)) + { + PageEditor.SetFieldIssues(hostField, [new FieldIssue(hostField.FieldKey, "Host name and IP are required.", FieldIssueSeverity.Error, null)]); + _recordError = "Host name and IP are required."; + return; + } + var field = hostField; + PageEditor.ClearFieldIssues(field); + var current = PageEditor.GetEffectiveMultiValues(_status!, field); + var next = current.Append($"{_hostRecordName.Trim()},{_hostRecordIp.Trim()}").ToList(); + PageEditor.ReplaceMultiValues(_status!, field, next); + PageEditor.Activate(field); + _hostRecordName = ""; + _hostRecordIp = ""; + StateHasChanged(); + } + + private void AddTxtRecord() + { + _recordError = null; + var txtField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.TxtRecord); + if (string.IsNullOrWhiteSpace(_txtName)) + { + PageEditor.SetFieldIssues(txtField, [new FieldIssue(txtField.FieldKey, "Name is required.", FieldIssueSeverity.Error, null)]); + _recordError = "Name is required."; + return; + } + var field = txtField; + PageEditor.ClearFieldIssues(field); + var current = PageEditor.GetEffectiveMultiValues(_status!, field); + var value = string.IsNullOrWhiteSpace(_txtValue) ? _txtName.Trim() : $"{_txtName.Trim()},\"{_txtValue.Trim()}\""; + var next = current.Append(value).ToList(); + PageEditor.ReplaceMultiValues(_status!, field, next); + PageEditor.Activate(field); + _txtName = ""; + _txtValue = ""; + StateHasChanged(); + } protected override async Task OnInitializedAsync() { diff --git a/src/DnsmasqWebUI/Components/Pages/Filters.razor b/src/DnsmasqWebUI/Components/Pages/Filters.razor index d27d39f..4c28d5b 100644 --- a/src/DnsmasqWebUI/Components/Pages/Filters.razor +++ b/src/DnsmasqWebUI/Components/Pages/Filters.razor @@ -1,7 +1,11 @@ @page "/filters" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata @inject IStatusClient StatusClient +@inject IEffectiveConfigPageEditor PageEditor @inject IOptions AppOptions Filters | @AppTitle @@ -20,6 +24,28 @@ else
+
+

Quick add

+
+
+ + +
+
+ + + +
+
+ + +
+
+ @if (_filterError != null) + { +

@_filterError

+ } +
All Blocking / sinkhole @@ -33,6 +59,70 @@ else @code { private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; + private string _blockDomain = ""; + private string _splitDomain = ""; + private string _splitUpstream = ""; + private string _localDomain = ""; + private string? _filterError; + + private void BlockDomain() + { + _filterError = null; + var domain = _blockDomain?.Trim() ?? ""; + if (string.IsNullOrEmpty(domain)) + { + _filterError = "Enter a domain to block."; + return; + } + var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Address); + var current = PageEditor.GetEffectiveMultiValues(_status!, field); + var rule = $"/{domain}/#"; + var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList(); + PageEditor.ReplaceMultiValues(_status!, field, next); + PageEditor.Activate(field); + _blockDomain = ""; + StateHasChanged(); + } + + private void AddSplitDnsRoute() + { + _filterError = null; + var domain = _splitDomain?.Trim() ?? ""; + var upstream = _splitUpstream?.Trim() ?? ""; + if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(upstream)) + { + _filterError = "Domain and upstream are required."; + return; + } + var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server); + var current = PageEditor.GetEffectiveMultiValues(_status!, field); + var rule = $"/{domain}/{upstream}"; + var next = current.Append(rule).ToList(); + PageEditor.ReplaceMultiValues(_status!, field, next); + PageEditor.Activate(field); + _splitDomain = ""; + _splitUpstream = ""; + StateHasChanged(); + } + + private void AddLocalOnlyDomain() + { + _filterError = null; + var domain = _localDomain?.Trim() ?? ""; + if (string.IsNullOrEmpty(domain)) + { + _filterError = "Enter a domain."; + return; + } + var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Local); + var current = PageEditor.GetEffectiveMultiValues(_status!, field); + var rule = $"/{domain}/"; + var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList(); + PageEditor.ReplaceMultiValues(_status!, field, next); + PageEditor.Activate(field); + _localDomain = ""; + StateHasChanged(); + } protected override async Task OnInitializedAsync() { diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 513e28c..4458503 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -1,8 +1,11 @@ @page "/hosts" @rendermode InteractiveServer @namespace DnsmasqWebUI.Components.Pages +@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions @inject IStatusClient StatusClient @inject IHostsClient HostsClient +@inject IEffectiveConfigEditSession Session @inject IOptions AppOptions Hosts | @AppTitle @@ -38,13 +41,9 @@ else {
@_error
} - @if (_message != null) - { -
@_message
- } - +

Save changes via the toolbar above.

@if (_readOnlyHosts?.Count > 0) { @@ -60,10 +59,36 @@ else private string AppTitle => AppOptions!.Value!.EffectiveTitle; private DnsmasqServiceStatus? _status; private List _entries = new(); + private List _originalManagedEntries = new(); private IReadOnlyList? _readOnlyHosts; private string? _error; - private string? _message; - private bool _saving; + + private static List CloneEntries(IReadOnlyList entries) + { + return entries.Select(e => new HostEntry + { + LineNumber = e.LineNumber, + Id = e.Id, + Address = e.Address, + Names = e.Names != null ? new List(e.Names) : new List(), + RawLine = e.RawLine, + IsComment = e.IsComment, + IsPassthrough = e.IsPassthrough + }).ToList(); + } + + private void SyncManagedHostsDraft() + { + if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath)) + return; + var draft = CloneEntries(_entries); + var original = CloneEntries(_originalManagedEntries); + var pending = new PendingManagedHostsChange(original, draft, _status.ManagedHostsFilePath); + if (pending.HasChanges) + Session.TrackManagedHostsChange(pending); + else + Session.RevertManagedHostsChange(); + } /// Returns MarkupString so Blazor renders raw HTML (path + source label). private MarkupString RenderAddnHostsList() @@ -93,7 +118,7 @@ else { try { - _status = await StatusClient.GetStatusAsync(); + await Load(); await InvokeAsync(StateHasChanged); } catch { /* ignore */ } @@ -102,13 +127,14 @@ else private async Task Load() { _error = null; - _message = null; try { _status = await StatusClient.GetStatusAsync(); var entries = await HostsClient.GetHostsAsync(); _entries = entries?.ToList() ?? new List(); + _originalManagedEntries = CloneEntries(_entries); _readOnlyHosts = await HostsClient.GetReadOnlyHostsAsync(); + SyncManagedHostsDraft(); } catch (Exception ex) { @@ -116,29 +142,4 @@ else } } - private async Task Save() - { - _saving = true; - _error = null; - _message = null; - try - { - var result = await HostsClient.SaveHostsAsync(_entries); - if (result.Reload.Success) - _message = "Saved and dnsmasq reloaded."; - else if (!string.IsNullOrEmpty(result.Reload.StdErr)) - _error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr; - else - _message = "Saved."; - _status = await StatusClient.GetStatusAsync(); - } - catch (Exception ex) - { - _error = ex.Message; - } - finally - { - _saving = false; - } - } } diff --git a/src/DnsmasqWebUI/Controllers/DhcpController.cs b/src/DnsmasqWebUI/Controllers/DhcpController.cs index 4c9d024..4618db4 100644 --- a/src/DnsmasqWebUI/Controllers/DhcpController.cs +++ b/src/DnsmasqWebUI/Controllers/DhcpController.cs @@ -1,7 +1,5 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; -using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; using DnsmasqWebUI.Models.Dhcp; -using DnsmasqWebUI.Models.Dnsmasq; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -12,13 +10,11 @@ namespace DnsmasqWebUI.Controllers; public class DhcpController : ControllerBase { private readonly IDnsmasqConfigService _configService; - private readonly IReloadService _reloadService; private readonly ILogger _logger; - public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService, ILogger logger) + public DhcpController(IDnsmasqConfigService configService, ILogger logger) { _configService = configService; - _reloadService = reloadService; _logger = logger; } @@ -37,28 +33,4 @@ public class DhcpController : ControllerBase return StatusCode(500, new { error = ex.Message }); } } - - [HttpPut("hosts")] - public async Task> PutHosts([FromBody] List? entries, CancellationToken ct) - { - if (entries == null) - return BadRequest(new { error = "Body required" }); - try - { - await _configService.WriteDhcpHostsAsync(entries, ct); - var reload = await _reloadService.ReloadAsync(ct); - _logger.LogInformation("DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success); - return Ok(new SaveWithReloadResult(true, reload)); - } - catch (ArgumentException ex) - { - _logger.LogWarning(ex, "DHCP hosts put validation failed"); - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Put DHCP hosts failed"); - return StatusCode(500, new { error = ex.Message }); - } - } } diff --git a/src/DnsmasqWebUI/Controllers/HostsController.cs b/src/DnsmasqWebUI/Controllers/HostsController.cs index e14814d..81380a9 100644 --- a/src/DnsmasqWebUI/Controllers/HostsController.cs +++ b/src/DnsmasqWebUI/Controllers/HostsController.cs @@ -1,6 +1,4 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; -using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; -using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Hosts; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -12,14 +10,12 @@ namespace DnsmasqWebUI.Controllers; public class HostsController : ControllerBase { private readonly IHostsFileService _hostsService; - private readonly IReloadService _reloadService; private readonly IHostsCache _hostsCache; private readonly ILogger _logger; - public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache, ILogger logger) + public HostsController(IHostsFileService hostsService, IHostsCache hostsCache, ILogger logger) { _hostsService = hostsService; - _reloadService = reloadService; _hostsCache = hostsCache; _logger = logger; } @@ -56,28 +52,4 @@ public class HostsController : ControllerBase return StatusCode(500, new { error = ex.Message }); } } - - [HttpPut] - public async Task> Put([FromBody] List? entries, CancellationToken ct) - { - if (entries == null) - return BadRequest(new { error = "Body required" }); - try - { - await _hostsService.WriteAsync(entries, ct); - var reload = await _reloadService.ReloadAsync(ct); - _logger.LogInformation("Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success); - return Ok(new SaveWithReloadResult(true, reload)); - } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, "Hosts put validation failed"); - return BadRequest(new { error = ex.Message }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Put hosts failed"); - return StatusCode(500, new { error = ex.Message }); - } - } } diff --git a/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IDhcpHostsClient.cs b/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IDhcpHostsClient.cs index 6248efa..e0d96ee 100644 --- a/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IDhcpHostsClient.cs +++ b/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IDhcpHostsClient.cs @@ -4,12 +4,9 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Client.Abstractions; -/// Typed client for GET/PUT api/dhcp/hosts. +/// Typed client for GET api/dhcp/hosts. Save goes through effective-config save flow. public interface IDhcpHostsClient { /// Gets DHCP host entries from GET api/dhcp/hosts. Task> GetDhcpHostsAsync(CancellationToken ct = default); - - /// Writes DHCP host entries and triggers reload via PUT api/dhcp/hosts. - Task SaveDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs b/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs index 4495b74..09a4a09 100644 --- a/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs +++ b/src/DnsmasqWebUI/Infrastructure/Client/Abstractions/IHostsClient.cs @@ -4,7 +4,7 @@ using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Client.Abstractions; -/// Typed client for GET/PUT api/hosts. +/// Typed client for GET api/hosts. Save goes through effective-config save flow. public interface IHostsClient { /// Gets managed hosts file entries from GET api/hosts. @@ -12,7 +12,4 @@ public interface IHostsClient /// Gets read-only hosts files (system + addn-hosts) from GET api/hosts/readonly. Task> GetReadOnlyHostsAsync(CancellationToken ct = default); - - /// Writes managed hosts file and triggers reload via PUT api/hosts. - Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Client/DhcpHostsClient.cs b/src/DnsmasqWebUI/Infrastructure/Client/DhcpHostsClient.cs index 7471624..b60aae1 100644 --- a/src/DnsmasqWebUI/Infrastructure/Client/DhcpHostsClient.cs +++ b/src/DnsmasqWebUI/Infrastructure/Client/DhcpHostsClient.cs @@ -1,10 +1,7 @@ using System.Net.Http.Json; -using System.Text.Json.Serialization; using DnsmasqWebUI.Infrastructure.Client.Abstractions; using DnsmasqWebUI.Infrastructure.Helpers.Http; using DnsmasqWebUI.Models.Dhcp; -using DnsmasqWebUI.Models.Dnsmasq; -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Client; @@ -19,29 +16,4 @@ public sealed class DhcpHostsClient : IDhcpHostsClient var list = await _http.GetFromJsonAsync>("api/dhcp/hosts", ApiJsonOptions.ClientOptions, ct); return list ?? new List(); } - - public async Task SaveDhcpHostsAsync(IReadOnlyList entries, CancellationToken ct = default) - { - var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ApiJsonOptions.ClientOptions, ct); - if (!response.IsSuccessStatusCode) - { - var body = await response.Content.ReadAsStringAsync(ct); - var msg = response.ReasonPhrase ?? response.StatusCode.ToString(); - if (!string.IsNullOrEmpty(body) && body.TrimStart().StartsWith("{")) - { - try - { - var err = System.Text.Json.JsonSerializer.Deserialize(body, ApiJsonOptions.ClientOptions); - if (!string.IsNullOrEmpty(err?.Error)) - msg = err.Error; - } - catch { /* use msg as-is */ } - } - throw new HttpRequestException(msg); - } - return await response.Content.ReadFromJsonAsync(ApiJsonOptions.ClientOptions, ct) - ?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts."); - } - - private sealed class JsonError { [JsonPropertyName("error")] public string? Error { get; set; } } } diff --git a/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs b/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs index 54a6c18..ddd163a 100644 --- a/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs +++ b/src/DnsmasqWebUI/Infrastructure/Client/HostsClient.cs @@ -1,8 +1,6 @@ using DnsmasqWebUI.Infrastructure.Client.Abstractions; using DnsmasqWebUI.Infrastructure.Helpers.Http; using DnsmasqWebUI.Models.Hosts; -using DnsmasqWebUI.Models.Dnsmasq; -using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using System.Net.Http.Json; namespace DnsmasqWebUI.Infrastructure.Client; @@ -24,12 +22,4 @@ public sealed class HostsClient : IHostsClient var list = await _http.GetFromJsonAsync>("api/hosts/readonly", ApiJsonOptions.ClientOptions, ct); return list ?? new List(); } - - public async Task SaveHostsAsync(IReadOnlyList entries, CancellationToken ct = default) - { - var response = await _http.PutAsJsonAsync("api/hosts", entries, ApiJsonOptions.ClientOptions, ct); - response.EnsureSuccessStatusCode(); - return await response.Content.ReadFromJsonAsync(ApiJsonOptions.ClientOptions, ct) - ?? throw new InvalidOperationException("Unexpected null from api/hosts."); - } } diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Abstractions/IEffectiveConfigDirectiveSerializer.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Abstractions/IEffectiveConfigDirectiveSerializer.cs new file mode 100644 index 0000000..48c7924 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Abstractions/IEffectiveConfigDirectiveSerializer.cs @@ -0,0 +1,10 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Serialization.Abstractions; + +/// Serializes simple (non-structured) options to directive lines using . +public interface IEffectiveConfigDirectiveSerializer : IApplicationSingleton +{ + string SerializeSingle(string optionName, object? value); + IReadOnlyList SerializeMulti(string optionName, IReadOnlyList values); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/DnsmasqConfText.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/DnsmasqConfText.cs new file mode 100644 index 0000000..5e323eb --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/DnsmasqConfText.cs @@ -0,0 +1,37 @@ +namespace DnsmasqWebUI.Infrastructure.Serialization; + +/// +/// Shared dnsmasq directive text mechanics: key=, key=value, strip prefix, and match checks for option, #option=, ##option=. +/// Use with for option names. +/// +public static class DnsmasqConfText +{ + /// Returns the directive prefix for an option (e.g. "dhcp-host="). + public static string DirectivePrefix(string optionName) => $"{optionName}="; + + /// Returns a full directive line: option=value, or just option when value is null/empty. + public static string DirectiveLine(string optionName, string? value) => + string.IsNullOrEmpty(value) ? optionName : $"{optionName}={value}"; + + /// Strips the option= prefix from a line (option=, #option=, ##option=); preserves leading #/## so value round-trips. Returns the full line if it does not match. + public static string StripDirectivePrefix(string optionName, string line) + { + var prefix = DirectivePrefix(optionName); + if (line.StartsWith("##" + prefix, StringComparison.Ordinal)) + return "##" + line[(2 + prefix.Length)..]; + if (line.StartsWith("#" + prefix, StringComparison.Ordinal)) + return "#" + line[(1 + prefix.Length)..]; + if (line.StartsWith(prefix, StringComparison.Ordinal)) + return line[prefix.Length..]; + return line; + } + + /// True when the line is an option directive, optionally with # or ## prefix. + public static bool HasDirectivePrefix(string optionName, string line) + { + var prefix = DirectivePrefix(optionName); + return line.StartsWith(prefix, StringComparison.Ordinal) || + line.StartsWith("#" + prefix, StringComparison.Ordinal) || + line.StartsWith("##" + prefix, StringComparison.Ordinal); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/EffectiveConfigDirectiveSerializer.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/EffectiveConfigDirectiveSerializer.cs new file mode 100644 index 0000000..3a064f0 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/EffectiveConfigDirectiveSerializer.cs @@ -0,0 +1,54 @@ +using DnsmasqWebUI.Infrastructure.Serialization.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Serialization; + +/// Serializes simple options to directive lines using and . +public sealed class EffectiveConfigDirectiveSerializer : IEffectiveConfigDirectiveSerializer +{ + /// + public string SerializeSingle(string optionName, object? value) + { + return EffectiveConfigWriteSemantics.GetBehavior(optionName) switch + { + EffectiveConfigWriteBehavior.Flag => + (value is bool b && b) ? optionName : "", + EffectiveConfigWriteBehavior.SingleValue => + DnsmasqConfText.DirectiveLine(optionName, value?.ToString()), + EffectiveConfigWriteBehavior.KeyOnlyOrValue => + string.IsNullOrWhiteSpace(value?.ToString()) + ? optionName + : DnsmasqConfText.DirectiveLine(optionName, value?.ToString()), + EffectiveConfigWriteBehavior.InversePair => + SerializeInversePair(optionName, value), + _ => throw new InvalidOperationException($"Unsupported single-value write behavior for '{optionName}'.") + }; + } + + /// + public IReadOnlyList SerializeMulti(string optionName, IReadOnlyList values) + { + return EffectiveConfigWriteSemantics.GetBehavior(optionName) switch + { + EffectiveConfigWriteBehavior.MultiValue => + values.Select(v => DnsmasqConfText.DirectiveLine(optionName, v)).ToList(), + EffectiveConfigWriteBehavior.MultiKeyOnlyOrValue => + values.Select(v => string.IsNullOrWhiteSpace(v) ? optionName : DnsmasqConfText.DirectiveLine(optionName, v)).ToList(), + _ => throw new InvalidOperationException($"Unsupported multi-value write behavior for '{optionName}'.") + }; + } + + private static string SerializeInversePair(string optionName, object? value) + { + var pair = EffectiveConfigWriteSemantics.GetInversePairKeys(optionName); + if (pair is null || value is not ExplicitToggleState state) + return ""; + return state switch + { + ExplicitToggleState.Enabled => pair.Value.KeyA, + ExplicitToggleState.Disabled => pair.Value.KeyB, + _ => "" + }; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandler.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandler.cs new file mode 100644 index 0000000..fb7fb35 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandler.cs @@ -0,0 +1,12 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; + +/// +/// Base contract for structured dnsmasq option value handlers. Keyed by (use ). +/// +public interface IStructuredOptionValueHandler : IApplicationMultiSingleton +{ + string OptionName { get; } + Type ValueType { get; } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandlerOfT.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandlerOfT.cs new file mode 100644 index 0000000..7cb5959 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandlerOfT.cs @@ -0,0 +1,12 @@ +using DnsmasqWebUI.Infrastructure.Services.Common.Serialization.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; + +/// +/// Typed structured option handler: serialize/parse value and full directive line. Keyed by . +/// +public interface IStructuredOptionValueHandler : IStructuredOptionValueHandler, IValueSerializer, IValueParser +{ + /// Full directive line (option=value) for the given value. + string SerializeLine(T value); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandlerRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandlerRegistry.cs new file mode 100644 index 0000000..d66f2cb --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/Abstractions/IStructuredOptionValueHandlerRegistry.cs @@ -0,0 +1,12 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; + +namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; + +/// Lookup for structured option value handlers by option name (use ). +public interface IStructuredOptionValueHandlerRegistry : IApplicationSingleton +{ + bool IsStructured(string optionName); + IStructuredOptionValueHandler? Get(string optionName); + IStructuredOptionValueHandler? Get(string optionName); + IStructuredOptionValueHandler GetRequired(string optionName); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/DhcpHostOptionValueHandler.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/DhcpHostOptionValueHandler.cs new file mode 100644 index 0000000..d1f5482 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/DhcpHostOptionValueHandler.cs @@ -0,0 +1,37 @@ +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; +using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dhcp; + +namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers; + +/// Canonical typed handler for dhcp-host= values. Replaces ad-hoc mapper; uses and . +public sealed class DhcpHostOptionValueHandler : IStructuredOptionValueHandler +{ + /// + public string OptionName => DnsmasqConfKeys.DhcpHost; + + /// + public Type ValueType => typeof(DhcpHostEntry); + + /// + public string SerializeLine(DhcpHostEntry value) => + DnsmasqConfDhcpHostLineParser.ToLine(value); + + /// + public string SerializeValue(DhcpHostEntry value) + { + var line = SerializeLine(value); + var valuePart = DnsmasqConfText.StripDirectivePrefix(OptionName, line); + return (valuePart is "#" or "##") ? "" : valuePart; + } + + /// + public bool TryParseValue(string text, int lineNumber, out DhcpHostEntry? value) + { + value = DnsmasqConfDhcpHostLineParser.ParseLine( + DnsmasqConfText.DirectiveLine(OptionName, text), + lineNumber); + return value is not null; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/StructuredOptionValueHandlerRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/StructuredOptionValueHandlerRegistry.cs new file mode 100644 index 0000000..00dcd7f --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/OptionHandlers/StructuredOptionValueHandlerRegistry.cs @@ -0,0 +1,107 @@ +using System.Reflection; +using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; + +namespace DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers; + +/// +/// Registry of structured option handlers. Validates handler option names and semantics metadata at construction time +/// so runtime resolution is fail-fast and consistent. +/// +public sealed class StructuredOptionValueHandlerRegistry : IStructuredOptionValueHandlerRegistry +{ + private readonly IReadOnlyDictionary _byOption; + + public StructuredOptionValueHandlerRegistry(IEnumerable handlers) + { + var handlerList = (handlers ?? Array.Empty()).ToList(); + var validOptionNames = GetDnsmasqConfKeyValues(); + + foreach (var handler in handlerList) + { + if (!validOptionNames.Contains(handler.OptionName)) + throw new InvalidOperationException( + $"Structured handler '{handler.GetType().Name}' uses unknown option name '{handler.OptionName}'. Use DnsmasqConfKeys."); + } + + try + { + _byOption = handlerList.ToDictionary(h => h.OptionName, StringComparer.Ordinal); + } + catch (ArgumentException ex) + { + throw new InvalidOperationException("Duplicate structured option handlers registered for the same option name.", ex); + } + + ValidateSemanticsAlignment(_byOption); + } + + /// + public bool IsStructured(string optionName) => + _byOption.ContainsKey(optionName); + + /// + public IStructuredOptionValueHandler? Get(string optionName) => + _byOption.TryGetValue(optionName, out var handler) ? handler : null; + + /// + public IStructuredOptionValueHandler? Get(string optionName) => + Get(optionName) as Abstractions.IStructuredOptionValueHandler; + + /// + public IStructuredOptionValueHandler GetRequired(string optionName) + { + var handler = Get(optionName); + if (handler != null) + return handler; + + var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(optionName); + throw new InvalidOperationException( + structuredType is null + ? $"Option '{optionName}' is not declared as a structured option." + : $"No structured handler registered for option '{optionName}' with value type '{typeof(T).Name}'. Declared type is '{structuredType.Name}'."); + } + + private static void ValidateSemanticsAlignment(IReadOnlyDictionary handlers) + { + foreach (var (optionName, handler) in handlers) + { + var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(optionName); + if (structuredType is null) + { + throw new InvalidOperationException( + $"Structured handler '{handler.GetType().Name}' is registered for '{optionName}', but semantics has no StructuredValueType."); + } + + if (handler.ValueType != structuredType) + { + throw new InvalidOperationException( + $"Structured handler type mismatch for '{optionName}': handler type '{handler.ValueType.Name}' does not match semantics type '{structuredType.Name}'."); + } + } + + foreach (var optionName in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames()) + { + var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(optionName); + if (structuredType is null) + continue; + + if (!handlers.ContainsKey(optionName)) + { + throw new InvalidOperationException( + $"Semantics declares structured option '{optionName}' with type '{structuredType.Name}', but no structured handler is registered."); + } + } + } + + private static HashSet GetDnsmasqConfKeyValues() + { + var values = new HashSet(StringComparer.Ordinal); + foreach (var field in typeof(DnsmasqConfKeys).GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (field.FieldType == typeof(string) && field.GetValue(null) is string value) + values.Add(value); + } + return values; + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfDhcpHostLineParser.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfDhcpHostLineParser.cs index bf2adc1..eb60225 100644 --- a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfDhcpHostLineParser.cs +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfDhcpHostLineParser.cs @@ -1,3 +1,5 @@ +using DnsmasqWebUI.Infrastructure.Serialization; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Models.Dhcp; using Superpower; using Superpower.Model; @@ -9,9 +11,12 @@ namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig; /// Parses a single dhcp-host= line from a dnsmasq .conf file. Same format as long option without --. /// Official format: --dhcp-host=[hwaddr][,id:client_id|*][,set:tag][,tag:tag][,ipaddr][,hostname][,lease_time][,ignore] /// Multiple hwaddr allowed (one IP for several MACs). See: https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html +/// Directive text from and . /// public static class DnsmasqConfDhcpHostLineParser { + private static readonly string OptionName = DnsmasqConfKeys.DhcpHost; + private static readonly string DirectivePrefix = DnsmasqConfText.DirectivePrefix(OptionName); // Hostname: letter then (letter/digit/_/-)*. Full string. private static readonly TextParser HostnameParser = Character.Letter.Then(first => (Character.LetterOrDigit.Or(Character.In('_', '-')).Many().Text()) @@ -47,7 +52,7 @@ public static class DnsmasqConfDhcpHostLineParser // Literal "dhcp-host=" (consumed, value discarded) private static readonly TextParser DhcpHostTag = - ConfParserHelpers.Token(Span.EqualTo("dhcp-host=")).Value(Unit.Value); + ConfParserHelpers.Token(Span.EqualTo(DirectivePrefix)).Value(Unit.Value); // One field: no comma, no # (stops at next comma or trailing comment) private static readonly TextParser Field = @@ -61,19 +66,24 @@ public static class DnsmasqConfDhcpHostLineParser .Select(s => (string?)s).OptionalOrDefault(null) select (fields.ToList(), string.IsNullOrEmpty(comment) ? null : comment.Trim()); - // Full line: optional ##/# prefix, "dhcp-host=", comma-separated fields, optional # comment + // Optional ## or # at start of value (after "dhcp-host=") for value-level comment/deleted (e.g. "dhcp-host=#mac,addr") + private static readonly TextParser<(bool isComment, bool isDeleted)> ValueLevelPrefix = + Character.EqualTo('#').Repeat(2).Select(_ => (true, true)).Try() + .Or(Character.EqualTo('#').Select(_ => (true, false))) + .OptionalOrDefault((false, false)); + + // Full line: optional ##/# prefix, "dhcp-host=", optional value-level ##/#, comma-separated fields, optional # comment private static readonly TextParser<(bool isComment, bool isDeleted, List fields, string? comment)> LineParser = from prefix in Prefix from _ in DhcpHostTag + from valuePrefix in ValueLevelPrefix from fc in FieldsAndComment - select (prefix.isComment, prefix.isDeleted, fc.fields, fc.comment); + select (prefix.isComment || valuePrefix.isComment, prefix.isDeleted || valuePrefix.isDeleted, fc.fields, fc.comment); public static DhcpHostEntry? ParseLine(string line, int lineNumber) { var remain = line.Trim(); - if (!remain.StartsWith("dhcp-host=", StringComparison.Ordinal) && - !remain.StartsWith("#dhcp-host=", StringComparison.Ordinal) && - !remain.StartsWith("##dhcp-host=", StringComparison.Ordinal)) + if (!DnsmasqConfText.HasDirectivePrefix(OptionName, remain)) return null; var result = LineParser.TryParse(remain); @@ -143,7 +153,7 @@ public static class DnsmasqConfDhcpHostLineParser if (h.Ignore) parts.Add("ignore"); - var line = prefix + "dhcp-host=" + string.Join(", ", parts); + var line = prefix + DnsmasqConfText.DirectivePrefix(OptionName) + string.Join(", ", parts); if (!string.IsNullOrEmpty(h.Comment)) line += " # " + h.Comment; return line; diff --git a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfFileLineParser.cs b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfFileLineParser.cs index ed4bf0e..576e871 100644 --- a/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfFileLineParser.cs +++ b/src/DnsmasqWebUI/Infrastructure/Serialization/Parsers/DnsmasqConfig/DnsmasqConfFileLineParser.cs @@ -1,3 +1,5 @@ +using DnsmasqWebUI.Infrastructure.Serialization; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using Superpower; @@ -13,6 +15,8 @@ namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig; /// public static class DnsmasqConfFileLineParser { + private static readonly string DhcpHostPrefix = DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.DhcpHost); + private enum ConfLineParseKind { Blank, Comment, AddnHosts, DhcpHostCandidate, Other } private static readonly TextParser<(ConfLineParseKind kind, string content)> Blank = @@ -23,12 +27,12 @@ public static class DnsmasqConfFileLineParser .Select(_ => (ConfLineParseKind.Comment, "")).Named("comment"); private static readonly TextParser<(ConfLineParseKind kind, string content)> AddnHosts = - ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("addn-hosts=")) + ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo(DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.AddnHosts))) .IgnoreThen(Character.AnyChar.Many().Text()) .Select(s => (ConfLineParseKind.AddnHosts, s.Trim())).Named("addn-hosts line"); private static readonly TextParser<(ConfLineParseKind kind, string content)> DhcpHostCandidate = - ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo("dhcp-host=")) + ConfParserHelpers.OptionalCommentPrefix.IgnoreThen(Span.EqualTo(DhcpHostPrefix)) .IgnoreThen(Character.AnyChar.Many()) .Select(_ => (ConfLineParseKind.DhcpHostCandidate, "")).Named("dhcp-host line"); @@ -96,7 +100,7 @@ public static class DnsmasqConfFileLineParser { BlankLine b => b.RawLine.Length > 0 ? b.RawLine : "", CommentLine c => c.RawLine, - AddnHostsLine a => "addn-hosts=" + a.AddnHostsPath, + AddnHostsLine a => DnsmasqConfText.DirectiveLine(DnsmasqConfKeys.AddnHosts, a.AddnHostsPath), DhcpHostLine d => DnsmasqConfDhcpHostLineParser.ToLine(d.DhcpHost), OtherLine o => o.RawLine, _ => "" diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Serialization/Abstractions/IValueParser.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Serialization/Abstractions/IValueParser.cs new file mode 100644 index 0000000..e1d4db2 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Serialization/Abstractions/IValueParser.cs @@ -0,0 +1,7 @@ +namespace DnsmasqWebUI.Infrastructure.Services.Common.Serialization.Abstractions; + +/// Generic contract to parse a value from text (e.g. for line-number context in errors). +public interface IValueParser +{ + bool TryParseValue(string text, int lineNumber, out T? value); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Common/Serialization/Abstractions/IValueSerializer.cs b/src/DnsmasqWebUI/Infrastructure/Services/Common/Serialization/Abstractions/IValueSerializer.cs new file mode 100644 index 0000000..9f85483 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Common/Serialization/Abstractions/IValueSerializer.cs @@ -0,0 +1,7 @@ +namespace DnsmasqWebUI.Infrastructure.Services.Common.Serialization.Abstractions; + +/// Generic contract to serialize a value to a string. +public interface IValueSerializer +{ + string SerializeValue(T value); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs index d4c5c89..27568f9 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Config/DnsmasqConfigService.cs @@ -1,8 +1,10 @@ using System.Text; using DnsmasqWebUI.Infrastructure.Helpers.Config; -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Serialization; +using DnsmasqWebUI.Infrastructure.Serialization.Abstractions; using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Contracts; using DnsmasqWebUI.Models.Dhcp; @@ -16,12 +18,18 @@ public class DnsmasqConfigService : IDnsmasqConfigService { private readonly IDnsmasqConfigSetService _configSetService; private readonly IConfigSetCache _configSetCache; + private readonly IEffectiveConfigDirectiveSerializer _directiveSerializer; private readonly ILogger _logger; - public DnsmasqConfigService(IDnsmasqConfigSetService configSetService, IConfigSetCache configSetCache, ILogger logger) + public DnsmasqConfigService( + IDnsmasqConfigSetService configSetService, + IConfigSetCache configSetCache, + ILogger logger, + IEffectiveConfigDirectiveSerializer? directiveSerializer = null) { _configSetService = configSetService; _configSetCache = configSetCache; + _directiveSerializer = directiveSerializer ?? new EffectiveConfigDirectiveSerializer(); _logger = logger; } @@ -104,13 +112,21 @@ public class DnsmasqConfigService : IDnsmasqConfigService if (matchingIndices.Count == 0) { - configLines.Insert(0, lineFactory?.Invoke(1) ?? new OtherLine { LineNumber = 1, RawLine = $"{key}={value}" }); + configLines.Insert(0, lineFactory?.Invoke(1) ?? new OtherLine + { + LineNumber = 1, + RawLine = DnsmasqConfText.DirectiveLine(key, value) + }); return; } var keepIdx = matchingIndices[0]; var keepLineNumber = configLines[keepIdx].LineNumber; - configLines[keepIdx] = lineFactory?.Invoke(keepLineNumber) ?? new OtherLine { LineNumber = keepLineNumber, RawLine = $"{key}={value}" }; + configLines[keepIdx] = lineFactory?.Invoke(keepLineNumber) ?? new OtherLine + { + LineNumber = keepLineNumber, + RawLine = DnsmasqConfText.DirectiveLine(key, value) + }; for (var i = matchingIndices.Count - 1; i >= 1; i--) configLines.RemoveAt(matchingIndices[i]); } @@ -248,7 +264,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService var value = c.NewValue as string; if (value is null) continue; - var lineText = value.Length == 0 ? confKey : $"{confKey}={value.Trim()}"; + var lineText = _directiveSerializer.SerializeSingle(confKey, value.Trim()); list.Add(new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText }); maxLineNumber++; continue; @@ -259,9 +275,8 @@ public class DnsmasqConfigService : IDnsmasqConfigService IReadOnlyList readonlyValues = readonlyByOption.TryGetValue(confKey, out var listValues) ? listValues : Array.Empty(); var valuesToWrite = FilterManagedOnly(multiKeyOnlyValues, readonlyValues); RemoveAllMatchingLines(list, MatchesOption); - foreach (var v in valuesToWrite) + foreach (var raw in _directiveSerializer.SerializeMulti(confKey, valuesToWrite.Select(v => v.Trim()).ToList())) { - var raw = string.IsNullOrEmpty(v) ? confKey : $"{confKey}={v.Trim()}"; list.Add(new OtherLine { LineNumber = ++maxLineNumber, RawLine = raw }); } continue; @@ -303,11 +318,10 @@ public class DnsmasqConfigService : IDnsmasqConfigService for (var i = matchingIndices.Count - 1; i >= 0; i--) list.RemoveAt(matchingIndices[i]); var insertIdx = matchingIndices.Count > 0 ? matchingIndices[0] : list.Count; - for (var i = 0; i < valuesToWrite.Count; i++) + var serialized = _directiveSerializer.SerializeMulti(confKey, valuesToWrite.Select(v => v.Trim()).ToList()); + for (var i = 0; i < serialized.Count; i++) { - var lineKey = confKey; - var lineText = string.IsNullOrEmpty(valuesToWrite[i]) ? lineKey : lineKey + "=" + valuesToWrite[i]; - var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = lineText }; + var lineObj = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = serialized[i] }; maxLineNumber++; list.Insert(insertIdx + i, lineObj); } @@ -320,23 +334,18 @@ public class DnsmasqConfigService : IDnsmasqConfigService if (idx >= 0) list.RemoveAt(idx); continue; } - if (!isFlag) + if (writeBehavior == EffectiveConfigWriteBehavior.SingleValue && + string.IsNullOrWhiteSpace(c.NewValue?.ToString())) { - var v = ToConfValue(c.NewValue); - if (string.IsNullOrWhiteSpace(v)) - { - // Non-flag option with no value: remove existing line if any; never write key-only (malformed). - if (idx >= 0) list.RemoveAt(idx); - continue; - } + if (idx >= 0) list.RemoveAt(idx); + continue; } - string rawLine; - if (isFlag) - rawLine = confKey; - else + + var rawLine = _directiveSerializer.SerializeSingle(confKey, c.NewValue); + if (string.IsNullOrWhiteSpace(rawLine)) { - var v = ToConfValue(c.NewValue).Trim(); - rawLine = confKey + "=" + v; + if (idx >= 0) list.RemoveAt(idx); + continue; } var newLine = new OtherLine { LineNumber = maxLineNumber + 1, RawLine = rawLine }; maxLineNumber++; @@ -434,15 +443,6 @@ public class DnsmasqConfigService : IDnsmasqConfigService return false; } - private static string ToConfValue(object? value) - { - 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() ?? ""; - } - public async Task WriteManagedConfigAsync(IReadOnlyList lines, CancellationToken ct = default) { var set = await _configSetService.GetConfigSetAsync(ct); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigDescriptorProvider.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigDescriptorProvider.cs new file mode 100644 index 0000000..6a690ef --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigDescriptorProvider.cs @@ -0,0 +1,23 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +/// +/// Canonical API for descriptor lookup and filtering. Scoped so UI and PageEditor share the same descriptor graph per status snapshot. +/// +public interface IEffectiveConfigDescriptorProvider : IApplicationScopedService +{ + /// All field descriptors for the given status (registry-built; may be cached per status for the scope). + IReadOnlyList GetAll(DnsmasqServiceStatus status); + + /// Descriptors grouped by section, filtered by the given views (section + optional option allow-list). + IReadOnlyDictionary> GetBySection( + DnsmasqServiceStatus status, + IReadOnlyList views); + + /// Resolves a single descriptor by field ref; null if not found. + EffectiveConfigFieldDescriptor? Resolve(DnsmasqServiceStatus status, EffectiveConfigFieldRef field); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs index b41f13f..8a14d69 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigEditSession.cs @@ -9,9 +9,12 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; /// public interface IEffectiveConfigEditSession : IApplicationScopedService { + /// Raised when session state changes so UI (section and external widgets) can refresh. + event Action? Changed; + bool IsEditMode { get; } string? ActiveFieldKey { get; } - IReadOnlyList PendingChanges { get; } + IReadOnlyList PendingChanges { get; } /// Per-field validation issues (errors block save; warnings can be confirmed). IReadOnlyDictionary> FieldIssues { get; } @@ -23,6 +26,8 @@ public interface IEffectiveConfigEditSession : IApplicationScopedService void TrackCommit(EffectiveConfigEditCommittedArgs args); void RevertChange(string sectionId, string optionName); + void TrackManagedHostsChange(PendingManagedHostsChange change); + void RevertManagedHostsChange(); void SetFieldIssues(string fieldKey, IReadOnlyList issues); void ClearFieldIssues(string fieldKey); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigFieldBuilder.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigFieldBuilder.cs new file mode 100644 index 0000000..7b68e75 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigFieldBuilder.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +/// +/// Builds the full effective-config descriptor graph for a status using the render-fragment registry. +/// Registered via DI so registry-backed building stays explicit and testable. +/// +public interface IEffectiveConfigFieldBuilder : IApplicationSingleton +{ + /// Builds all field descriptors for the given status. + IReadOnlyList BuildFieldDescriptors(DnsmasqServiceStatus status); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigPageEditor.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigPageEditor.cs new file mode 100644 index 0000000..9076d50 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigPageEditor.cs @@ -0,0 +1,30 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +/// +/// Page-facing orchestrator for specialized effective-config UI. Resolves descriptors from status, +/// overlays pending changes, and writes through the shared edit session so the section and widgets stay in sync. +/// +public interface IEffectiveConfigPageEditor : IApplicationScopedService +{ + void EnsureEditMode(); + + EffectiveConfigFieldRef Field(string sectionId, string optionName); + + object? GetEffectiveValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field); + IReadOnlyList GetEffectiveMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field); + + void SetSingleValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, object? newValue); + void ReplaceMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, IReadOnlyList newValues); + void AppendMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string newValue); + void RemoveMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string valueToRemove); + + void Revert(EffectiveConfigFieldRef field); + void SetFieldIssues(EffectiveConfigFieldRef field, IReadOnlyList issues); + void ClearFieldIssues(EffectiveConfigFieldRef field); + void RefreshCrossOptionIssues(DnsmasqServiceStatus status); + void Activate(EffectiveConfigFieldRef field); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs index 403fe3b..61d2c75 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveConfigSaveService.cs @@ -3,14 +3,14 @@ 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. +/// Orchestrates effective-config save: backup managed config and/or managed hosts, write, validate, reload; and restore from backups. 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. + /// Creates timestamped backups for each target that exists, applies option and managed-hosts changes, then validates and reloads. Returns structured result for UI including backups for restore. Task SaveAsync( - IReadOnlyList changes, + 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); + /// Overwrites each target file with its backup, then runs reload. Returns structured result for UI. + Task RestoreAsync(IReadOnlyList backups, CancellationToken ct = default); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigDescriptorProvider.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigDescriptorProvider.cs new file mode 100644 index 0000000..99e4464 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigDescriptorProvider.cs @@ -0,0 +1,72 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; + +/// +/// Canonical descriptor lookup/filter; uses the DI-managed field builder and caches per status reference +/// so UI and PageEditor share the same graph. +/// +public sealed class EffectiveConfigDescriptorProvider : IEffectiveConfigDescriptorProvider +{ + private readonly IEffectiveConfigFieldBuilder _fieldBuilder; + private DnsmasqServiceStatus? _cachedStatus; + private IReadOnlyList? _cachedList; + + public EffectiveConfigDescriptorProvider(IEffectiveConfigFieldBuilder fieldBuilder) + { + _fieldBuilder = fieldBuilder ?? throw new ArgumentNullException(nameof(fieldBuilder)); + } + + /// + public IReadOnlyList GetAll(DnsmasqServiceStatus status) + { + if (status == null) + throw new ArgumentNullException(nameof(status)); + if (ReferenceEquals(_cachedStatus, status) && _cachedList != null) + return _cachedList; + _cachedStatus = status; + _cachedList = _fieldBuilder.BuildFieldDescriptors(status); + return _cachedList; + } + + /// + public IReadOnlyDictionary> GetBySection( + DnsmasqServiceStatus status, + IReadOnlyList views) + { + if (status == null) + throw new ArgumentNullException(nameof(status)); + if (views == null || views.Count == 0) + return new Dictionary>(StringComparer.OrdinalIgnoreCase); + + var all = GetAll(status); + var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var view in views) + { + var list = all + .Where(d => string.Equals(d.SectionId, view.SectionId, StringComparison.OrdinalIgnoreCase)) + .Where(d => view.AllowedOptionNames == null + || view.AllowedOptionNames.Contains(d.OptionName, StringComparer.OrdinalIgnoreCase)) + .ToList(); + if (list.Count > 0) + result[view.SectionId] = list; + } + + return result.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value, StringComparer.OrdinalIgnoreCase); + } + + /// + public EffectiveConfigFieldDescriptor? Resolve(DnsmasqServiceStatus status, EffectiveConfigFieldRef field) + { + if (status == null) + throw new ArgumentNullException(nameof(status)); + var all = GetAll(status); + return all.FirstOrDefault(d => + string.Equals(d.SectionId, field.SectionId, StringComparison.OrdinalIgnoreCase) && + string.Equals(d.OptionName, field.OptionName, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs index dd694a2..5f87c98 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigEditSession.cs @@ -6,7 +6,7 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession { private readonly IEffectiveConfigSaveService _saveService; - private readonly List _pending = new(); + private readonly List _pending = new(); private readonly Dictionary> _fieldIssues = new(StringComparer.OrdinalIgnoreCase); private readonly List _crossOptionIssues = new(); @@ -15,9 +15,13 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession _saveService = saveService; } + public event Action? Changed; + + private void NotifyChanged() => Changed?.Invoke(); + public bool IsEditMode { get; private set; } public string? ActiveFieldKey { get; private set; } - public IReadOnlyList PendingChanges => _pending; + public IReadOnlyList PendingChanges => _pending; public IReadOnlyDictionary> FieldIssues { get @@ -42,6 +46,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession _pending.Clear(); _fieldIssues.Clear(); _crossOptionIssues.Clear(); + NotifyChanged(); } public void ExitEditModeDiscard() @@ -51,29 +56,34 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession _crossOptionIssues.Clear(); ActiveFieldKey = null; IsEditMode = false; + NotifyChanged(); } public void ActivateField(string fieldKey) { IsEditMode = true; ActiveFieldKey = fieldKey; + NotifyChanged(); } public void DeactivateField() { ActiveFieldKey = null; + NotifyChanged(); } public void SetFieldIssues(string fieldKey, IReadOnlyList issues) { if (string.IsNullOrEmpty(fieldKey)) return; _fieldIssues[fieldKey] = issues.ToList(); + NotifyChanged(); } public void ClearFieldIssues(string fieldKey) { if (string.IsNullOrEmpty(fieldKey)) return; _fieldIssues.Remove(fieldKey); + NotifyChanged(); } public void SetCrossOptionIssues(IReadOnlyList issues) @@ -81,6 +91,7 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession _crossOptionIssues.Clear(); if (issues != null) _crossOptionIssues.AddRange(issues); + NotifyChanged(); } public bool HasBlockingValidationErrors() @@ -99,23 +110,32 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession public void TrackCommit(EffectiveConfigEditCommittedArgs args) { - var existing = _pending.FirstOrDefault(c => - c.SectionId == args.SectionId && c.OptionName == args.OptionName); - if (existing != null) - _pending.Remove(existing); + _pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == args.SectionId && o.OptionName == args.OptionName); if (!ValuesEqual(args.OldValue, args.NewValue)) - _pending.Add(new PendingEffectiveConfigChange( + _pending.Add(new PendingOptionChange( args.SectionId, args.OptionName, args.OldValue, args.NewValue, args.CurrentSourceFilePath)); ActiveFieldKey = null; + NotifyChanged(); } public void RevertChange(string sectionId, string optionName) { - var existing = _pending.FirstOrDefault(c => - c.SectionId == sectionId && c.OptionName == optionName); - if (existing != null) - _pending.Remove(existing); + _pending.RemoveAll(c => c is PendingOptionChange o && o.SectionId == sectionId && o.OptionName == optionName); ClearFieldIssues($"{sectionId}:{optionName}"); + NotifyChanged(); + } + + public void TrackManagedHostsChange(PendingManagedHostsChange change) + { + _pending.RemoveAll(c => c is PendingManagedHostsChange); + _pending.Add(change); + NotifyChanged(); + } + + public void RevertManagedHostsChange() + { + _pending.RemoveAll(c => c is PendingManagedHostsChange); + NotifyChanged(); } public async Task ApplyAsync(CancellationToken ct = default) @@ -126,6 +146,8 @@ public sealed class EffectiveConfigEditSession : IEffectiveConfigEditSession var result = await _saveService.SaveAsync(_pending.ToList(), ct); if (result.Saved && result.Restarted) ExitEditModeDiscard(); + else + NotifyChanged(); return result; } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs new file mode 100644 index 0000000..4b83304 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigPageEditor.cs @@ -0,0 +1,139 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation; +using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; + +/// +/// Page-facing orchestrator: resolves descriptors via the shared provider, overlays pending changes, +/// and writes through the shared edit session. Runs cross-option evaluation after mutations. +/// +public sealed class EffectiveConfigPageEditor : IEffectiveConfigPageEditor +{ + private readonly IEffectiveConfigEditSession _session; + private readonly IEffectiveConfigDescriptorProvider _descriptorProvider; + + public EffectiveConfigPageEditor(IEffectiveConfigEditSession session, IEffectiveConfigDescriptorProvider descriptorProvider) + { + _session = session; + _descriptorProvider = descriptorProvider ?? throw new ArgumentNullException(nameof(descriptorProvider)); + } + + public void EnsureEditMode() + { + if (!_session.IsEditMode) + _session.EnterEditMode(); + } + + public EffectiveConfigFieldRef Field(string sectionId, string optionName) + => EffectiveConfigFieldRef.For(sectionId, optionName); + + public object? GetEffectiveValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field) + { + var descriptor = ResolveDescriptor(status, field); + if (descriptor == null) return null; + + var pending = GetPendingOptionChange(field); + var fromDescriptor = descriptor.IsMultiValue + ? (object?)(descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List()) + : descriptor.GetValue(); + return pending != null ? pending.NewValue : fromDescriptor; + } + + public IReadOnlyList GetEffectiveMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field) + { + var descriptor = ResolveDescriptor(status, field); + if (descriptor == null) return new List(); + + var pending = GetPendingOptionChange(field); + if (pending?.NewValue is IReadOnlyList list) + return list; + return descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List(); + } + + public void SetSingleValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, object? newValue) + { + EnsureEditMode(); + var descriptor = ResolveDescriptor(status, field); + if (descriptor == null) return; + + var pending = GetPendingOptionChange(field); + var baseValue = descriptor.IsMultiValue + ? (object?)(descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List()) + : descriptor.GetValue(); + var oldValue = pending?.OldValue ?? baseValue; + var source = descriptor.GetSource() ?? descriptor.GetItems()?.FirstOrDefault()?.Source; + _session.TrackCommit(new EffectiveConfigEditCommittedArgs( + field.SectionId, field.OptionName, oldValue, newValue, source?.FilePath)); + RefreshCrossOptionIssues(status); + } + + public void ReplaceMultiValues(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, IReadOnlyList newValues) + { + EnsureEditMode(); + var descriptor = ResolveDescriptor(status, field); + if (descriptor == null) return; + + var pending = GetPendingOptionChange(field); + var baseValues = descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List(); + var oldValues = pending?.OldValue is IReadOnlyList list ? list.ToList() : baseValues; + var source = descriptor.GetSource() ?? descriptor.GetItems()?.FirstOrDefault()?.Source; + _session.TrackCommit(new EffectiveConfigEditCommittedArgs( + field.SectionId, field.OptionName, oldValues, newValues.ToList(), source?.FilePath)); + RefreshCrossOptionIssues(status); + } + + public void AppendMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string newValue) + { + var current = GetEffectiveMultiValues(status, field); + var next = current.Append(newValue).ToList(); + ReplaceMultiValues(status, field, next); + } + + public void RemoveMultiValue(DnsmasqServiceStatus status, EffectiveConfigFieldRef field, string valueToRemove) + { + var current = GetEffectiveMultiValues(status, field); + var next = current.Where(v => !string.Equals(v, valueToRemove, StringComparison.Ordinal)).ToList(); + ReplaceMultiValues(status, field, next); + } + + public void Revert(EffectiveConfigFieldRef field) + { + _session.RevertChange(field.SectionId, field.OptionName); + } + + public void SetFieldIssues(EffectiveConfigFieldRef field, IReadOnlyList issues) + { + _session.SetFieldIssues(field.FieldKey, issues); + } + + public void ClearFieldIssues(EffectiveConfigFieldRef field) + { + _session.ClearFieldIssues(field.FieldKey); + } + + public void RefreshCrossOptionIssues(DnsmasqServiceStatus status) + { + var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(status, _session.PendingChanges.OfType().ToList()); + _session.SetCrossOptionIssues(issues); + } + + public void Activate(EffectiveConfigFieldRef field) + { + EnsureEditMode(); + _session.ActivateField(field.FieldKey); + } + + private PendingOptionChange? GetPendingOptionChange(EffectiveConfigFieldRef field) + { + return _session.PendingChanges.OfType().FirstOrDefault(c => + string.Equals(c.SectionId, field.SectionId, StringComparison.OrdinalIgnoreCase) && + string.Equals(c.OptionName, field.OptionName, StringComparison.OrdinalIgnoreCase)); + } + + private EffectiveConfigFieldDescriptor? ResolveDescriptor(DnsmasqServiceStatus status, EffectiveConfigFieldRef field) + { + return _descriptorProvider.Resolve(status, field); + } +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs index 6da7c64..f1c5978 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveConfigSaveService.cs @@ -1,6 +1,7 @@ using System.Linq; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions; @@ -15,6 +16,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService private readonly IDnsmasqConfigSetService _configSetService; private readonly IDnsmasqConfigService _configService; private readonly IConfigSetCache _configSetCache; + private readonly IHostsFileService _hostsFileService; private readonly IConfigValidationService _validationService; private readonly IEffectiveConfigSemanticValidationService _semanticValidationService; private readonly IReloadService _reloadService; @@ -25,6 +27,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IConfigSetCache configSetCache, + IHostsFileService hostsFileService, IConfigValidationService validationService, IEffectiveConfigSemanticValidationService semanticValidationService, IReloadService reloadService, @@ -34,6 +37,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService _configSetService = configSetService; _configService = configService; _configSetCache = configSetCache; + _hostsFileService = hostsFileService; _validationService = validationService; _semanticValidationService = semanticValidationService; _reloadService = reloadService; @@ -43,7 +47,7 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService /// public async Task SaveAsync( - IReadOnlyList changes, + IReadOnlyList changes, CancellationToken ct = default) { if (changes.Count == 0) @@ -52,55 +56,32 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService var version = await _versionService.GetVersionInfoAsync(ct); if (!version.ProbeSucceeded) { - return new EffectiveConfigSaveResult( - BackupCreated: false, - BackupPath: null, - Saved: false, - Validated: false, - ValidationExitCode: -1, - ValidationStdOut: null, - ValidationStdErr: null, - Restarted: false, - RestartExitCode: -1, - RestartStdOut: null, - RestartStdErr: null, - ErrorCode: EffectiveConfigSaveResult.ErrorCodes.VersionProbeFailed, - UserMessage: string.IsNullOrWhiteSpace(version.Error) + return Fail(Array.Empty(), EffectiveConfigSaveResult.ErrorCodes.VersionProbeFailed, + string.IsNullOrWhiteSpace(version.Error) ? "Cannot save: dnsmasq version could not be determined." : $"Cannot save: dnsmasq version could not be determined. {version.Error}"); } if (!version.IsSupported) { - return new EffectiveConfigSaveResult( - BackupCreated: false, - BackupPath: null, - Saved: false, - Validated: false, - ValidationExitCode: -1, - ValidationStdOut: null, - ValidationStdErr: null, - Restarted: false, - RestartExitCode: -1, - RestartStdOut: null, - RestartStdErr: null, - ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedVersion, - UserMessage: $"Installed dnsmasq {version.InstalledVersion} is below required {version.MinimumVersion}."); + return Fail(Array.Empty(), EffectiveConfigSaveResult.ErrorCodes.UnsupportedVersion, + $"Installed dnsmasq {version.InstalledVersion} is below required {version.MinimumVersion}."); } var set = await _configSetService.GetConfigSetAsync(ct); - if (string.IsNullOrWhiteSpace(set.ManagedFilePath)) + var plan = BuildWritePlan(changes, set.ManagedFilePath ?? ""); + + if (plan.OptionChanges.Count > 0 && string.IsNullOrWhiteSpace(set.ManagedFilePath)) { _logger.LogWarning("Save skipped: managed config path is not configured"); - return new EffectiveConfigSaveResult( - false, null, false, false, -1, null, null, false, -1, null, null, - EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath, "Managed config path is not configured."); + return Fail(Array.Empty(), EffectiveConfigSaveResult.ErrorCodes.MissingManagedPath, + "Managed config path is not configured."); } - var managedPath = set.ManagedFilePath!; - var backupPath = BuildBackupPath(managedPath); + var optionWriteChanges = plan.OptionChanges + .Select(c => new PendingEffectiveConfigChange(c.SectionId, c.OptionName, c.OldValue, c.NewValue, c.CurrentSourceFilePath)) + .ToList(); - // Reject changes for options not supported by this dnsmasq build (e.g. DNSSEC when built without DNSSEC). - var unsupported = changes + var unsupported = optionWriteChanges .Where(c => !EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(c.OptionName, version.Capabilities)) .Select(c => c.OptionName) .Distinct(StringComparer.Ordinal) @@ -115,23 +96,10 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService var userMessage = unsupported.Count == 1 ? firstReason : $"Some of the options you changed aren't supported by your dnsmasq build. Options not saved: {optionList}."; - return new EffectiveConfigSaveResult( - BackupCreated: false, - BackupPath: null, - Saved: false, - Validated: false, - ValidationExitCode: -1, - ValidationStdOut: null, - ValidationStdErr: null, - Restarted: false, - RestartExitCode: -1, - RestartStdOut: null, - RestartStdErr: null, - ErrorCode: EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities, - UserMessage: userMessage); + return Fail(Array.Empty(), EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities, userMessage); } - var semanticIssues = _semanticValidationService.Validate(changes); + var semanticIssues = _semanticValidationService.Validate(optionWriteChanges); if (semanticIssues.Any(i => i.Severity == FieldIssueSeverity.Error)) { var errorIssues = semanticIssues.Where(i => i.Severity == FieldIssueSeverity.Error).ToList(); @@ -142,12 +110,41 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService var failedFields = errorIssues.Select(i => i.FieldKey).Distinct().ToList(); _logger.LogWarning( "Save blocked: semantic validation failed for {ErrorCount} error(s) on field(s) {FailedFields}. First message: {FirstMessage}", - errorIssues.Count, - failedFields, - messages.FirstOrDefault()); + errorIssues.Count, failedFields, messages.FirstOrDefault()); + return Fail(Array.Empty(), EffectiveConfigSaveResult.ErrorCodes.SemanticValidationFailed, userMessage); + } + + var backups = CreateBackups(plan.Targets); + + try + { + if (plan.OptionChanges.Count > 0) + await _configService.ApplyEffectiveConfigChangesAsync(optionWriteChanges, ct); + if (plan.ManagedHostsChange != null) + { + await _hostsFileService.WriteAsync(plan.ManagedHostsChange.NewEntries, ct); + _logger.LogInformation("Managed hosts file written, {Count} entries", plan.ManagedHostsChange.NewEntries.Count); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write effective config or managed hosts"); + // Roll back any partial write so we never leave one target overwritten and the other unchanged. + foreach (var backup in backups) + { + if (File.Exists(backup.BackupPath)) + { + var dir = Path.GetDirectoryName(backup.TargetPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.Copy(backup.BackupPath, backup.TargetPath, overwrite: true); + _logger.LogInformation("Rolled back partial write: {BackupPath} -> {TargetPath}", backup.BackupPath, backup.TargetPath); + } + } + if (backups.Count > 0) + _configSetCache.Invalidate(); return new EffectiveConfigSaveResult( - BackupCreated: false, - BackupPath: null, + backups, Saved: false, Validated: false, ValidationExitCode: -1, @@ -157,44 +154,16 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService RestartExitCode: -1, RestartStdOut: null, RestartStdErr: null, - ErrorCode: EffectiveConfigSaveResult.ErrorCodes.SemanticValidationFailed, - UserMessage: userMessage); + ErrorCode: EffectiveConfigSaveResult.ErrorCodes.WriteFailed, + UserMessage: ex.Message); } - 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, - null, - false, - -1, - null, - ex.Message, - EffectiveConfigSaveResult.ErrorCodes.WriteFailed, - "Failed to write config."); - } - - var backupCreated = File.Exists(backupPath); var validateResult = await _validationService.ValidateAsync(ct); - if (!validateResult.Success) { - _logger.LogWarning("Config saved but validation failed: exit {ExitCode}, stderr: {Stderr}", validateResult.ExitCode, validateResult.StdErr); + _logger.LogWarning("Changes saved but validation failed: exit {ExitCode}, stderr: {Stderr}", validateResult.ExitCode, validateResult.StdErr); return new EffectiveConfigSaveResult( - BackupCreated: backupCreated, - BackupPath: backupCreated ? backupPath : null, + backups, Saved: true, Validated: false, ValidationExitCode: validateResult.ExitCode, @@ -209,13 +178,11 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService } var restartResult = await _reloadService.ReloadAsync(ct); - if (!restartResult.Success) _logger.LogWarning("Config saved but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr); return new EffectiveConfigSaveResult( - BackupCreated: backupCreated, - BackupPath: backupCreated ? backupPath : null, + backups, Saved: true, Validated: true, ValidationExitCode: 0, @@ -232,30 +199,39 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService } /// - public async Task RestoreAsync(string backupPath, CancellationToken ct = default) + public async Task RestoreAsync( + IReadOnlyList backups, + CancellationToken ct = default) { - var set = await _configSetService.GetConfigSetAsync(ct); - if (string.IsNullOrWhiteSpace(set.ManagedFilePath)) + if (backups == null || backups.Count == 0) { - _logger.LogWarning("Restore skipped: managed config path is not configured"); + _logger.LogWarning("Restore skipped: no backups provided"); return new EffectiveConfigRestoreResult( - false, false, -1, null, "Managed config path is not configured."); + false, false, -1, null, "No backups available."); } - if (!File.Exists(backupPath)) + foreach (var backup in backups) { - _logger.LogWarning("Restore skipped: backup file not found: {Path}", backupPath); - return new EffectiveConfigRestoreResult( - false, false, -1, null, "Backup file not found."); + if (!File.Exists(backup.BackupPath)) + { + _logger.LogWarning("Restore skipped: backup file not found: {Path}", backup.BackupPath); + return new EffectiveConfigRestoreResult( + false, false, -1, null, $"Backup file not found: {backup.BackupPath}"); + } + } + + foreach (var backup in backups) + { + var dir = Path.GetDirectoryName(backup.TargetPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.Copy(backup.BackupPath, backup.TargetPath, overwrite: true); + _logger.LogInformation("Restored {Kind} from backup: {BackupPath} -> {TargetPath}", backup.Kind, backup.BackupPath, backup.TargetPath); } - var managedPath = set.ManagedFilePath!; - File.Copy(backupPath, managedPath, overwrite: true); _configSetCache.Invalidate(); - _logger.LogInformation("Restored managed config from backup: {BackupPath}", backupPath); var restartResult = await _reloadService.ReloadAsync(ct); - if (!restartResult.Success) _logger.LogWarning("Restore completed but restart command failed: exit {ExitCode}, stderr: {Stderr}", restartResult.ExitCode, restartResult.StdErr); @@ -265,22 +241,45 @@ public sealed class EffectiveConfigSaveService : IEffectiveConfigSaveService RestartExitCode: restartResult.ExitCode, RestartStdErr: restartResult.StdErr, UserMessage: restartResult.Success - ? "Backup restored and dnsmasq restarted." - : "Backup restored, but the restart command still failed."); + ? "Backups restored and dnsmasq restarted." + : "Backups restored, but the restart command still failed."); } - private static string BuildBackupPath(string managedPath) => - $"{managedPath}.bak.{DateTime.UtcNow:yyyyMMdd-HHmmss}"; + private static EffectiveConfigSaveResult Fail( + IReadOnlyList backups, + string errorCode, + string userMessage) => + new(backups, false, false, -1, null, null, false, -1, null, null, errorCode, userMessage); - private static void CreateBackupIfSourceExists(string managedPath, string backupPath) + private static DnsmasqSaveWritePlan BuildWritePlan( + IReadOnlyList changes, + string managedConfigPath) { - if (!File.Exists(managedPath)) - return; + var optionChanges = changes.OfType().ToList(); + var hostsChange = changes.OfType().FirstOrDefault(); + var targets = new List(); + if (optionChanges.Count > 0 && !string.IsNullOrWhiteSpace(managedConfigPath)) + targets.Add(new DnsmasqManagedWriteTarget(DnsmasqManagedTargetKind.ManagedConfig, managedConfigPath)); + if (hostsChange != null) + targets.Add(new DnsmasqManagedWriteTarget(DnsmasqManagedTargetKind.ManagedHosts, hostsChange.ManagedHostsFilePath)); + return new DnsmasqSaveWritePlan(optionChanges, hostsChange, targets); + } - var dir = Path.GetDirectoryName(backupPath); - if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - File.Copy(managedPath, backupPath, overwrite: false); + private static IReadOnlyList CreateBackups(IReadOnlyList targets) + { + var list = new List(); + var timestamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); + foreach (var target in targets) + { + if (!File.Exists(target.TargetPath)) + continue; + var backupPath = $"{target.TargetPath}.bak.{timestamp}"; + var dir = Path.GetDirectoryName(backupPath); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) + Directory.CreateDirectory(dir); + File.Copy(target.TargetPath, backupPath, overwrite: false); + list.Add(new DnsmasqManagedBackup(target.Kind, target.TargetPath, backupPath)); + } + return list; } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigFieldBuilder.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigFieldBuilder.cs index 3fcc417..3e25f5b 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigFieldBuilder.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigFieldBuilder.cs @@ -1,3 +1,4 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering.Abstractions; using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; @@ -6,9 +7,17 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; /// /// Builds field descriptors for viewing and editing. Mapping is done once here via delegates passed to each descriptor. +/// Full graph is built via DI so the registry dependency remains explicit. /// -public static class EffectiveConfigFieldBuilder +public sealed class EffectiveConfigFieldBuilder : IEffectiveConfigFieldBuilder { + private readonly IEffectiveConfigRenderFragmentRegistry _registry; + + public EffectiveConfigFieldBuilder(IEffectiveConfigRenderFragmentRegistry registry) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + } + private static EffectiveDnsmasqConfig? Config(DnsmasqServiceStatus? s) => s?.EffectiveConfig; private static EffectiveConfigSources? Sources(DnsmasqServiceStatus? s) => s?.EffectiveConfigSources; @@ -28,27 +37,18 @@ public static class EffectiveConfigFieldBuilder }; } - /// Section IDs for use by registry/views. Prefer EffectiveConfigSections for section list and option→section mapping. - public static string SectionHosts => EffectiveConfigSections.SectionHosts; - public static string SectionResolver => EffectiveConfigSections.SectionResolver; - public static string SectionDnsRecords => EffectiveConfigSections.SectionDnsRecords; - public static string SectionDhcp => EffectiveConfigSections.SectionDhcp; - public static string SectionTftpPxe => EffectiveConfigSections.SectionTftpPxe; - public static string SectionDnssec => EffectiveConfigSections.SectionDnssec; - public static string SectionCache => EffectiveConfigSections.SectionCache; - public static string SectionProcess => EffectiveConfigSections.SectionProcess; - - public static IReadOnlyList BuildFieldDescriptors(DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry? registry = null) + /// + public IReadOnlyList BuildFieldDescriptors(DnsmasqServiceStatus status) { if (status == null || status.EffectiveConfig == null) return Array.Empty(); var list = new List(); - AddAllDescriptors(list, status, registry); + AddAllDescriptors(list, status, _registry); return list; } - private static void AddAllDescriptors(List list, DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry? registry) + private static void AddAllDescriptors(List list, DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry registry) { // Hosts list.AddDescriptor(registry, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null); diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSpecialOptionSemantics.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSpecialOptionSemantics.cs index 949ad7c..8adc9da 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSpecialOptionSemantics.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigSpecialOptionSemantics.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using DnsmasqWebUI.Models.Dhcp; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; @@ -7,12 +8,14 @@ namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; /// Single source of truth for options with special parse/write/validation semantics. /// Prevents drift between parse, write, hint, and validation for UseStaleCache, AddMac, AddSubnet, Umbrella, Do0x20Encode. /// Inverse-pair key names are not stored here; see . +/// When set, indicates the option has a typed structured value handler (e.g. dhcp-host → ). /// public sealed record OptionSemantics( string OptionName, EffectiveConfigParserBehavior ParserBehavior, EffectiveConfigWriteBehavior WriteBehavior, - OptionValidationSemantics Validation + OptionValidationSemantics Validation, + Type? StructuredValueType = null ); /// @@ -220,7 +223,8 @@ public static class EffectiveConfigSpecialOptionSemantics DnsmasqConfKeys.DhcpHost, EffectiveConfigParserBehavior.Multi, EffectiveConfigWriteBehavior.MultiValue, - ComplexMulti), + ComplexMulti, + typeof(DhcpHostEntry)), [DnsmasqConfKeys.DhcpOption] = new OptionSemantics( DnsmasqConfKeys.DhcpOption, EffectiveConfigParserBehavior.Multi, @@ -507,6 +511,10 @@ public static class EffectiveConfigSpecialOptionSemantics public static (string KeyA, string KeyB)? GetInversePairKeys(string optionName) => InversePairKeysByOptionName.TryGetValue(optionName, out var pair) ? pair : null; + /// Returns the structured value type for an option that has a typed handler (e.g. dhcp-host → DhcpHostEntry); null otherwise. + public static Type? GetStructuredValueType(string optionName) => + TryGetSemantics(optionName)?.StructuredValueType; + /// Returns all option names that have special semantics. Used by wiring tests to avoid hardcoded lists. public static IReadOnlyCollection GetAllOptionNames() => ByOptionName.Keys.ToList(); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs index 6e1ae0b..4a257d8 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Metadata/EffectiveConfigViews.cs @@ -1,12 +1,11 @@ -using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering.Abstractions; -using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; /// /// Defines which sections and (optionally) which fields are visible per EffectiveConfigContext. -/// Single place to add new contexts or change what shows on Hosts/DHCP/etc. +/// Visibility only; descriptor construction is done by . /// public static class EffectiveConfigViews { @@ -81,34 +80,4 @@ public static class EffectiveConfigViews DnsmasqConfKeys.NoRoundRobin, ]), ]; - - /// - /// For each section in the view, returns the context-visible descriptors for that section. - /// Does not apply search; component applies search to each section's list and hides sections with no matches. - /// - /// When provided, used to create the correct descriptor type per field (e.g. integer options). - public static IReadOnlyDictionary> GetDescriptorsBySection( - DnsmasqServiceStatus? status, - IReadOnlyList views, - IEffectiveConfigRenderFragmentRegistry? registry = null) - { - if (status == null || views.Count == 0) - return new Dictionary>(); - - var allDescriptors = EffectiveConfigFieldBuilder.BuildFieldDescriptors(status, registry); - var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); - - foreach (var view in views) - { - var list = allDescriptors - .Where(d => string.Equals(d.SectionId, view.SectionId, StringComparison.OrdinalIgnoreCase)) - .Where(d => view.AllowedOptionNames == null - || view.AllowedOptionNames.Contains(d.OptionName, StringComparer.OrdinalIgnoreCase)) - .ToList(); - if (list.Count > 0) - result[view.SectionId] = list; - } - - return result.ToDictionary(kv => kv.Key, kv => (IReadOnlyList)kv.Value, StringComparer.OrdinalIgnoreCase); - } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs index 91e5a0d..fc1c384 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Rendering/EffectiveConfigRenderFragmentRegistry.cs @@ -44,36 +44,36 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm private void RegisterCustomSingles() { - RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay)); - RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay)); - RegisterComponent(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay)); - RegisterComponent(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay)); + RegisterComponent(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Port, typeof(PortValueDisplay)); + RegisterComponent(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, typeof(Do0x20EncodeDisplay)); + RegisterComponent(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.LogQueries, typeof(LogQueriesDisplay)); + RegisterComponent(EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.LocalService, typeof(LocalServiceDisplay)); } private void RegisterIntegerDescriptors() { - RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds"); - RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535); - RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0); - RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.PortLimit, max: 65535); - RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.MinPort, min: 1, max: 65535); - RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.MaxPort, min: 1, max: 65535); - RegisterInteger(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DnsForwardMax, min: 1); - RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheSize, min: 0); - RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.LocalTtl, unit: "seconds"); - RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NegTtl, unit: "seconds"); - RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MaxTtl, unit: "seconds"); - RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MaxCacheTtl, unit: "seconds"); - RegisterInteger(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds"); - RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0); - RegisterInteger(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpTtl); + RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.AuthTtl, unit: "seconds"); + RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.EdnsPacketMax, max: 65535); + RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.QueryPort, max: 65535, defaultValue: 0); + RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.PortLimit, max: 65535); + RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.MinPort, min: 1, max: 65535); + RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.MaxPort, min: 1, max: 65535); + RegisterInteger(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.DnsForwardMax, min: 1); + RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.CacheSize, min: 0); + RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.LocalTtl, unit: "seconds"); + RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.NegTtl, unit: "seconds"); + RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.MaxTtl, unit: "seconds"); + RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.MaxCacheTtl, unit: "seconds"); + RegisterInteger(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.MinCacheTtl, unit: "seconds"); + RegisterInteger(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpLeaseMax, min: 0); + RegisterInteger(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpTtl); } private void RegisterFlagDisplays() { - RegisterFlags(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers); + RegisterFlags(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.NoHosts, DnsmasqConfKeys.ReadEthers); RegisterFlags( - EffectiveConfigFieldBuilder.SectionResolver, + EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.ExpandHosts, DnsmasqConfKeys.BogusPriv, DnsmasqConfKeys.StrictOrder, @@ -90,7 +90,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm DnsmasqConfKeys.LocaliseQueries, DnsmasqConfKeys.NoRoundRobin); RegisterFlags( - EffectiveConfigFieldBuilder.SectionDhcp, + EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative, DnsmasqConfKeys.DhcpRapidCommit, DnsmasqConfKeys.LeasefileRo, @@ -101,25 +101,25 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm DnsmasqConfKeys.ScriptOnRenewal, DnsmasqConfKeys.DhcpNoOverride); RegisterFlags( - EffectiveConfigFieldBuilder.SectionTftpPxe, + EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.EnableTftp, DnsmasqConfKeys.TftpSecure, DnsmasqConfKeys.TftpNoFail, DnsmasqConfKeys.TftpNoBlocksize); - RegisterFlags(EffectiveConfigFieldBuilder.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx); + RegisterFlags(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Localmx, DnsmasqConfKeys.Selfmx); RegisterFlags( - EffectiveConfigFieldBuilder.SectionDnssec, + EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.Dnssec, DnsmasqConfKeys.ProxyDnssec, DnsmasqConfKeys.DnssecNoTimecheck, DnsmasqConfKeys.DnssecDebug); RegisterFlags( - EffectiveConfigFieldBuilder.SectionCache, + EffectiveConfigSections.SectionCache, DnsmasqConfKeys.NoNegcache, DnsmasqConfKeys.StripMac, DnsmasqConfKeys.StripSubnet); RegisterFlags( - EffectiveConfigFieldBuilder.SectionProcess, + EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.NoPoll, DnsmasqConfKeys.BindInterfaces, DnsmasqConfKeys.BindDynamic, @@ -138,22 +138,22 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm private void RegisterKeyOnlyOrValueDisplays() { RegisterSemanticSingleComponent( - EffectiveConfigFieldBuilder.SectionCache, + EffectiveConfigSections.SectionCache, typeof(KeyOnlyOrValueDisplay), DnsmasqConfKeys.UseStaleCache, DnsmasqConfKeys.AddMac, DnsmasqConfKeys.AddSubnet, DnsmasqConfKeys.Umbrella); RegisterSemanticSingleComponent( - EffectiveConfigFieldBuilder.SectionResolver, + EffectiveConfigSections.SectionResolver, typeof(KeyOnlyOrValueDisplay), DnsmasqConfKeys.ConnmarkAllowlistEnable); RegisterSemanticSingleComponent( - EffectiveConfigFieldBuilder.SectionDnssec, + EffectiveConfigSections.SectionDnssec, typeof(KeyOnlyOrValueDisplay), DnsmasqConfKeys.DnssecCheckUnsigned); RegisterComponents( - EffectiveConfigFieldBuilder.SectionDhcp, + EffectiveConfigSections.SectionDhcp, typeof(KeyOnlyOrValueDisplay), DnsmasqConfKeys.DhcpGenerateNames, DnsmasqConfKeys.DhcpBroadcast, @@ -163,38 +163,38 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm private void RegisterPathValidatedSingles() { RegisterSemanticSingles( - EffectiveConfigFieldBuilder.SectionHosts, + EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Hostsdir); RegisterSemanticSingles( - EffectiveConfigFieldBuilder.SectionDhcp, + EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpLeasefile, DnsmasqConfKeys.DhcpScript, DnsmasqConfKeys.DhcpLuascript); RegisterSemanticSingles( - EffectiveConfigFieldBuilder.SectionTftpPxe, + EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.TftpRoot); RegisterSemanticSingles( - EffectiveConfigFieldBuilder.SectionDnssec, + EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecTimestamp); RegisterSemanticSingles( - EffectiveConfigFieldBuilder.SectionCache, + EffectiveConfigSections.SectionCache, DnsmasqConfKeys.Dumpfile); RegisterSemanticSingles( - EffectiveConfigFieldBuilder.SectionProcess, + EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.PidFile); } private void RegisterMultiValueDescriptors() { RegisterSemanticMultiDescriptor( - EffectiveConfigFieldBuilder.SectionResolver, + EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server, behavior: new DistinctMultiValueEditBehavior()); - RegisterSemanticMultiDescriptor(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.ListenAddress); + RegisterSemanticMultiDescriptor(EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.ListenAddress); - RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.AddnHosts); + RegisterSemanticMultis(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.AddnHosts); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionResolver, + EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Local, DnsmasqConfKeys.ResolvFile, DnsmasqConfKeys.RevServer, @@ -207,7 +207,7 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm DnsmasqConfKeys.Nftset, DnsmasqConfKeys.ConnmarkAllowlist); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionDhcp, + EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpRange, DnsmasqConfKeys.DhcpHost, DnsmasqConfKeys.DhcpOption, @@ -234,10 +234,10 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm DnsmasqConfKeys.Slaac); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionResolver, + EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.FilterRr); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionDnsRecords, + EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Domain, DnsmasqConfKeys.Cname, DnsmasqConfKeys.MxHost, @@ -256,20 +256,20 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm DnsmasqConfKeys.AuthSecServers, DnsmasqConfKeys.AuthPeer); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionDhcp, + EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpCircuitid, DnsmasqConfKeys.DhcpRemoteid, DnsmasqConfKeys.DhcpSubscrid); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionTftpPxe, + EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.PxeService); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionTftpPxe, + EffectiveConfigSections.SectionTftpPxe, DnsmasqConfKeys.DhcpOptionPxe); - RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionDnssec, DnsmasqConfKeys.TrustAnchor); - RegisterSemanticMultis(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.CacheRr); + RegisterSemanticMultis(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.TrustAnchor); + RegisterSemanticMultis(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.CacheRr); RegisterSemanticMultis( - EffectiveConfigFieldBuilder.SectionProcess, + EffectiveConfigSections.SectionProcess, DnsmasqConfKeys.Interface, DnsmasqConfKeys.ExceptInterface, DnsmasqConfKeys.AuthServer, diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs index f76b58e..b7e418b 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Validation/EffectiveConfigCrossOptionEvaluator.cs @@ -15,7 +15,7 @@ public static class EffectiveConfigCrossOptionEvaluator /// public static IReadOnlyList Evaluate( DnsmasqServiceStatus? status, - IReadOnlyList pending) + IReadOnlyList pending) { var issues = new List(); @@ -50,7 +50,7 @@ public static class EffectiveConfigCrossOptionEvaluator private static bool GetEffectiveBool( DnsmasqServiceStatus? status, - IReadOnlyList? pending, + IReadOnlyList? pending, string optionName, Func fromConfig) { @@ -66,7 +66,7 @@ public static class EffectiveConfigCrossOptionEvaluator private static int? GetEffectiveInt( DnsmasqServiceStatus? status, - IReadOnlyList? pending, + IReadOnlyList? pending, string optionName, Func fromConfig) { @@ -80,7 +80,7 @@ public static class EffectiveConfigCrossOptionEvaluator return fromConfig(status); } - private static IReadOnlyList? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList pending) + private static IReadOnlyList? GetEffectiveServerValues(DnsmasqServiceStatus? status, IReadOnlyList pending) { var fromConfig = status?.EffectiveConfig?.ServerValues; var pendingChange = pending?.FirstOrDefault(c => diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/DnsmasqSaveWritePlan.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/DnsmasqSaveWritePlan.cs new file mode 100644 index 0000000..32aad38 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/DnsmasqSaveWritePlan.cs @@ -0,0 +1,25 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// Kind of managed file written by the save flow. +public enum DnsmasqManagedTargetKind +{ + ManagedConfig, + ManagedHosts +} + +/// One file that may be written during save (config or managed hosts). +public sealed record DnsmasqManagedWriteTarget( + DnsmasqManagedTargetKind Kind, + string TargetPath); + +/// One backup created before write (target path + backup path). +public sealed record DnsmasqManagedBackup( + DnsmasqManagedTargetKind Kind, + string TargetPath, + string BackupPath); + +/// Plan built from pending changes: which option changes, hosts change, and which targets to back up and write. +public sealed record DnsmasqSaveWritePlan( + IReadOnlyList OptionChanges, + PendingManagedHostsChange? ManagedHostsChange, + IReadOnlyList Targets); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigFieldRef.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigFieldRef.cs new file mode 100644 index 0000000..42837e3 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigFieldRef.cs @@ -0,0 +1,14 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// +/// Typed identity for an effective-config field (section + option). +/// Use for activation, revert, and field issues instead of loose string pairs. +/// +public readonly record struct EffectiveConfigFieldRef(string SectionId, string OptionName) +{ + /// Key used by session (ActivateField, SetFieldIssues, etc.). + public string FieldKey => $"{SectionId}:{OptionName}"; + + public static EffectiveConfigFieldRef For(string sectionId, string optionName) + => new(sectionId, optionName); +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs index 021ecfa..18d0127 100644 --- a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/EffectiveConfigSaveResult.cs @@ -1,9 +1,8 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; /// Result of the effective-config save flow (backup, write, validate, restart). Used by UI to drive state and show restore/continue options. -/// True when a backup file was created before write. -/// Path to the backup file when one was created; null otherwise. -/// True when the managed config was written successfully. +/// Backups created before write (one per target file touched). Empty when no write occurred or no backups were created. +/// True when all managed files were written successfully. /// True when validation was run and succeeded (or was skipped). When validation ran and failed, restart is not attempted. /// Validation command exit code; -1 when not run or failed to start. /// Standard output from the validation command. @@ -15,8 +14,7 @@ namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; /// Machine-readable code: see ; null when success. /// Short message for the user. public record EffectiveConfigSaveResult( - bool BackupCreated, - string? BackupPath, + IReadOnlyList Backups, bool Saved, bool Validated, int ValidationExitCode, @@ -29,6 +27,8 @@ public record EffectiveConfigSaveResult( string? ErrorCode, string? UserMessage) { + /// True when at least one backup was created (for restore UI). + public bool BackupCreated => Backups.Count > 0; /// Machine-readable error codes for . public static class ErrorCodes { @@ -51,5 +51,5 @@ public record EffectiveConfigSaveResult( /// Result when there are no pending changes to apply. public static EffectiveConfigSaveResult NoChanges() => - new(false, null, false, false, -1, null, null, false, -1, null, null, ErrorCodes.NoChanges, "No pending changes."); + new(Array.Empty(), false, false, -1, null, null, false, -1, null, null, ErrorCodes.NoChanges, "No pending changes."); } diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingDnsmasqChange.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingDnsmasqChange.cs new file mode 100644 index 0000000..62ee262 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingDnsmasqChange.cs @@ -0,0 +1,4 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// Base type for a pending change (config option or managed hosts). Used by the edit session and save flow. +public abstract record PendingDnsmasqChange(string ChangeKey); diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingManagedHostsChange.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingManagedHostsChange.cs new file mode 100644 index 0000000..7323632 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingManagedHostsChange.cs @@ -0,0 +1,32 @@ +using DnsmasqWebUI.Models.Hosts; + +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// A pending change for the managed hosts file. Written via IHostsFileService on save. +public sealed record PendingManagedHostsChange( + IReadOnlyList OldEntries, + IReadOnlyList NewEntries, + string ManagedHostsFilePath) + : PendingDnsmasqChange("hosts:managed") +{ + /// True when NewEntries differs from OldEntries. + public bool HasChanges => !EntriesEqual(OldEntries, NewEntries); + + private static bool EntriesEqual(IReadOnlyList left, IReadOnlyList right) + { + if (left.Count != right.Count) return false; + for (var i = 0; i < left.Count; i++) + { + var a = left[i]; + var b = right[i]; + if (a.Id != b.Id || + a.Address != b.Address || + !(a.Names ?? new List()).SequenceEqual(b.Names ?? new List(), StringComparer.Ordinal) || + a.RawLine != b.RawLine || + a.IsComment != b.IsComment || + a.IsPassthrough != b.IsPassthrough) + return false; + } + return true; + } +} diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingOptionChange.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingOptionChange.cs new file mode 100644 index 0000000..56342a9 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/PendingOptionChange.cs @@ -0,0 +1,10 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// A pending change for one config option. Written to the managed config file on save. +public sealed record PendingOptionChange( + string SectionId, + string OptionName, + object? OldValue, + object? NewValue, + string? CurrentSourceFilePath = null) + : PendingDnsmasqChange($"{SectionId}:{OptionName}");