From 43ba5116ac64eaba700bc06a49965ee386d29439 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sat, 11 Apr 2026 21:35:52 +1000 Subject: [PATCH] Make specialized DNS and resolver views draft-aware for release v0.0.9. Project current effective multi-value edits back onto source-aware rows, add occurrence-based targeting for duplicate records, and bump the app version so the curated pages stay consistent with Effective configuration during editing. --- .../DnsRecordEditorSyncGuardTests.cs | 8 + .../DnsRecordsFileFilterBuilderTests.cs | 47 +++ .../Dhcp/DhcpPageProjectionServiceTests.cs | 13 +- .../DnsRecordsPageProjectionServiceTests.cs | 385 +++++++++++++++++- ...fectiveMultiValueProjectionServiceTests.cs | 39 ++ .../FiltersPageProjectionServiceTests.cs | 27 +- .../DnsRecords/DnsRecordsTable.razor | 12 +- .../Editors/AdvancedDnsRecordEditor.razor | 4 + .../Editors/CnameRecordEditor.razor | 4 + .../DnsRecords/Editors/HostRecordEditor.razor | 4 + .../DnsRecords/Editors/MxRecordEditor.razor | 4 + .../DnsRecords/Editors/PtrRecordEditor.razor | 4 + .../DnsRecords/Editors/SrvRecordEditor.razor | 4 + .../DnsRecords/Editors/TxtRecordEditor.razor | 4 + .../Filters/FilterPolicyTable.razor | 14 +- .../Components/Pages/DnsRecords.razor | 62 ++- .../Components/Pages/Filters.razor | 159 ++++++-- src/DnsmasqWebUI/DnsmasqWebUI.csproj | 2 +- .../Dnsmasq/Dhcp/DhcpPageProjectionService.cs | 55 ++- .../IDnsRecordsPageProjectionService.cs | 10 +- .../DnsRecords/DnsRecordDirectiveCodecs.cs | 4 + .../DnsRecordsPageProjectionService.cs | 62 ++- .../IFiltersPageProjectionService.cs | 11 +- .../Filters/FiltersPageProjectionService.cs | 103 ++++- .../IEffectiveMultiValueProjectionService.cs | 16 + .../EffectiveMultiValueProjectionService.cs | 89 ++++ .../Models/Dhcp/Ui/DhcpPageModels.cs | 2 + .../Models/DnsRecords/DnsRecordRow.cs | 4 + .../DnsRecords/DnsRecordsFileFilterBuilder.cs | 17 +- .../ProjectedMultiValueOccurrence.cs | 15 + .../Models/Filters/FilterPolicyRow.cs | 4 + .../Filters/FilterSourceFileFilterBuilder.cs | 4 +- 32 files changed, 1053 insertions(+), 139 deletions(-) create mode 100644 src/DnsmasqWebUI.Tests/Models/DnsRecords/DnsRecordsFileFilterBuilderTests.cs create mode 100644 src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveMultiValueProjectionServiceTests.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveMultiValueProjectionService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveMultiValueProjectionService.cs create mode 100644 src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ProjectedMultiValueOccurrence.cs diff --git a/src/DnsmasqWebUI.Tests/Components/DnsRecords/DnsRecordEditorSyncGuardTests.cs b/src/DnsmasqWebUI.Tests/Components/DnsRecords/DnsRecordEditorSyncGuardTests.cs index c3bc8cc..b937e57 100644 --- a/src/DnsmasqWebUI.Tests/Components/DnsRecords/DnsRecordEditorSyncGuardTests.cs +++ b/src/DnsmasqWebUI.Tests/Components/DnsRecords/DnsRecordEditorSyncGuardTests.cs @@ -27,12 +27,16 @@ public class DnsRecordEditorSyncGuardTests public void EditMode_UsesRowId() { var row = new DnsRecordRow( + "cname:0", "cname:0", DnsmasqConfKeys.Cname, DnsRecordFamily.Cname, 0, "a,b", null, + null, + null, + false, true, new CnamePayload(["a"], "b", null), [], @@ -48,12 +52,16 @@ public class DnsRecordEditorSyncGuardTests string? k = null; DnsRecordEditorSyncGuard.ShouldReseedFromExisting(ref k, null); var row = new DnsRecordRow( + "txt:1", "txt:1", DnsmasqConfKeys.TxtRecord, DnsRecordFamily.Txt, 1, "x,y", null, + null, + null, + false, true, new TxtPayload("x", "y"), [], diff --git a/src/DnsmasqWebUI.Tests/Models/DnsRecords/DnsRecordsFileFilterBuilderTests.cs b/src/DnsmasqWebUI.Tests/Models/DnsRecords/DnsRecordsFileFilterBuilderTests.cs new file mode 100644 index 0000000..725d5bf --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/DnsRecords/DnsRecordsFileFilterBuilderTests.cs @@ -0,0 +1,47 @@ +using DnsmasqWebUI.Models.DnsRecords; + +namespace DnsmasqWebUI.Tests.Models.DnsRecords; + +public sealed class DnsRecordsFileFilterBuilderTests +{ + private static DnsRecordRow Row( + string id, + string sourcePath, + bool isEditable, + bool isDraftOnly = false) => + new( + Id: id, + OccurrenceId: id, + OptionName: "cname", + Family: DnsRecordFamily.Cname, + IndexInOption: 0, + RawValue: "alias,target", + Source: null, + SourcePath: sourcePath, + SourceLabel: Path.GetFileName(sourcePath), + IsDraftOnly: isDraftOnly, + IsEditable: isEditable, + Payload: new CnamePayload(["alias"], "target", null), + Issues: [], + Summary: "alias -> target"); + + [Fact] + public void Build_ClassifiesOnlyManagedConfigPathAsManaged() + { + var managed = "/etc/dnsmasq-webui/managed.conf"; + var rows = new[] + { + Row("managed", managed, isEditable: true), + Row("draft-other", "/tmp/custom-writable.conf", isEditable: true, isDraftOnly: true) + }; + + var builder = new DnsRecordsFileFilterBuilder(rows, managed); + var model = builder.Build(); + + Assert.Equal(2, model.Sections.Count); + Assert.Equal("managed", model.Sections[0].Kind); + Assert.Equal(managed, model.Sections[0].Options[0].Value); + Assert.Equal("other", model.Sections[1].Kind); + Assert.Equal("/tmp/custom-writable.conf", model.Sections[1].Options[0].Value); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Dhcp/DhcpPageProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Dhcp/DhcpPageProjectionServiceTests.cs index c0dfbbd..e1f585b 100644 --- a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Dhcp/DhcpPageProjectionServiceTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/Dhcp/DhcpPageProjectionServiceTests.cs @@ -1,5 +1,6 @@ using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.Dhcp.Ui; using DnsmasqWebUI.Models.Dnsmasq; @@ -8,7 +9,7 @@ namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Dhcp; public class DhcpPageProjectionServiceTests { - private static readonly DhcpPageProjectionService Sut = new(); + private static readonly DhcpPageProjectionService Sut = new(new EffectiveMultiValueProjectionService()); private static readonly DhcpHostOptionValueHandler Handler = new(); private static DhcpHostPageRow SyntheticRow( @@ -23,8 +24,10 @@ public class DhcpPageProjectionServiceTests EffectiveIndex: effectiveIndex, ValueString: mac, RowKey: $"t:{effectiveIndex}", + OccurrenceId: $"t:{effectiveIndex}", SourceKind: kind, SourcePath: sourcePath, + IsDraftOnly: false, IsEditable: editable, IsActive: true, Entry: new DhcpHostEntry @@ -70,8 +73,8 @@ public class DhcpPageProjectionServiceTests }; var rows = new List { - new(0, "a", "k0", DhcpSourceKind.Managed, "/m", true, true, entry1, null, null, true), - new(1, "b", "k1", DhcpSourceKind.Managed, "/m", true, true, entry2, null, null, true) + new(0, "a", "k0", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true), + new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, true, true, entry2, null, null, true) }; DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null); @@ -175,8 +178,8 @@ public class DhcpPageProjectionServiceTests }; var rows = new List { - new(0, "a", "k0", DhcpSourceKind.Managed, "/m", true, true, entry1, null, null, true), - new(1, "b", "k1", DhcpSourceKind.Managed, "/m", true, true, entry2, null, null, true) + new(0, "a", "k0", "k0", DhcpSourceKind.Managed, "/m", false, true, true, entry1, null, null, true), + new(1, "b", "k1", "k1", DhcpSourceKind.Managed, "/m", false, true, true, entry2, null, null, true) }; DhcpPageProjectionService.ApplyHostConflictsAndLeases(rows, null, null); diff --git a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionServiceTests.cs index 1e56f79..91d1af7 100644 --- a/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionServiceTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionServiceTests.cs @@ -1,5 +1,6 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; using DnsmasqWebUI.Models.Dnsmasq; @@ -12,7 +13,8 @@ public class DnsRecordsPageProjectionServiceTests { private readonly DnsRecordsPageProjectionService _sut = new( new DnsRecordDirectiveCodecProvider(), - new NoopOptionSemanticValidator()); + new NoopOptionSemanticValidator(), + new EffectiveMultiValueProjectionService()); [Fact] public void BuildRows_ReturnsEmpty_WhenEffectiveConfigNull() @@ -78,6 +80,31 @@ public class DnsRecordsPageProjectionServiceTests Assert.Equal("n", filtered[0].Id); } + [Fact] + public void BuildRows_CurrentValuesAccessor_UsesDraftValuesAndManagedSourcePathForUnmatchedRows() + { + var status = MinimalStatus() with + { + ManagedFilePath = "/managed.conf", + EffectiveConfig = MinimalConfig() with { CnameValues = ["disk.example,target.example"] }, + EffectiveConfigSources = MinimalSources() with + { + CnameValues = [new ValueWithSource("disk.example,target.example", new ConfigValueSource("/etc/dnsmasq.d/base.conf", "base.conf", false, 12))] + } + }; + + var rows = _sut.BuildRows(status, optionName => optionName == DnsmasqConfKeys.Cname + ? ["draft.example,target.example"] + : []); + + var row = Assert.Single(rows); + Assert.Equal("draft.example,target.example", row.RawValue); + Assert.True(row.IsDraftOnly); + Assert.True(row.IsEditable); + Assert.Equal("/managed.conf", row.SourcePath); + Assert.Equal("managed.conf", row.SourceLabel); + } + private static DnsRecordRow Row( string id, string optionName, @@ -89,17 +116,373 @@ public class DnsRecordsPageProjectionServiceTests string? summary = null, IReadOnlyList? issues = null) => new( + id, id, optionName, family, IndexInOption: 0, rawValue, source, + source?.FilePath, + source?.FileName, + false, editable, payload ?? new CnamePayload(["x"], "y", null), issues ?? [], summary ?? rawValue); + private static EffectiveDnsmasqConfig MinimalConfig() => + new( + NoHosts: false, + AddnHostsPaths: [], + HostsdirPath: null, + ServerValues: [], + LocalValues: [], + RevServerValues: [], + AddressValues: [], + Interfaces: [], + ListenAddresses: [], + ExceptInterfaces: [], + DhcpRanges: [], + DhcpHostLines: [], + DhcpOptionLines: [], + DhcpMatchValues: [], + DhcpBootValues: [], + DhcpIgnoreValues: [], + DhcpVendorclassValues: [], + DhcpUserclassValues: [], + RaParamValues: [], + SlaacValues: [], + PxeServiceValues: [], + TrustAnchorValues: [], + ResolvFiles: [], + RebindDomainOkValues: [], + BogusNxdomainValues: [], + IgnoreAddressValues: [], + AliasValues: [], + FilterRrValues: [], + CacheRrValues: [], + AuthServerValues: [], + NoDhcpInterfaceValues: [], + NoDhcpv4InterfaceValues: [], + NoDhcpv6InterfaceValues: [], + DomainValues: [], + CnameValues: [], + MxHostValues: [], + SrvValues: [], + PtrRecordValues: [], + TxtRecordValues: [], + NaptrRecordValues: [], + HostRecordValues: [], + DynamicHostValues: [], + InterfaceNameValues: [], + DhcpOptionForceLines: [], + IpsetValues: [], + NftsetValues: [], + DhcpMacValues: [], + DhcpNameMatchValues: [], + DhcpIgnoreNamesValues: [], + DhcpHostsfilePaths: [], + DhcpOptsfilePaths: [], + DhcpHostsdirPaths: [], + DhcpOptsdirPaths: [], + ConnmarkAllowlistValues: [], + CaaRecordValues: [], + DnsRrValues: [], + SynthDomainValues: [], + AuthZoneValues: [], + AuthSoaValues: [], + AuthSecServersValues: [], + AuthPeerValues: [], + DhcpRelayValues: [], + DhcpCircuitidValues: [], + DhcpRemoteidValues: [], + DhcpSubscridValues: [], + DhcpProxyValues: [], + TagIfValues: [], + BridgeInterfaceValues: [], + SharedNetworkValues: [], + DhcpOptionPxeValues: [], + ExpandHosts: false, + BogusPriv: false, + StrictOrder: false, + AllServers: false, + NoResolv: false, + DomainNeeded: false, + NoPoll: false, + BindInterfaces: false, + BindDynamic: false, + NoNegcache: false, + DnsLoopDetect: false, + StopDnsRebind: false, + RebindLocalhostOk: false, + ClearOnReload: false, + Filterwin2k: false, + FilterA: false, + FilterAaaa: false, + LocaliseQueries: false, + LogDebug: false, + DhcpAuthoritative: false, + LeasefileRo: false, + EnableTftp: false, + TftpSecure: false, + TftpNoFail: false, + TftpNoBlocksize: false, + Dnssec: false, + DnssecCheckUnsigned: null, + ReadEthers: false, + DhcpRapidCommit: false, + Localmx: false, + Selfmx: false, + EnableRa: false, + LogDhcp: false, + KeepInForeground: false, + NoDaemon: false, + ProxyDnssec: false, + ConnmarkAllowlistEnable: null, + NoRoundRobin: false, + DnssecNoTimecheck: false, + DnssecDebug: false, + LeasequeryValues: [], + DhcpGenerateNames: null, + DhcpBroadcast: null, + DhcpSequentialIp: false, + DhcpIgnoreClid: false, + BootpDynamic: null, + NoPing: false, + ScriptArp: false, + ScriptOnRenewal: false, + DhcpNoOverride: false, + QuietDhcp: false, + QuietDhcp6: false, + QuietRa: false, + QuietTftp: false, + DhcpLeaseFilePath: null, + CacheSize: null, + Port: null, + LocalTtl: null, + PidFilePath: null, + User: null, + Group: null, + LogFacility: null, + LogQueries: null, + AuthTtl: null, + EdnsPacketMax: null, + QueryPort: null, + PortLimit: null, + MinPort: null, + MaxPort: null, + LogAsync: null, + LocalService: null, + DhcpLeaseMax: null, + NegTtl: null, + MaxTtl: null, + MaxCacheTtl: null, + MinCacheTtl: null, + DhcpTtl: null, + TftpRootPath: null, + PxePrompt: null, + EnableDbus: null, + EnableUbus: null, + FastDnsRetry: null, + DhcpScriptPath: null, + MxTarget: null, + DnsForwardMax: null, + DumpfilePath: null, + Dumpmask: null, + AddCpeId: null, + DnssecTimestamp: null, + DnssecLimits: null, + DhcpAlternatePort: null, + DhcpDuid: null, + DhcpLuascriptPath: null, + DhcpScriptuser: null, + DhcpPxeVendor: null, + UseStaleCache: null, + AddMac: null, + StripMac: false, + AddSubnet: null, + StripSubnet: false, + Umbrella: null, + Do0x20EncodeState: ExplicitToggleState.Default, + Conntrack: false); + + private static EffectiveConfigSources MinimalSources() => + new( + NoHosts: null, + AddnHostsPaths: [], + HostsdirPath: null, + ServerValues: [], + LocalValues: [], + RevServerValues: [], + AddressValues: [], + Interfaces: [], + ListenAddresses: [], + ExceptInterfaces: [], + DhcpRanges: [], + DhcpHostLines: [], + DhcpOptionLines: [], + DhcpMatchValues: [], + DhcpBootValues: [], + DhcpIgnoreValues: [], + DhcpVendorclassValues: [], + DhcpUserclassValues: [], + RaParamValues: [], + SlaacValues: [], + PxeServiceValues: [], + TrustAnchorValues: [], + ResolvFiles: [], + RebindDomainOkValues: [], + BogusNxdomainValues: [], + IgnoreAddressValues: [], + AliasValues: [], + FilterRrValues: [], + CacheRrValues: [], + AuthServerValues: [], + NoDhcpInterfaceValues: [], + NoDhcpv4InterfaceValues: [], + NoDhcpv6InterfaceValues: [], + DomainValues: [], + CnameValues: [], + MxHostValues: [], + SrvValues: [], + PtrRecordValues: [], + TxtRecordValues: [], + NaptrRecordValues: [], + HostRecordValues: [], + DynamicHostValues: [], + InterfaceNameValues: [], + DhcpOptionForceLines: [], + IpsetValues: [], + NftsetValues: [], + DhcpMacValues: [], + DhcpNameMatchValues: [], + DhcpIgnoreNamesValues: [], + DhcpHostsfilePaths: [], + DhcpOptsfilePaths: [], + DhcpHostsdirPaths: [], + DhcpOptsdirPaths: [], + ConnmarkAllowlistValues: [], + CaaRecordValues: [], + DnsRrValues: [], + SynthDomainValues: [], + AuthZoneValues: [], + AuthSoaValues: [], + AuthSecServersValues: [], + AuthPeerValues: [], + DhcpRelayValues: [], + DhcpCircuitidValues: [], + DhcpRemoteidValues: [], + DhcpSubscridValues: [], + DhcpProxyValues: [], + TagIfValues: [], + BridgeInterfaceValues: [], + SharedNetworkValues: [], + DhcpOptionPxeValues: [], + ExpandHosts: null, + BogusPriv: null, + StrictOrder: null, + AllServers: null, + NoResolv: null, + DomainNeeded: null, + NoPoll: null, + BindInterfaces: null, + BindDynamic: null, + NoNegcache: null, + DnsLoopDetect: null, + StopDnsRebind: null, + RebindLocalhostOk: null, + ClearOnReload: null, + Filterwin2k: null, + FilterA: null, + FilterAaaa: null, + LocaliseQueries: null, + LogDebug: null, + DhcpAuthoritative: null, + LeasefileRo: null, + EnableTftp: null, + TftpSecure: null, + TftpNoFail: null, + TftpNoBlocksize: null, + Dnssec: null, + DnssecCheckUnsigned: null, + ReadEthers: null, + DhcpRapidCommit: null, + Localmx: null, + Selfmx: null, + EnableRa: null, + LogDhcp: null, + KeepInForeground: null, + NoDaemon: null, + ProxyDnssec: null, + ConnmarkAllowlistEnable: null, + NoRoundRobin: null, + DnssecNoTimecheck: null, + DnssecDebug: null, + LeasequeryValues: [], + DhcpGenerateNames: null, + DhcpBroadcast: null, + DhcpSequentialIp: null, + DhcpIgnoreClid: null, + BootpDynamic: null, + NoPing: null, + ScriptArp: null, + ScriptOnRenewal: null, + DhcpNoOverride: null, + QuietDhcp: null, + QuietDhcp6: null, + QuietRa: null, + QuietTftp: null, + DhcpLeaseFilePath: null, + CacheSize: null, + Port: null, + LocalTtl: null, + PidFilePath: null, + User: null, + Group: null, + LogFacility: null, + LogQueries: null, + AuthTtl: null, + EdnsPacketMax: null, + QueryPort: null, + PortLimit: null, + MinPort: null, + MaxPort: null, + LogAsync: null, + LocalService: null, + DhcpLeaseMax: null, + NegTtl: null, + MaxTtl: null, + MaxCacheTtl: null, + MinCacheTtl: null, + DhcpTtl: null, + TftpRootPath: null, + PxePrompt: null, + EnableDbus: null, + EnableUbus: null, + FastDnsRetry: null, + DhcpScriptPath: null, + MxTarget: null, + DnsForwardMax: null, + DumpfilePath: null, + Dumpmask: null, + AddCpeId: null, + DnssecTimestamp: null, + DnssecLimits: null, + DhcpAlternatePort: null, + DhcpDuid: null, + DhcpLuascriptPath: null, + DhcpScriptuser: null, + DhcpPxeVendor: null, + UseStaleCache: null, + AddMac: null, + StripMac: null, + AddSubnet: null, + StripSubnet: null, + Umbrella: null, + Do0x20Encode: null, + Conntrack: null); + private static DnsmasqServiceStatus MinimalStatus() => new( SystemHostsPath: null, diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveMultiValueProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveMultiValueProjectionServiceTests.cs new file mode 100644 index 0000000..71c11cc --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveMultiValueProjectionServiceTests.cs @@ -0,0 +1,39 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Tests.Services.EffectiveConfig; + +public class EffectiveMultiValueProjectionServiceTests +{ + private readonly EffectiveMultiValueProjectionService _sut = new(); + + [Fact] + public void Project_ReusesBaselineSources_ByOccurrenceOrder() + { + var baseline = new[] + { + new ValueWithSource("same", new ConfigValueSource("/a.conf", "a.conf", true, 10)), + new ValueWithSource("same", new ConfigValueSource("/b.conf", "b.conf", false, 20)) + }; + + var projected = _sut.Project(["same", "same"], baseline, "/managed.conf"); + + Assert.Equal("/a.conf", projected[0].Source?.FilePath); + Assert.Equal("/b.conf", projected[1].Source?.FilePath); + Assert.False(projected[0].IsDraftOnly); + Assert.False(projected[1].IsDraftOnly); + } + + [Fact] + public void Project_UnmatchedRows_AreDraftEditableAndPointAtManagedPath() + { + var projected = _sut.Project(["draft-value"], [], "/managed.conf"); + + var row = Assert.Single(projected); + Assert.True(row.IsDraftOnly); + Assert.True(row.IsEditable); + Assert.Null(row.Source); + Assert.Equal("/managed.conf", row.DisplaySourcePath); + Assert.Equal("managed.conf", row.DisplaySourceLabel); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/Filters/FiltersPageProjectionServiceTests.cs b/src/DnsmasqWebUI.Tests/Services/Filters/FiltersPageProjectionServiceTests.cs index d9453d3..955787c 100644 --- a/src/DnsmasqWebUI.Tests/Services/Filters/FiltersPageProjectionServiceTests.cs +++ b/src/DnsmasqWebUI.Tests/Services/Filters/FiltersPageProjectionServiceTests.cs @@ -1,4 +1,6 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Filters; @@ -427,7 +429,7 @@ public class FiltersPageProjectionServiceTests ["/ads.example/#"], [new ValueWithSource("/ads.example/#", managed)]); - var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter()); + var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService()); var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null)); var blocking = groups.First(g => g.Category == FilterPolicyCategory.Blocking); var row = Assert.Single(blocking.Rows); @@ -443,9 +445,30 @@ public class FiltersPageProjectionServiceTests ["/ads.example/#"], [new ValueWithSource("/ads.example/#", ro)]); - var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter()); + var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService()); var groups = svc.BuildGroups(status, new FilterPolicyQueryState("", null, null, null, null)); var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows); Assert.False(row.IsEditable); } + + [Fact] + public void BuildGroups_CurrentValuesAccessor_UsesDraftManagedRows() + { + var ro = new ConfigValueSource("/etc/dnsmasq.d/z.conf", "z.conf", IsManaged: false, 2); + var status = MinimalStatus( + ["/ads.example/#"], + [new ValueWithSource("/ads.example/#", ro)]); + + var svc = new FiltersPageProjectionService(new FilterPolicySummaryFormatter(), new EffectiveMultiValueProjectionService()); + var groups = svc.BuildGroups( + status, + new FilterPolicyQueryState("", null, null, null, null), + optionName => optionName == DnsmasqConfKeys.Address ? ["/draft.example/#"] : []); + + var row = Assert.Single(groups.First(g => g.Category == FilterPolicyCategory.Blocking).Rows); + Assert.Equal("/draft.example/#", row.RawValue); + Assert.True(row.IsEditable); + Assert.True(row.IsDraftOnly); + Assert.Equal("/managed.conf", row.SourcePath); + } } diff --git a/src/DnsmasqWebUI/Components/DnsRecords/DnsRecordsTable.razor b/src/DnsmasqWebUI/Components/DnsRecords/DnsRecordsTable.razor index 60e9728..64f3edf 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/DnsRecordsTable.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/DnsRecordsTable.razor @@ -73,21 +73,25 @@ else @foreach (var row in Rows) { - + @row.OptionName
@row.Summary
@row.RawValue - @if (row.Source == null) + @if (string.IsNullOrWhiteSpace(row.SourcePath)) { — } else { - @row.Source.FileName - @if (row.Source.IsReadOnly) + @(row.SourceLabel ?? row.SourcePath) + @if (row.IsDraftOnly) + { + draft + } + else if (row.Source?.IsReadOnly == true) { read-only } diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/AdvancedDnsRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/AdvancedDnsRecordEditor.razor index a58ca1e..5239103 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/Editors/AdvancedDnsRecordEditor.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/AdvancedDnsRecordEditor.razor @@ -233,11 +233,15 @@ else if (OptionName == DnsmasqConfKeys.AuthPeer) var row = isNew ? new DnsRecordRow( Id: $"{OptionName}:new", + OccurrenceId: $"{OptionName}:new", OptionName: OptionName, Family: fam, IndexInOption: -1, RawValue: "", Source: null, + SourcePath: null, + SourceLabel: null, + IsDraftOnly: false, IsEditable: true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/CnameRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/CnameRecordEditor.razor index 57f8fca..7e10d9c 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/Editors/CnameRecordEditor.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/CnameRecordEditor.razor @@ -82,11 +82,15 @@ var row = isNew ? new DnsRecordRow( Id: $"{DnsmasqConfKeys.Cname}:new", + OccurrenceId: $"{DnsmasqConfKeys.Cname}:new", OptionName: DnsmasqConfKeys.Cname, Family: DnsRecordFamily.Cname, IndexInOption: -1, RawValue: "", Source: null, + SourcePath: null, + SourceLabel: null, + IsDraftOnly: false, IsEditable: true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/HostRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/HostRecordEditor.razor index 93839b4..d281104 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/Editors/HostRecordEditor.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/HostRecordEditor.razor @@ -91,11 +91,15 @@ var row = isNew ? new DnsRecordRow( Id: $"{DnsmasqConfKeys.HostRecord}:new", + OccurrenceId: $"{DnsmasqConfKeys.HostRecord}:new", OptionName: DnsmasqConfKeys.HostRecord, Family: DnsRecordFamily.HostRecord, IndexInOption: -1, RawValue: "", Source: null, + SourcePath: null, + SourceLabel: null, + IsDraftOnly: false, IsEditable: true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/MxRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/MxRecordEditor.razor index 1ac71d3..3dc38bf 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/Editors/MxRecordEditor.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/MxRecordEditor.razor @@ -82,11 +82,15 @@ var row = isNew ? new DnsRecordRow( Id: $"{DnsmasqConfKeys.MxHost}:new", + OccurrenceId: $"{DnsmasqConfKeys.MxHost}:new", OptionName: DnsmasqConfKeys.MxHost, Family: DnsRecordFamily.Mx, IndexInOption: -1, RawValue: "", Source: null, + SourcePath: null, + SourceLabel: null, + IsDraftOnly: false, IsEditable: true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/PtrRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/PtrRecordEditor.razor index 0111f9a..07dc425 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/Editors/PtrRecordEditor.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/PtrRecordEditor.razor @@ -62,11 +62,15 @@ var row = isNew ? new DnsRecordRow( Id: $"{DnsmasqConfKeys.PtrRecord}:new", + OccurrenceId: $"{DnsmasqConfKeys.PtrRecord}:new", OptionName: DnsmasqConfKeys.PtrRecord, Family: DnsRecordFamily.Ptr, IndexInOption: -1, RawValue: "", Source: null, + SourcePath: null, + SourceLabel: null, + IsDraftOnly: false, IsEditable: true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/SrvRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/SrvRecordEditor.razor index f4d1085..824f142 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/Editors/SrvRecordEditor.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/SrvRecordEditor.razor @@ -112,11 +112,15 @@ var row = isNew ? new DnsRecordRow( Id: $"{DnsmasqConfKeys.Srv}:new", + OccurrenceId: $"{DnsmasqConfKeys.Srv}:new", OptionName: DnsmasqConfKeys.Srv, Family: DnsRecordFamily.Srv, IndexInOption: -1, RawValue: "", Source: null, + SourcePath: null, + SourceLabel: null, + IsDraftOnly: false, IsEditable: true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Components/DnsRecords/Editors/TxtRecordEditor.razor b/src/DnsmasqWebUI/Components/DnsRecords/Editors/TxtRecordEditor.razor index daf3d49..11ade15 100644 --- a/src/DnsmasqWebUI/Components/DnsRecords/Editors/TxtRecordEditor.razor +++ b/src/DnsmasqWebUI/Components/DnsRecords/Editors/TxtRecordEditor.razor @@ -62,11 +62,15 @@ var row = isNew ? new DnsRecordRow( Id: $"{DnsmasqConfKeys.TxtRecord}:new", + OccurrenceId: $"{DnsmasqConfKeys.TxtRecord}:new", OptionName: DnsmasqConfKeys.TxtRecord, Family: DnsRecordFamily.Txt, IndexInOption: -1, RawValue: "", Source: null, + SourcePath: null, + SourceLabel: null, + IsDraftOnly: false, IsEditable: true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Components/Filters/FilterPolicyTable.razor b/src/DnsmasqWebUI/Components/Filters/FilterPolicyTable.razor index ec2f257..49b0c26 100644 --- a/src/DnsmasqWebUI/Components/Filters/FilterPolicyTable.razor +++ b/src/DnsmasqWebUI/Components/Filters/FilterPolicyTable.razor @@ -27,7 +27,7 @@ else @foreach (var row in Items) { - + @row.Title
@row.Summary
@@ -36,14 +36,18 @@ else @row.RawValue - @if (row.Source == null) + @if (string.IsNullOrWhiteSpace(row.SourcePath)) { — } else { - @row.Source.FileName - @if (row.IsEditable) + @(row.SourceLabel ?? row.SourcePath) + @if (row.IsDraftOnly) + { + + } + else if (row.IsEditable) { } @@ -52,7 +56,7 @@ else + Title="@row.Source?.GetReadOnlyTooltip()" /> } } diff --git a/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor index 2929f22..4c63f9b 100644 --- a/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor +++ b/src/DnsmasqWebUI/Components/Pages/DnsRecords.razor @@ -82,6 +82,10 @@ else Filter and edit multi-value DNS directives with plain-language summaries. Use category chips to narrow the view; on narrow screens, use the table’s horizontal scroll cues. Prefer Effective configuration for options that do not need a specialized form.

+ @if (_recordsError != null) + { +
@_recordsError
+ }
@@ -275,8 +279,9 @@ else private bool _mxDefaultsAllReadOnly = true; private string? _mxTargetTitle; private string? _mxDefaultsError; + private string? _recordsError; - private DnsRecordsFileFilterBuilder FileFilterBuilder => new(_allRows); + private DnsRecordsFileFilterBuilder FileFilterBuilder => new(_allRows, _status?.ManagedFilePath); private bool EditorOpen => _editExisting != null || _addCommonFamily != null || _addAdvancedOption != null; @@ -319,6 +324,7 @@ else private void OpenAddCommon(DnsRecordFamily family) { PageEditor.EnsureEditMode(); + _recordsError = null; _addCommonFamily = family; _addAdvancedOption = null; _editExisting = null; @@ -327,6 +333,7 @@ else private void OpenAddAdvanced(string optionName) { PageEditor.EnsureEditMode(); + _recordsError = null; _addAdvancedOption = optionName; _addCommonFamily = null; _editExisting = null; @@ -337,6 +344,7 @@ else if (!row.IsEditable) return; PageEditor.EnsureEditMode(); + _recordsError = null; _editExisting = row; _addCommonFamily = null; _addAdvancedOption = null; @@ -357,6 +365,7 @@ else { if (_status == null) return; + _recordsError = null; var codec = DnsRecordCodecs.Get(result.Row.OptionName); var serialized = codec.Serialize(result.Row); var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, result.Row.OptionName); @@ -366,16 +375,17 @@ else list.Add(serialized); else { - var idx = result.Row.IndexInOption; - if (idx >= 0 && idx < list.Count && string.Equals(list[idx], result.Row.RawValue, StringComparison.Ordinal)) + if (TryLocateOccurrenceIndex(result.Row.OptionName, list, result.Row.OccurrenceId, out var idx) + && idx >= 0 + && idx < list.Count + && string.Equals(list[idx], result.Row.RawValue, StringComparison.Ordinal)) + { list[idx] = serialized; + } else { - var i = list.FindIndex(v => string.Equals(v, result.Row.RawValue, StringComparison.Ordinal)); - if (i >= 0) - list[i] = serialized; - else - list.Add(serialized); + _recordsError = "This DNS record changed while you were editing it. Reopen the editor and try again."; + return; } } @@ -389,16 +399,20 @@ else { if (_status == null || !row.IsEditable) return; + _recordsError = null; var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, row.OptionName); var list = PageEditor.GetEffectiveMultiValues(_status, field).ToList(); - var idx = row.IndexInOption; - if (idx >= 0 && idx < list.Count && string.Equals(list[idx], row.RawValue, StringComparison.Ordinal)) + if (TryLocateOccurrenceIndex(row.OptionName, list, row.OccurrenceId, out var idx) + && idx >= 0 + && idx < list.Count + && string.Equals(list[idx], row.RawValue, StringComparison.Ordinal)) + { list.RemoveAt(idx); + } else { - var i = list.FindIndex(v => string.Equals(v, row.RawValue, StringComparison.Ordinal)); - if (i >= 0) - list.RemoveAt(i); + _recordsError = "This DNS record changed before it could be removed. Refresh the table and try again."; + return; } PageEditor.ReplaceMultiValues(_status, field, list); @@ -406,6 +420,20 @@ else await RefreshStatusAsync(); } + private bool TryLocateOccurrenceIndex(string optionName, IReadOnlyList currentValues, string occurrenceId, out int index) + { + var projected = DnsRecordsProjection.ProjectOccurrences(_status!, optionName, currentValues); + var match = projected.FirstOrDefault(p => string.Equals(p.OccurrenceId, occurrenceId, StringComparison.Ordinal)); + if (match == null) + { + index = -1; + return false; + } + + index = match.EffectiveIndex; + return true; + } + private void SyncMxDraftsFromStatus() { if (_status == null) @@ -466,7 +494,13 @@ else private void RebuildRows() { - _allRows = _status == null ? [] : DnsRecordsProjection.BuildRows(_status); + _allRows = _status == null + ? [] + : DnsRecordsProjection.BuildRows( + _status, + optionName => PageEditor.GetEffectiveMultiValues( + _status, + PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, optionName))); } private async Task LoadAsync() diff --git a/src/DnsmasqWebUI/Components/Pages/Filters.razor b/src/DnsmasqWebUI/Components/Pages/Filters.razor index 60e18c4..48ec4dc 100644 --- a/src/DnsmasqWebUI/Components/Pages/Filters.razor +++ b/src/DnsmasqWebUI/Components/Pages/Filters.razor @@ -322,9 +322,11 @@ else private readonly AddressEditorModel _addressModel = new(); private string? _addressEditingRaw; + private string? _addressEditingOccurrenceId; private readonly SplitDnsEditorModel _splitModel = new(); private string? _splitEditingRaw; + private string? _splitEditingOccurrenceId; private FilterPolicyKind? _splitEditingKind; private string _rebindDomain = ""; @@ -342,6 +344,7 @@ else private string _connDomain = ""; private string? _simpleEditOriginal; + private string? _simpleEditOccurrenceId; private FilterPolicyKind? _simpleEditKind; private bool ShowSection(FilterPolicyCategory c) => @@ -374,7 +377,10 @@ else return; } - _groups = FiltersProjection.BuildGroups(_status, BuildQuery()); + _groups = FiltersProjection.BuildGroups( + _status, + BuildQuery(), + optionName => PageEditor.GetEffectiveMultiValues(_status, PageEditor.Field(Resolver, optionName))); } private void RebuildSourceBuilder() @@ -386,7 +392,10 @@ else } var flat = FiltersProjection - .BuildGroups(_status, new FilterPolicyQueryState("", null, null, null, null)) + .BuildGroups( + _status, + new FilterPolicyQueryState("", null, null, null, null), + optionName => PageEditor.GetEffectiveMultiValues(_status, PageEditor.Field(Resolver, optionName))) .SelectMany(g => g.Rows) .ToList(); _sourceFilterBuilder = new FilterSourceFileFilterBuilder(flat, _status.ManagedFilePath); @@ -412,7 +421,8 @@ else return Task.CompletedTask; } - ReplaceOrAppend(DnsmasqConfKeys.Address, line.Trim(), _addressEditingRaw); + if (!ReplaceOrAppend(DnsmasqConfKeys.Address, line.Trim(), _addressEditingRaw, _addressEditingOccurrenceId)) + return Task.CompletedTask; CancelAddressEdit(); return Task.CompletedTask; } @@ -420,6 +430,7 @@ else private void CancelAddressEdit() { _addressEditingRaw = null; + _addressEditingOccurrenceId = null; _addressModel.ResetForAdd(); StateHasChanged(); } @@ -448,14 +459,17 @@ else if (originalOption != null && !string.Equals(originalOption, newOption, StringComparison.Ordinal)) { - PageEditor.RemoveMultiValue(_status!, PageEditor.Field(Resolver, originalOption), _splitEditingRaw); - ReplaceOrAppend(newOption, trimmed, null); + if (!TryRemoveOccurrence(originalOption, _splitEditingRaw, _splitEditingOccurrenceId)) + return Task.CompletedTask; + if (!ReplaceOrAppend(newOption, trimmed, null, null)) + return Task.CompletedTask; CancelSplitEdit(); return Task.CompletedTask; } } - ReplaceOrAppend(newOption, trimmed, _splitEditingRaw); + if (!ReplaceOrAppend(newOption, trimmed, _splitEditingRaw, _splitEditingOccurrenceId)) + return Task.CompletedTask; CancelSplitEdit(); return Task.CompletedTask; } @@ -463,6 +477,7 @@ else private void CancelSplitEdit() { _splitEditingRaw = null; + _splitEditingOccurrenceId = null; _splitEditingKind = null; _splitModel.Reset(); StateHasChanged(); @@ -478,7 +493,8 @@ else return; } - ReplaceOrAppend(DnsmasqConfKeys.RebindDomainOk, d, _simpleEditKind == FilterPolicyKind.RebindDomainOk ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.RebindDomainOk, d, _simpleEditKind == FilterPolicyKind.RebindDomainOk ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.RebindDomainOk ? _simpleEditOccurrenceId : null)) + return; _rebindDomain = ""; ClearSimpleEdit(); } @@ -493,7 +509,8 @@ else return; } - ReplaceOrAppend(DnsmasqConfKeys.BogusNxdomain, v, _simpleEditKind == FilterPolicyKind.BogusNxdomain ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.BogusNxdomain, v, _simpleEditKind == FilterPolicyKind.BogusNxdomain ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.BogusNxdomain ? _simpleEditOccurrenceId : null)) + return; _bogusAddr = ""; ClearSimpleEdit(); } @@ -508,7 +525,8 @@ else return; } - ReplaceOrAppend(DnsmasqConfKeys.IgnoreAddress, v, _simpleEditKind == FilterPolicyKind.IgnoreAddress ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.IgnoreAddress, v, _simpleEditKind == FilterPolicyKind.IgnoreAddress ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.IgnoreAddress ? _simpleEditOccurrenceId : null)) + return; _ignoreAddr = ""; ClearSimpleEdit(); } @@ -525,7 +543,8 @@ else } var line = $"{m},{t}"; - ReplaceOrAppend(DnsmasqConfKeys.FilterRr, line, _simpleEditKind == FilterPolicyKind.FilterRr ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.FilterRr, line, _simpleEditKind == FilterPolicyKind.FilterRr ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.FilterRr ? _simpleEditOccurrenceId : null)) + return; _filterRrMatch = ""; _filterRrType = ""; ClearSimpleEdit(); @@ -542,7 +561,8 @@ else return; } - ReplaceOrAppend(DnsmasqConfKeys.Alias, $"{a},{b}", _simpleEditKind == FilterPolicyKind.Alias ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.Alias, $"{a},{b}", _simpleEditKind == FilterPolicyKind.Alias ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.Alias ? _simpleEditOccurrenceId : null)) + return; _aliasLeft = ""; _aliasRight = ""; ClearSimpleEdit(); @@ -560,7 +580,8 @@ else } var line = $"/{d}/{n}"; - ReplaceOrAppend(DnsmasqConfKeys.Ipset, line, _simpleEditKind == FilterPolicyKind.Ipset ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.Ipset, line, _simpleEditKind == FilterPolicyKind.Ipset ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.Ipset ? _simpleEditOccurrenceId : null)) + return; _ipsetDomain = ""; _ipsetName = ""; ClearSimpleEdit(); @@ -578,7 +599,8 @@ else } var line = $"/{d}/{sp}"; - ReplaceOrAppend(DnsmasqConfKeys.Nftset, line, _simpleEditKind == FilterPolicyKind.Nftset ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.Nftset, line, _simpleEditKind == FilterPolicyKind.Nftset ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.Nftset ? _simpleEditOccurrenceId : null)) + return; _nftDomain = ""; _nftSpecs = ""; ClearSimpleEdit(); @@ -595,23 +617,29 @@ else return; } - ReplaceOrAppend(DnsmasqConfKeys.ConnmarkAllowlist, $"{m},{d}", _simpleEditKind == FilterPolicyKind.ConnmarkAllowlist ? _simpleEditOriginal : null); + if (!ReplaceOrAppend(DnsmasqConfKeys.ConnmarkAllowlist, $"{m},{d}", _simpleEditKind == FilterPolicyKind.ConnmarkAllowlist ? _simpleEditOriginal : null, _simpleEditKind == FilterPolicyKind.ConnmarkAllowlist ? _simpleEditOccurrenceId : null)) + return; _connMark = ""; _connDomain = ""; ClearSimpleEdit(); } - private void ReplaceOrAppend(string option, string line, string? original) + private bool ReplaceOrAppend(string option, string line, string? original, string? occurrenceId) { var field = PageEditor.Field(Resolver, option); var current = PageEditor.GetEffectiveMultiValues(_status!, field).ToList(); - if (original != null) + if (occurrenceId != null && original != null) { - var i = current.FindIndex(x => string.Equals(x, original, StringComparison.Ordinal)); - if (i >= 0) - current[i] = line; - else if (!current.Contains(line, StringComparer.Ordinal)) - current.Add(line); + if (!TryLocateOccurrenceIndex(option, current, occurrenceId, out var i) + || i < 0 + || i >= current.Count + || !string.Equals(current[i], original, StringComparison.Ordinal)) + { + _filterError = "This rule changed while you were editing it. Reopen the editor and try again."; + return false; + } + + current[i] = line; } else if (!current.Contains(line, StringComparer.Ordinal)) current.Add(line); @@ -619,6 +647,44 @@ else PageEditor.ReplaceMultiValues(_status!, field, current); PageEditor.Activate(field); RefreshAfterMutation(); + return true; + } + + private bool TryRemoveOccurrence(string option, string? expectedRaw, string? occurrenceId) + { + if (occurrenceId == null) + return false; + + var field = PageEditor.Field(Resolver, option); + var current = PageEditor.GetEffectiveMultiValues(_status!, field).ToList(); + if (!TryLocateOccurrenceIndex(option, current, occurrenceId, out var idx) + || idx < 0 + || idx >= current.Count + || (expectedRaw != null && !string.Equals(current[idx], expectedRaw, StringComparison.Ordinal))) + { + _filterError = "This rule changed before it could be removed. Refresh the table and try again."; + return false; + } + + current.RemoveAt(idx); + PageEditor.ReplaceMultiValues(_status!, field, current); + PageEditor.Activate(field); + RefreshAfterMutation(); + return true; + } + + private bool TryLocateOccurrenceIndex(string option, IReadOnlyList current, string occurrenceId, out int index) + { + var projected = FiltersProjection.ProjectOccurrences(_status!, option, current); + var match = projected.FirstOrDefault(p => string.Equals(p.OccurrenceId, occurrenceId, StringComparison.Ordinal)); + if (match == null) + { + index = -1; + return false; + } + + index = match.EffectiveIndex; + return true; } private void RefreshAfterMutation() @@ -639,35 +705,42 @@ else case FilterPolicyKind.Address: _addressModel.LoadFrom(row.RawValue); _addressEditingRaw = row.RawValue; + _addressEditingOccurrenceId = row.OccurrenceId; break; case FilterPolicyKind.Server: _splitModel.LoadFromServerValue(row.RawValue); _splitEditingRaw = row.RawValue; + _splitEditingOccurrenceId = row.OccurrenceId; _splitEditingKind = row.Kind; break; case FilterPolicyKind.RevServer: _splitModel.LoadFromRevServerValue(row.RawValue); _splitEditingRaw = row.RawValue; + _splitEditingOccurrenceId = row.OccurrenceId; _splitEditingKind = row.Kind; break; case FilterPolicyKind.Local: _splitModel.LoadFromLocalValue(row.RawValue); _splitEditingRaw = row.RawValue; + _splitEditingOccurrenceId = row.OccurrenceId; _splitEditingKind = row.Kind; break; case FilterPolicyKind.RebindDomainOk: _rebindDomain = row.RawValue.Trim(); _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; case FilterPolicyKind.BogusNxdomain: _bogusAddr = row.RawValue.Trim(); _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; case FilterPolicyKind.IgnoreAddress: _ignoreAddr = row.RawValue.Trim(); _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; case FilterPolicyKind.FilterRr: @@ -684,17 +757,24 @@ else } _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; case FilterPolicyKind.Alias: - var ap = row.RawValue.Split(',', 2); - if (ap.Length == 2) + var ap = AliasRuleFields.Parse(row.RawValue); + if (!ap.IsRaw) { - _aliasLeft = ap[0].Trim(); - _aliasRight = ap[1].Trim(); + _aliasLeft = ap.Left; + _aliasRight = ap.Right; + } + else + { + _aliasLeft = row.RawValue.Trim(); + _aliasRight = ""; } _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; case FilterPolicyKind.Ipset: @@ -704,8 +784,14 @@ else _ipsetDomain = ip.DomainPath; _ipsetName = ip.SetName; } + else + { + _ipsetDomain = row.RawValue.Trim(); + _ipsetName = ""; + } _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; case FilterPolicyKind.Nftset: @@ -715,8 +801,14 @@ else _nftDomain = nf.DomainPath; _nftSpecs = nf.SetSpecs; } + else + { + _nftDomain = row.RawValue.Trim(); + _nftSpecs = ""; + } _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; case FilterPolicyKind.ConnmarkAllowlist: @@ -726,8 +818,14 @@ else _connMark = cm.Mark; _connDomain = cm.Domain; } + else + { + _connMark = row.RawValue.Trim(); + _connDomain = ""; + } _simpleEditOriginal = row.RawValue; + _simpleEditOccurrenceId = row.OccurrenceId; _simpleEditKind = row.Kind; break; } @@ -743,14 +841,14 @@ else var option = OptionNameForKind(row.Kind); if (option == null) return; - PageEditor.RemoveMultiValue(_status!, PageEditor.Field(Resolver, option), row.RawValue); - if (_splitEditingRaw != null && string.Equals(_splitEditingRaw, row.RawValue, StringComparison.Ordinal)) + if (!TryRemoveOccurrence(option, row.RawValue, row.OccurrenceId)) + return; + if (_splitEditingOccurrenceId != null && string.Equals(_splitEditingOccurrenceId, row.OccurrenceId, StringComparison.Ordinal)) CancelSplitEdit(); - if (_addressEditingRaw != null && string.Equals(_addressEditingRaw, row.RawValue, StringComparison.Ordinal)) + if (_addressEditingOccurrenceId != null && string.Equals(_addressEditingOccurrenceId, row.OccurrenceId, StringComparison.Ordinal)) CancelAddressEdit(); - if (_simpleEditOriginal != null && string.Equals(_simpleEditOriginal, row.RawValue, StringComparison.Ordinal)) + if (_simpleEditOccurrenceId != null && string.Equals(_simpleEditOccurrenceId, row.OccurrenceId, StringComparison.Ordinal)) ClearSimpleEdit(); - RefreshAfterMutation(); } private static string? OptionNameForKind(FilterPolicyKind k) => k switch @@ -773,6 +871,7 @@ else private void ClearSimpleEdit() { _simpleEditOriginal = null; + _simpleEditOccurrenceId = null; _simpleEditKind = null; } diff --git a/src/DnsmasqWebUI/DnsmasqWebUI.csproj b/src/DnsmasqWebUI/DnsmasqWebUI.csproj index de29d44..8d3485b 100644 --- a/src/DnsmasqWebUI/DnsmasqWebUI.csproj +++ b/src/DnsmasqWebUI/DnsmasqWebUI.csproj @@ -3,7 +3,7 @@ net10.0 enable - 0.0.8 + 0.0.9 enable true diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs index 48ffb61..dbac5b3 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Dhcp/DhcpPageProjectionService.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Sockets; using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Models.Dhcp; using DnsmasqWebUI.Models.Dhcp.Ui; @@ -12,60 +13,56 @@ namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Dhcp; public sealed class DhcpPageProjectionService : IDhcpPageProjectionService { + private readonly IEffectiveMultiValueProjectionService _multiValueProjection; + + public DhcpPageProjectionService(IEffectiveMultiValueProjectionService multiValueProjection) + { + _multiValueProjection = multiValueProjection; + } + public IReadOnlyList BuildHostRows( DnsmasqServiceStatus status, IReadOnlyList effectiveDhcpHostValues, IStructuredOptionValueHandler handler, IReadOnlyList? leases = null) { - var baseline = status.EffectiveConfigSources?.DhcpHostLines; - var usedBaseline = baseline != null ? new bool[baseline.Count] : Array.Empty(); + var projected = _multiValueProjection.Project( + effectiveDhcpHostValues, + status.EffectiveConfigSources?.DhcpHostLines, + status.ManagedFilePath); var rows = new List(effectiveDhcpHostValues.Count); - for (var i = 0; i < effectiveDhcpHostValues.Count; i++) + for (var i = 0; i < projected.Count; i++) { - var valueString = effectiveDhcpHostValues[i]; - ConfigValueSource? matchedSource = null; - if (baseline != null) - { - for (var j = 0; j < baseline.Count; j++) - { - if (usedBaseline[j]) continue; - if (string.Equals(baseline[j].Value, valueString, StringComparison.Ordinal)) - { - matchedSource = baseline[j].Source; - usedBaseline[j] = true; - break; - } - } - } + var item = projected[i]; + var valueString = item.Value; - var parsedOk = handler.TryParseValue(valueString, i + 1, out var entry) && entry != null; + var parsedOk = handler.TryParseValue(valueString, item.EffectiveIndex + 1, out var entry) && entry != null; var e = parsedOk ? entry! : new DhcpHostEntry { - LineNumber = i + 1, + LineNumber = item.EffectiveIndex + 1, IsComment = true, RawLine = valueString, MacAddresses = new List() }; - e.SourcePath = matchedSource?.FilePath; - var isUnmatchedDraft = matchedSource == null; - var isManagedSource = matchedSource?.IsManaged == true || isUnmatchedDraft; - var sourceKind = isUnmatchedDraft + e.SourcePath = item.DisplaySourcePath; + var sourceKind = item.IsDraftOnly ? DhcpSourceKind.Managed - : ClassifySourceKind(matchedSource!.FilePath, matchedSource.IsManaged, status); - var isEditable = isManagedSource; + : ClassifySourceKind(item.Source?.FilePath, item.Source?.IsManaged == true, status); + var isEditable = item.IsEditable; e.IsEditable = isEditable; rows.Add(new DhcpHostPageRow( - EffectiveIndex: i, + EffectiveIndex: item.EffectiveIndex, ValueString: valueString, - RowKey: $"dhcp-host:{i}:{Fnv1aHash(valueString)}", + RowKey: $"dhcp-host:{item.EffectiveIndex}:{Fnv1aHash(valueString)}", + OccurrenceId: item.OccurrenceId, SourceKind: sourceKind, - SourcePath: matchedSource?.FilePath ?? status.ManagedFilePath ?? "", + SourcePath: item.DisplaySourcePath ?? status.ManagedFilePath ?? "", + IsDraftOnly: item.IsDraftOnly, IsEditable: isEditable, IsActive: !e.IsDeleted && !e.IsComment, Entry: e, diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordsPageProjectionService.cs index c0a5023..4308a86 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordsPageProjectionService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/Abstractions/IDnsRecordsPageProjectionService.cs @@ -1,5 +1,6 @@ using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.DnsRecords; namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; @@ -7,7 +8,14 @@ namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; /// Builds and filters typed rows for the DNS records page. public interface IDnsRecordsPageProjectionService : IApplicationSingleton { - IReadOnlyList BuildRows(DnsmasqServiceStatus status); + IReadOnlyList BuildRows( + DnsmasqServiceStatus status, + Func>? currentValuesAccessor = null); + + IReadOnlyList ProjectOccurrences( + DnsmasqServiceStatus status, + string optionName, + IReadOnlyList currentValues); IReadOnlyList FilterRows(IReadOnlyList rows, DnsRecordsQueryState query); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecs.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecs.cs index 78bac0c..7fd0141 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecs.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordDirectiveCodecs.cs @@ -21,11 +21,15 @@ internal static class DnsRecordCodecHelpers string summary) => new( Id: $"{optionName}:{index}", + OccurrenceId: $"{optionName}:{index}", OptionName: optionName, Family: family, IndexInOption: index, RawValue: raw, Source: source, + SourcePath: source?.FilePath, + SourceLabel: source?.FileName, + IsDraftOnly: false, IsEditable: source?.IsReadOnly != true, Payload: payload, Issues: [], diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionService.cs index b266c0e..a742b36 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/DnsRecords/DnsRecordsPageProjectionService.cs @@ -1,5 +1,6 @@ using System.Linq; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.DnsRecords.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions; using DnsmasqWebUI.Models.Dnsmasq; @@ -12,43 +13,57 @@ public sealed class DnsRecordsPageProjectionService : IDnsRecordsPageProjectionS { private readonly IDnsRecordDirectiveCodecProvider _codecs; private readonly IOptionSemanticValidator _semanticValidator; + private readonly IEffectiveMultiValueProjectionService _multiValueProjection; private readonly DnsRecordConflictAnalyzer _conflicts = new(); public DnsRecordsPageProjectionService( IDnsRecordDirectiveCodecProvider codecs, - IOptionSemanticValidator semanticValidator) + IOptionSemanticValidator semanticValidator, + IEffectiveMultiValueProjectionService multiValueProjection) { _codecs = codecs; _semanticValidator = semanticValidator; + _multiValueProjection = multiValueProjection; } /// - public IReadOnlyList BuildRows(DnsmasqServiceStatus status) + public IReadOnlyList BuildRows( + DnsmasqServiceStatus status, + Func>? currentValuesAccessor = null) { if (status.EffectiveConfig == null) return []; - var ec = status.EffectiveConfig; var list = new List(); foreach (var optionName in _codecs.DnsRecordsSectionOptionNames) { if (!_codecs.TryGet(optionName, out var codec) || codec == null) continue; - var items = GetValueWithSources(status, ec, optionName); + var currentValues = currentValuesAccessor?.Invoke(optionName) ?? GetPlainValues(status.EffectiveConfig, optionName); + var items = ProjectOccurrences(status, optionName, currentValues); for (var i = 0; i < items.Count; i++) { - var row = codec.Parse(items[i], i); + var item = items[i]; + var row = codec.Parse(new ValueWithSource(item.Value, item.Source), item.EffectiveIndex); var issues = new List(); var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(optionName); if (semantics != null) { - var err = _semanticValidator.ValidateMultiItem(optionName, items[i].Value, semantics.Validation); + var err = _semanticValidator.ValidateMultiItem(optionName, item.Value, semantics.Validation); if (err != null) issues.Add(new DnsRecordIssue(err, semantics.Validation.Severity)); } - row = row with { Issues = issues }; + row = row with + { + OccurrenceId = item.OccurrenceId, + Issues = issues, + SourcePath = item.DisplaySourcePath, + SourceLabel = item.DisplaySourceLabel, + IsDraftOnly = item.IsDraftOnly, + IsEditable = item.IsEditable + }; list.Add(row); } } @@ -77,7 +92,7 @@ public sealed class DnsRecordsPageProjectionService : IDnsRecordsPageProjectionS if (!string.IsNullOrWhiteSpace(query.SourcePathFilter)) { var path = query.SourcePathFilter.Trim(); - q = q.Where(r => string.Equals(r.Source?.FilePath, path, StringComparison.OrdinalIgnoreCase)); + q = q.Where(r => string.Equals(r.SourcePath, path, StringComparison.OrdinalIgnoreCase)); } if (query.UiFamily != DnsRecordsUiFamily.All) @@ -94,6 +109,17 @@ public sealed class DnsRecordsPageProjectionService : IDnsRecordsPageProjectionS return q.ToList(); } + public IReadOnlyList ProjectOccurrences( + DnsmasqServiceStatus status, + string optionName, + IReadOnlyList currentValues) + { + return _multiValueProjection.Project( + currentValues, + GetBaselineValues(status, optionName), + status.ManagedFilePath); + } + private static bool MatchesUiFamily(DnsRecordRow row, DnsRecordsUiFamily ui) => ui switch { DnsRecordsUiFamily.Advanced => row.Family.IsAdvancedFamily(), @@ -113,18 +139,17 @@ public sealed class DnsRecordsPageProjectionService : IDnsRecordsPageProjectionS row.OptionName, row.RawValue, row.Summary, - row.Source?.FilePath ?? "", - row.Source?.FileName ?? ""); + row.SourcePath ?? "", + row.SourceLabel ?? ""); return haystack.Contains(needle, StringComparison.OrdinalIgnoreCase); } - private static IReadOnlyList GetValueWithSources( + private static IReadOnlyList? GetBaselineValues( DnsmasqServiceStatus status, - EffectiveDnsmasqConfig ec, string optionName) { var sources = status.EffectiveConfigSources; - IReadOnlyList? with = optionName switch + return optionName switch { DnsmasqConfKeys.Cname => sources?.CnameValues, DnsmasqConfKeys.MxHost => sources?.MxHostValues, @@ -144,10 +169,12 @@ public sealed class DnsRecordsPageProjectionService : IDnsRecordsPageProjectionS DnsmasqConfKeys.AuthPeer => sources?.AuthPeerValues, _ => null }; + } - if (with != null && with.Count > 0) - return with; - + private static IReadOnlyList GetPlainValues( + EffectiveDnsmasqConfig ec, + string optionName) + { IReadOnlyList? plain = optionName switch { DnsmasqConfKeys.Cname => ec.CnameValues, @@ -171,7 +198,6 @@ public sealed class DnsRecordsPageProjectionService : IDnsRecordsPageProjectionS if (plain == null || plain.Count == 0) return []; - - return plain.Select(v => new ValueWithSource(v, null)).ToList(); + return plain; } } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFiltersPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFiltersPageProjectionService.cs index 395d1f7..fc6dcc0 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFiltersPageProjectionService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/Abstractions/IFiltersPageProjectionService.cs @@ -1,10 +1,19 @@ using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; using DnsmasqWebUI.Models.Dnsmasq; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Filters; namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions; public interface IFiltersPageProjectionService : IApplicationSingleton { - IReadOnlyList BuildGroups(DnsmasqServiceStatus status, FilterPolicyQueryState query); + IReadOnlyList BuildGroups( + DnsmasqServiceStatus status, + FilterPolicyQueryState query, + Func>? currentValuesAccessor = null); + + IReadOnlyList ProjectOccurrences( + DnsmasqServiceStatus status, + string optionName, + IReadOnlyList currentValues); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FiltersPageProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FiltersPageProjectionService.cs index ba9302d..84a148f 100644 --- a/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FiltersPageProjectionService.cs +++ b/src/DnsmasqWebUI/Infrastructure/Services/Dnsmasq/Filters/FiltersPageProjectionService.cs @@ -1,5 +1,7 @@ using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Filters; using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata; using DnsmasqWebUI.Models.Dnsmasq; using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; using DnsmasqWebUI.Models.Filters; @@ -9,15 +11,22 @@ namespace DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Filters; public sealed class FiltersPageProjectionService : IFiltersPageProjectionService { private readonly IFilterPolicySummaryFormatter _summaries; + private readonly IEffectiveMultiValueProjectionService _multiValueProjection; - public FiltersPageProjectionService(IFilterPolicySummaryFormatter summaries) + public FiltersPageProjectionService( + IFilterPolicySummaryFormatter summaries, + IEffectiveMultiValueProjectionService multiValueProjection) { _summaries = summaries; + _multiValueProjection = multiValueProjection; } - public IReadOnlyList BuildGroups(DnsmasqServiceStatus status, FilterPolicyQueryState query) + public IReadOnlyList BuildGroups( + DnsmasqServiceStatus status, + FilterPolicyQueryState query, + Func>? currentValuesAccessor = null) { - var all = BuildAllRows(status); + var all = BuildAllRows(status, currentValuesAccessor); var filtered = ApplyQuery(all, query); if (query.Category is { } only) @@ -47,7 +56,9 @@ public sealed class FiltersPageProjectionService : IFiltersPageProjectionService .ToList(); } - private IReadOnlyList BuildAllRows(DnsmasqServiceStatus status) + private IReadOnlyList BuildAllRows( + DnsmasqServiceStatus status, + Func>? currentValuesAccessor) { if (status.EffectiveConfig == null) return Array.Empty(); @@ -56,18 +67,18 @@ public sealed class FiltersPageProjectionService : IFiltersPageProjectionService var src = status.EffectiveConfigSources; var list = new List(); - list.AddRange(ProjectMulti(FilterPolicyKind.Address, "Blocking / sinkhole", Merge(cfg.AddressValues, src?.AddressValues), FacetsAddress)); - list.AddRange(ProjectMulti(FilterPolicyKind.Server, "Split DNS (forward)", Merge(cfg.ServerValues, src?.ServerValues), FacetsServer)); - list.AddRange(ProjectMulti(FilterPolicyKind.RevServer, "Reverse zone forward", Merge(cfg.RevServerValues, src?.RevServerValues), FacetsRevServer)); - list.AddRange(ProjectMulti(FilterPolicyKind.Local, "Local-only domain", Merge(cfg.LocalValues, src?.LocalValues), FacetsLocal)); - list.AddRange(ProjectMulti(FilterPolicyKind.RebindDomainOk, "Rebind domain exception", Merge(cfg.RebindDomainOkValues, src?.RebindDomainOkValues), FacetsRebind)); - list.AddRange(ProjectMulti(FilterPolicyKind.BogusNxdomain, "Bogus NXDOMAIN", Merge(cfg.BogusNxdomainValues, src?.BogusNxdomainValues), FacetsBogus)); - list.AddRange(ProjectMulti(FilterPolicyKind.IgnoreAddress, "Ignore answer address", Merge(cfg.IgnoreAddressValues, src?.IgnoreAddressValues), FacetsIgnore)); - list.AddRange(ProjectMulti(FilterPolicyKind.FilterRr, "Filter RR", Merge(cfg.FilterRrValues, src?.FilterRrValues), FacetsFilterRr)); - list.AddRange(ProjectMulti(FilterPolicyKind.Alias, "Alias / rewrite", Merge(cfg.AliasValues, src?.AliasValues), FacetsAlias)); - list.AddRange(ProjectMulti(FilterPolicyKind.Ipset, "ipset targeting", Merge(cfg.IpsetValues, src?.IpsetValues), FacetsIpset)); - list.AddRange(ProjectMulti(FilterPolicyKind.Nftset, "nftset targeting", Merge(cfg.NftsetValues, src?.NftsetValues), FacetsNftset)); - list.AddRange(ProjectMulti(FilterPolicyKind.ConnmarkAllowlist, "Connmark allowlist", Merge(cfg.ConnmarkAllowlistValues, src?.ConnmarkAllowlistValues), FacetsConnmark)); + list.AddRange(ProjectMulti(FilterPolicyKind.Address, "Blocking / sinkhole", Project(status, DnsmasqConfKeys.Address, currentValuesAccessor ?? (_ => cfg.AddressValues), src?.AddressValues), FacetsAddress)); + list.AddRange(ProjectMulti(FilterPolicyKind.Server, "Split DNS (forward)", Project(status, DnsmasqConfKeys.Server, currentValuesAccessor ?? (_ => cfg.ServerValues), src?.ServerValues), FacetsServer)); + list.AddRange(ProjectMulti(FilterPolicyKind.RevServer, "Reverse zone forward", Project(status, DnsmasqConfKeys.RevServer, currentValuesAccessor ?? (_ => cfg.RevServerValues), src?.RevServerValues), FacetsRevServer)); + list.AddRange(ProjectMulti(FilterPolicyKind.Local, "Local-only domain", Project(status, DnsmasqConfKeys.Local, currentValuesAccessor ?? (_ => cfg.LocalValues), src?.LocalValues), FacetsLocal)); + list.AddRange(ProjectMulti(FilterPolicyKind.RebindDomainOk, "Rebind domain exception", Project(status, DnsmasqConfKeys.RebindDomainOk, currentValuesAccessor ?? (_ => cfg.RebindDomainOkValues), src?.RebindDomainOkValues), FacetsRebind)); + list.AddRange(ProjectMulti(FilterPolicyKind.BogusNxdomain, "Bogus NXDOMAIN", Project(status, DnsmasqConfKeys.BogusNxdomain, currentValuesAccessor ?? (_ => cfg.BogusNxdomainValues), src?.BogusNxdomainValues), FacetsBogus)); + list.AddRange(ProjectMulti(FilterPolicyKind.IgnoreAddress, "Ignore answer address", Project(status, DnsmasqConfKeys.IgnoreAddress, currentValuesAccessor ?? (_ => cfg.IgnoreAddressValues), src?.IgnoreAddressValues), FacetsIgnore)); + list.AddRange(ProjectMulti(FilterPolicyKind.FilterRr, "Filter RR", Project(status, DnsmasqConfKeys.FilterRr, currentValuesAccessor ?? (_ => cfg.FilterRrValues), src?.FilterRrValues), FacetsFilterRr)); + list.AddRange(ProjectMulti(FilterPolicyKind.Alias, "Alias / rewrite", Project(status, DnsmasqConfKeys.Alias, currentValuesAccessor ?? (_ => cfg.AliasValues), src?.AliasValues), FacetsAlias)); + list.AddRange(ProjectMulti(FilterPolicyKind.Ipset, "ipset targeting", Project(status, DnsmasqConfKeys.Ipset, currentValuesAccessor ?? (_ => cfg.IpsetValues), src?.IpsetValues), FacetsIpset)); + list.AddRange(ProjectMulti(FilterPolicyKind.Nftset, "nftset targeting", Project(status, DnsmasqConfKeys.Nftset, currentValuesAccessor ?? (_ => cfg.NftsetValues), src?.NftsetValues), FacetsNftset)); + list.AddRange(ProjectMulti(FilterPolicyKind.ConnmarkAllowlist, "Connmark allowlist", Project(status, DnsmasqConfKeys.ConnmarkAllowlist, currentValuesAccessor ?? (_ => cfg.ConnmarkAllowlistValues), src?.ConnmarkAllowlistValues), FacetsConnmark)); return list; } @@ -75,30 +86,78 @@ public sealed class FiltersPageProjectionService : IFiltersPageProjectionService private IEnumerable ProjectMulti( FilterPolicyKind kind, string title, - IReadOnlyList items, + IReadOnlyList items, Func> facetsFactory) { var category = FilterPolicyCategoryMap.GetCategory(kind); for (var i = 0; i < items.Count; i++) { var item = items[i]; - var id = $"{kind}:{i}:{item.Source?.FilePath}:{item.Source?.LineNumber}:{item.Value.GetHashCode(StringComparison.Ordinal):X8}"; + var id = $"{kind}:{item.EffectiveIndex}:{item.Source?.FilePath ?? item.DisplaySourcePath}:{item.Source?.LineNumber}:{item.Value.GetHashCode(StringComparison.Ordinal):X8}"; var summary = _summaries.Format(kind, item.Value); var facets = facetsFactory(item.Value); yield return new FilterPolicyRow( id, + item.OccurrenceId, category, kind, title, summary, item.Value, - item.Source?.IsManaged == true, + item.IsEditable, IsActive: true, item.Source, + item.DisplaySourcePath, + item.DisplaySourceLabel, + item.IsDraftOnly, facets); } } + private IReadOnlyList Project( + DnsmasqServiceStatus status, + string optionName, + Func> currentValuesAccessor, + IReadOnlyList? baselineValues) + { + return _multiValueProjection.Project( + currentValuesAccessor(optionName), + baselineValues, + status.ManagedFilePath); + } + + public IReadOnlyList ProjectOccurrences( + DnsmasqServiceStatus status, + string optionName, + IReadOnlyList currentValues) + { + return _multiValueProjection.Project( + currentValues, + GetBaselineValues(status, optionName), + status.ManagedFilePath); + } + + private static IReadOnlyList? GetBaselineValues(DnsmasqServiceStatus status, string optionName) + { + var src = status.EffectiveConfigSources; + return optionName switch + { + DnsmasqConfKeys.Address => src?.AddressValues, + DnsmasqConfKeys.Server => src?.ServerValues, + DnsmasqConfKeys.RevServer => src?.RevServerValues, + DnsmasqConfKeys.Local => src?.LocalValues, + DnsmasqConfKeys.RebindDomainOk => src?.RebindDomainOkValues, + DnsmasqConfKeys.BogusNxdomain => src?.BogusNxdomainValues, + DnsmasqConfKeys.IgnoreAddress => src?.IgnoreAddressValues, + DnsmasqConfKeys.FilterRr => src?.FilterRrValues, + DnsmasqConfKeys.Alias => src?.AliasValues, + DnsmasqConfKeys.Ipset => src?.IpsetValues, + DnsmasqConfKeys.Nftset => src?.NftsetValues, + DnsmasqConfKeys.ConnmarkAllowlist => src?.ConnmarkAllowlistValues, + _ => null + }; + } + private static IReadOnlyDictionary FacetsAddress(string v) { var p = AddressRuleFields.Parse(v); @@ -231,14 +290,14 @@ public sealed class FiltersPageProjectionService : IFiltersPageProjectionService r.Title.Contains(s, StringComparison.OrdinalIgnoreCase) || r.Summary.Contains(s, StringComparison.OrdinalIgnoreCase) || r.RawValue.Contains(s, StringComparison.OrdinalIgnoreCase) || - (r.Source?.FileName?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false) || - (r.Source?.FilePath?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false)); + (r.SourceLabel?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false) || + (r.SourcePath?.Contains(s, StringComparison.OrdinalIgnoreCase) ?? false)); } if (!string.IsNullOrWhiteSpace(query.SourcePathFilter)) { var p = query.SourcePathFilter.Trim(); - q = q.Where(r => string.Equals(r.Source?.FilePath, p, StringComparison.OrdinalIgnoreCase)); + q = q.Where(r => string.Equals(r.SourcePath, p, StringComparison.OrdinalIgnoreCase)); } if (query.EditableFilter is { } ed) diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveMultiValueProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveMultiValueProjectionService.cs new file mode 100644 index 0000000..d8d125d --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/Abstractions/IEffectiveMultiValueProjectionService.cs @@ -0,0 +1,16 @@ +using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; + +/// +/// Reattaches baseline source metadata to the current effective multi-value list produced by the edit session. +/// The result is intended for page projections and should not be treated as authoritative on-disk provenance. +/// +public interface IEffectiveMultiValueProjectionService : IApplicationSingleton +{ + IReadOnlyList Project( + IReadOnlyList currentValues, + IReadOnlyList? baselineValues, + string? managedFilePath); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveMultiValueProjectionService.cs b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveMultiValueProjectionService.cs new file mode 100644 index 0000000..8e53080 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/EffectiveConfig/EffectiveMultiValueProjectionService.cs @@ -0,0 +1,89 @@ +using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig; + +public sealed class EffectiveMultiValueProjectionService : IEffectiveMultiValueProjectionService +{ + public IReadOnlyList Project( + IReadOnlyList currentValues, + IReadOnlyList? baselineValues, + string? managedFilePath) + { + if (currentValues.Count == 0) + return []; + + var managedPath = string.IsNullOrWhiteSpace(managedFilePath) ? null : managedFilePath.Trim(); + var managedLabel = string.IsNullOrWhiteSpace(managedPath) ? null : Path.GetFileName(managedPath); + var usedBaseline = baselineValues != null ? new bool[baselineValues.Count] : Array.Empty(); + var draftOrdinals = new Dictionary(StringComparer.Ordinal); + var projected = new List(currentValues.Count); + + for (var i = 0; i < currentValues.Count; i++) + { + var value = currentValues[i]; + ValueWithSource? matched = null; + var matchedBaselineIndex = -1; + if (baselineValues != null) + { + for (var j = 0; j < baselineValues.Count; j++) + { + if (usedBaseline[j]) + continue; + if (!string.Equals(baselineValues[j].Value, value, StringComparison.Ordinal)) + continue; + + matched = baselineValues[j]; + usedBaseline[j] = true; + matchedBaselineIndex = j; + break; + } + } + + if (matched != null) + { + projected.Add(new ProjectedMultiValueOccurrence( + OccurrenceId: $"baseline:{matchedBaselineIndex}", + Value: value, + EffectiveIndex: i, + Source: matched.Source, + IsDraftOnly: false, + IsEditable: matched.Source?.IsReadOnly != true, + DisplaySourcePath: matched.Source?.FilePath, + DisplaySourceLabel: matched.Source?.FileName)); + continue; + } + + draftOrdinals.TryGetValue(value, out var draftOrdinal); + draftOrdinals[value] = draftOrdinal + 1; + projected.Add(new ProjectedMultiValueOccurrence( + OccurrenceId: $"draft:{Fnv1aHash(value):X8}:{draftOrdinal}", + Value: value, + EffectiveIndex: i, + Source: null, + IsDraftOnly: true, + IsEditable: true, + DisplaySourcePath: managedPath, + DisplaySourceLabel: managedLabel)); + } + + return projected; + } + + private static uint Fnv1aHash(string s) + { + unchecked + { + const uint offset = 2166136261; + const uint prime = 16777619; + var h = offset; + foreach (var c in s) + { + h ^= c; + h *= prime; + } + + return h; + } + } +} diff --git a/src/DnsmasqWebUI/Models/Dhcp/Ui/DhcpPageModels.cs b/src/DnsmasqWebUI/Models/Dhcp/Ui/DhcpPageModels.cs index 8be90f8..ae8fc36 100644 --- a/src/DnsmasqWebUI/Models/Dhcp/Ui/DhcpPageModels.cs +++ b/src/DnsmasqWebUI/Models/Dhcp/Ui/DhcpPageModels.cs @@ -39,8 +39,10 @@ public sealed record DhcpHostPageRow( int EffectiveIndex, string ValueString, string RowKey, + string OccurrenceId, DhcpSourceKind SourceKind, string SourcePath, + bool IsDraftOnly, bool IsEditable, bool IsActive, DhcpHostEntry Entry, diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordRow.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordRow.cs index cc9e341..de67052 100644 --- a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordRow.cs +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordRow.cs @@ -5,11 +5,15 @@ namespace DnsmasqWebUI.Models.DnsRecords; /// One effective-config value row for the DNS records page. public sealed record DnsRecordRow( string Id, + string OccurrenceId, string OptionName, DnsRecordFamily Family, int IndexInOption, string RawValue, ConfigValueSource? Source, + string? SourcePath, + string? SourceLabel, + bool IsDraftOnly, bool IsEditable, DnsRecordPayload Payload, IReadOnlyList Issues, diff --git a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsFileFilterBuilder.cs b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsFileFilterBuilder.cs index 5b9b27f..135df4a 100644 --- a/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsFileFilterBuilder.cs +++ b/src/DnsmasqWebUI/Models/DnsRecords/DnsRecordsFileFilterBuilder.cs @@ -7,10 +7,12 @@ namespace DnsmasqWebUI.Models.DnsRecords; public sealed class DnsRecordsFileFilterBuilder : IGroupedSelectBuilder, IGroupedSelectTriggerSummary { private readonly IReadOnlyList _rows; + private readonly string? _managedConfigPath; - public DnsRecordsFileFilterBuilder(IReadOnlyList rows) + public DnsRecordsFileFilterBuilder(IReadOnlyList rows, string? managedConfigPath) { _rows = rows; + _managedConfigPath = managedConfigPath; } public string TriggerTitle => @@ -23,12 +25,12 @@ public sealed class DnsRecordsFileFilterBuilder : IGroupedSelectBuilder, public GroupedSelectModel Build() { var byPath = _rows - .Where(r => r.Source is { FilePath: not null } && !string.IsNullOrWhiteSpace(r.Source.FilePath)) - .GroupBy(r => r.Source!.FilePath.Trim(), StringComparer.OrdinalIgnoreCase) + .Where(r => !string.IsNullOrWhiteSpace(r.SourcePath)) + .GroupBy(r => r.SourcePath!.Trim(), StringComparer.OrdinalIgnoreCase) .Select(g => new { Path = g.Key, - Managed = g.First().Source!.IsManaged, + Managed = IsManagedPath(g.Key), Count = g.Count() }) .ToList(); @@ -129,4 +131,11 @@ public sealed class DnsRecordsFileFilterBuilder : IGroupedSelectBuilder, "managed" => "Managed", _ => "Config" }; + + private bool IsManagedPath(string path) + { + var managed = _managedConfigPath?.Trim(); + return !string.IsNullOrWhiteSpace(managed) + && string.Equals(path, managed, StringComparison.OrdinalIgnoreCase); + } } diff --git a/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ProjectedMultiValueOccurrence.cs b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ProjectedMultiValueOccurrence.cs new file mode 100644 index 0000000..31aa62f --- /dev/null +++ b/src/DnsmasqWebUI/Models/Dnsmasq/EffectiveConfig/ProjectedMultiValueOccurrence.cs @@ -0,0 +1,15 @@ +namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +/// +/// Draft-aware projection of a current effective multi-value item back onto the best available baseline source metadata. +/// Used by specialized pages to stay in sync with the effective-config session without mutating the baseline status snapshot. +/// +public sealed record ProjectedMultiValueOccurrence( + string OccurrenceId, + string Value, + int EffectiveIndex, + ConfigValueSource? Source, + bool IsDraftOnly, + bool IsEditable, + string? DisplaySourcePath, + string? DisplaySourceLabel); diff --git a/src/DnsmasqWebUI/Models/Filters/FilterPolicyRow.cs b/src/DnsmasqWebUI/Models/Filters/FilterPolicyRow.cs index 2924d6a..b307f11 100644 --- a/src/DnsmasqWebUI/Models/Filters/FilterPolicyRow.cs +++ b/src/DnsmasqWebUI/Models/Filters/FilterPolicyRow.cs @@ -4,6 +4,7 @@ namespace DnsmasqWebUI.Models.Filters; public sealed record FilterPolicyRow( string Id, + string OccurrenceId, FilterPolicyCategory Category, FilterPolicyKind Kind, string Title, @@ -12,4 +13,7 @@ public sealed record FilterPolicyRow( bool IsEditable, bool IsActive, ConfigValueSource? Source, + string? SourcePath, + string? SourceLabel, + bool IsDraftOnly, IReadOnlyDictionary Facets); diff --git a/src/DnsmasqWebUI/Models/Filters/FilterSourceFileFilterBuilder.cs b/src/DnsmasqWebUI/Models/Filters/FilterSourceFileFilterBuilder.cs index 2d61b4e..201621a 100644 --- a/src/DnsmasqWebUI/Models/Filters/FilterSourceFileFilterBuilder.cs +++ b/src/DnsmasqWebUI/Models/Filters/FilterSourceFileFilterBuilder.cs @@ -23,7 +23,7 @@ public sealed class FilterSourceFileFilterBuilder : IGroupedSelectBuilder Build() { var byPath = _rows - .Select(r => r.Source?.FilePath) + .Select(r => r.SourcePath) .Where(p => !string.IsNullOrWhiteSpace(p)) .Distinct(StringComparer.OrdinalIgnoreCase) .Select(path => path!.Trim()) @@ -31,7 +31,7 @@ public sealed class FilterSourceFileFilterBuilder : IGroupedSelectBuilder string.Equals(r.Source?.FilePath, path, StringComparison.OrdinalIgnoreCase)) + Count = _rows.Count(r => string.Equals(r.SourcePath, path, StringComparison.OrdinalIgnoreCase)) }) .ToList();