mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
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:
@@ -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);
|
||||
|
||||
+384
-1
@@ -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,
|
||||
|
||||
+39
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+26
-29
@@ -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,
|
||||
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
|
||||
+4
@@ -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: [],
|
||||
|
||||
+44
-18
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+10
-1
@@ -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);
|
||||
}
|
||||
|
||||
+81
-22
@@ -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)
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
+89
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user