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.
This commit is contained in:
2026-04-11 21:35:52 +10:00
parent b24ca17779
commit 43ba5116ac
32 changed files with 1053 additions and 139 deletions
@@ -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"),
[],
@@ -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);
}
}
@@ -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<DhcpHostPageRow>
{
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<DhcpHostPageRow>
{
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);
@@ -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<DnsRecordIssue>? 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,
@@ -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);
}
}
@@ -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);
}
}
@@ -73,21 +73,25 @@ else
<tbody>
@foreach (var row in Rows)
{
<tr @key="@row.Id">
<tr @key="@row.OccurrenceId">
<td><span class="badge bg-secondary">@row.OptionName</span></td>
<td>
<div class="dns-records-summary" title="@row.RawValue">@row.Summary</div>
<code class="small text-muted dns-records-raw">@row.RawValue</code>
</td>
<td class="small">
@if (row.Source == null)
@if (string.IsNullOrWhiteSpace(row.SourcePath))
{
<span class="text-muted">—</span>
}
else
{
<span title="@row.Source.FilePath">@row.Source.FileName</span>
@if (row.Source.IsReadOnly)
<span title="@row.SourcePath">@(row.SourceLabel ?? row.SourcePath)</span>
@if (row.IsDraftOnly)
{
<span class="badge bg-primary-subtle text-primary-emphasis ms-1">draft</span>
}
else if (row.Source?.IsReadOnly == true)
{
<span class="badge bg-warning text-dark ms-1">read-only</span>
}
@@ -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: [],
@@ -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: [],
@@ -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: [],
@@ -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: [],
@@ -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: [],
@@ -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: [],
@@ -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: [],
@@ -27,7 +27,7 @@ else
<tbody>
@foreach (var row in Items)
{
<tr @key="row.Id">
<tr @key="row.OccurrenceId">
<td class="small text-nowrap">@row.Title</td>
<td>
<div class="filters-policy-table__summary" title="@row.Summary">@row.Summary</div>
@@ -36,14 +36,18 @@ else
<code class="text-break d-block">@row.RawValue</code>
</td>
<td class="small">
@if (row.Source == null)
@if (string.IsNullOrWhiteSpace(row.SourcePath))
{
<span class="text-muted">—</span>
}
else
{
<span class="d-block text-truncate" style="max-width:14rem" title="@row.Source.FilePath">@row.Source.FileName</span>
@if (row.IsEditable)
<span class="d-block text-truncate" style="max-width:14rem" title="@row.SourcePath">@(row.SourceLabel ?? row.SourcePath)</span>
@if (row.IsDraftOnly)
{
<StatusBadge Kind="StatusBadgeKind.Editable" Text="draft" Class="mt-1" />
}
else if (row.IsEditable)
{
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable" Class="mt-1" />
}
@@ -52,7 +56,7 @@ else
<StatusBadge Kind="StatusBadgeKind.ReadOnly"
Text="read-only"
Class="mt-1"
Title="@row.Source.GetReadOnlyTooltip()" />
Title="@row.Source?.GetReadOnlyTooltip()" />
}
}
</td>
@@ -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 <strong>Effective configuration</strong> for options that do not need a specialized form.
</p>
@if (_recordsError != null)
{
<div class="alert alert-danger py-2 mb-3">@_recordsError</div>
}
<div class="app-page-controls-bar d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"
@onkeydown="OnToolbarKeyDown">
<div class="app-page-toolbar-fields flex-grow-1">
@@ -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<string> 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()
+129 -30
View File
@@ -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<string> 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;
}
+1 -1
View File
@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<Version>0.0.8</Version>
<Version>0.0.9</Version>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
<DisableBuildCompression>true</DisableBuildCompression>
@@ -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<DhcpHostPageRow> BuildHostRows(
DnsmasqServiceStatus status,
IReadOnlyList<string> effectiveDhcpHostValues,
IStructuredOptionValueHandler<DhcpHostEntry> handler,
IReadOnlyList<LeaseEntry>? leases = null)
{
var baseline = status.EffectiveConfigSources?.DhcpHostLines;
var usedBaseline = baseline != null ? new bool[baseline.Count] : Array.Empty<bool>();
var projected = _multiValueProjection.Project(
effectiveDhcpHostValues,
status.EffectiveConfigSources?.DhcpHostLines,
status.ManagedFilePath);
var rows = new List<DhcpHostPageRow>(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<string>()
};
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,
@@ -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;
/// <summary>Builds and filters typed rows for the DNS records page.</summary>
public interface IDnsRecordsPageProjectionService : IApplicationSingleton
{
IReadOnlyList<DnsRecordRow> BuildRows(DnsmasqServiceStatus status);
IReadOnlyList<DnsRecordRow> BuildRows(
DnsmasqServiceStatus status,
Func<string, IReadOnlyList<string>>? currentValuesAccessor = null);
IReadOnlyList<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> currentValues);
IReadOnlyList<DnsRecordRow> FilterRows(IReadOnlyList<DnsRecordRow> rows, DnsRecordsQueryState query);
}
@@ -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: [],
@@ -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;
}
/// <inheritdoc />
public IReadOnlyList<DnsRecordRow> BuildRows(DnsmasqServiceStatus status)
public IReadOnlyList<DnsRecordRow> BuildRows(
DnsmasqServiceStatus status,
Func<string, IReadOnlyList<string>>? currentValuesAccessor = null)
{
if (status.EffectiveConfig == null)
return [];
var ec = status.EffectiveConfig;
var list = new List<DnsRecordRow>();
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<DnsRecordIssue>();
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<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> 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<ValueWithSource> GetValueWithSources(
private static IReadOnlyList<ValueWithSource>? GetBaselineValues(
DnsmasqServiceStatus status,
EffectiveDnsmasqConfig ec,
string optionName)
{
var sources = status.EffectiveConfigSources;
IReadOnlyList<ValueWithSource>? 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<string> GetPlainValues(
EffectiveDnsmasqConfig ec,
string optionName)
{
IReadOnlyList<string>? 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;
}
}
@@ -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<FilterPolicyGroup> BuildGroups(DnsmasqServiceStatus status, FilterPolicyQueryState query);
IReadOnlyList<FilterPolicyGroup> BuildGroups(
DnsmasqServiceStatus status,
FilterPolicyQueryState query,
Func<string, IReadOnlyList<string>>? currentValuesAccessor = null);
IReadOnlyList<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> currentValues);
}
@@ -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<FilterPolicyGroup> BuildGroups(DnsmasqServiceStatus status, FilterPolicyQueryState query)
public IReadOnlyList<FilterPolicyGroup> BuildGroups(
DnsmasqServiceStatus status,
FilterPolicyQueryState query,
Func<string, IReadOnlyList<string>>? 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<FilterPolicyRow> BuildAllRows(DnsmasqServiceStatus status)
private IReadOnlyList<FilterPolicyRow> BuildAllRows(
DnsmasqServiceStatus status,
Func<string, IReadOnlyList<string>>? currentValuesAccessor)
{
if (status.EffectiveConfig == null)
return Array.Empty<FilterPolicyRow>();
@@ -56,18 +67,18 @@ public sealed class FiltersPageProjectionService : IFiltersPageProjectionService
var src = status.EffectiveConfigSources;
var list = new List<FilterPolicyRow>();
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<FilterPolicyRow> ProjectMulti(
FilterPolicyKind kind,
string title,
IReadOnlyList<ValueWithSource> items,
IReadOnlyList<ProjectedMultiValueOccurrence> items,
Func<string, IReadOnlyDictionary<string, string>> 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<ProjectedMultiValueOccurrence> Project(
DnsmasqServiceStatus status,
string optionName,
Func<string, IReadOnlyList<string>> currentValuesAccessor,
IReadOnlyList<ValueWithSource>? baselineValues)
{
return _multiValueProjection.Project(
currentValuesAccessor(optionName),
baselineValues,
status.ManagedFilePath);
}
public IReadOnlyList<ProjectedMultiValueOccurrence> ProjectOccurrences(
DnsmasqServiceStatus status,
string optionName,
IReadOnlyList<string> currentValues)
{
return _multiValueProjection.Project(
currentValues,
GetBaselineValues(status, optionName),
status.ManagedFilePath);
}
private static IReadOnlyList<ValueWithSource>? 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<string, string> 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)
@@ -0,0 +1,16 @@
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
/// <summary>
/// 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.
/// </summary>
public interface IEffectiveMultiValueProjectionService : IApplicationSingleton
{
IReadOnlyList<ProjectedMultiValueOccurrence> Project(
IReadOnlyList<string> currentValues,
IReadOnlyList<ValueWithSource>? baselineValues,
string? managedFilePath);
}
@@ -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<ProjectedMultiValueOccurrence> Project(
IReadOnlyList<string> currentValues,
IReadOnlyList<ValueWithSource>? 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<bool>();
var draftOrdinals = new Dictionary<string, int>(StringComparer.Ordinal);
var projected = new List<ProjectedMultiValueOccurrence>(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;
}
}
}
@@ -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,
@@ -5,11 +5,15 @@ namespace DnsmasqWebUI.Models.DnsRecords;
/// <summary>One effective-config value row for the DNS records page.</summary>
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<DnsRecordIssue> Issues,
@@ -7,10 +7,12 @@ namespace DnsmasqWebUI.Models.DnsRecords;
public sealed class DnsRecordsFileFilterBuilder : IGroupedSelectBuilder<string>, IGroupedSelectTriggerSummary<string>
{
private readonly IReadOnlyList<DnsRecordRow> _rows;
private readonly string? _managedConfigPath;
public DnsRecordsFileFilterBuilder(IReadOnlyList<DnsRecordRow> rows)
public DnsRecordsFileFilterBuilder(IReadOnlyList<DnsRecordRow> rows, string? managedConfigPath)
{
_rows = rows;
_managedConfigPath = managedConfigPath;
}
public string TriggerTitle =>
@@ -23,12 +25,12 @@ public sealed class DnsRecordsFileFilterBuilder : IGroupedSelectBuilder<string>,
public GroupedSelectModel<string> 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<string>,
"managed" => "Managed",
_ => "Config"
};
private bool IsManagedPath(string path)
{
var managed = _managedConfigPath?.Trim();
return !string.IsNullOrWhiteSpace(managed)
&& string.Equals(path, managed, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,15 @@
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
/// <summary>
/// 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.
/// </summary>
public sealed record ProjectedMultiValueOccurrence(
string OccurrenceId,
string Value,
int EffectiveIndex,
ConfigValueSource? Source,
bool IsDraftOnly,
bool IsEditable,
string? DisplaySourcePath,
string? DisplaySourceLabel);
@@ -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<string, string> Facets);
@@ -23,7 +23,7 @@ public sealed class FilterSourceFileFilterBuilder : IGroupedSelectBuilder<string
public GroupedSelectModel<string> 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
{
Path = path,
Kind = KindForPath(path),
Count = _rows.Count(r => string.Equals(r.Source?.FilePath, path, StringComparison.OrdinalIgnoreCase))
Count = _rows.Count(r => string.Equals(r.SourcePath, path, StringComparison.OrdinalIgnoreCase))
})
.ToList();