Refactor settings modal to use ISettingsModalService and SettingsModalSections

- Replace SettingsModalContext with SettingsModalSections
- Add SettingsModalService for modal state management
- Add StatusSection.razor.css for status-dot styles
- Update layout, NavMenu, and modal components
- Update tests for config/settings changes
This commit is contained in:
2026-02-09 14:59:01 +10:00
parent d06d6ef6c9
commit b70aa02017
26 changed files with 754 additions and 317 deletions
@@ -17,25 +17,30 @@ public class ConfigValueSourceTests
[Fact] [Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly() public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
{ {
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: null); var fileName = "02.conf";
var fullPath = $"/etc/dnsmasq.d/{fileName}";
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: null);
var tooltip = source.GetReadOnlyTooltip(); var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip); Assert.NotNull(tooltip);
Assert.Contains("02.conf", tooltip); Assert.Contains(fileName, tooltip);
Assert.Contains("readonly", tooltip); Assert.Contains("readonly", tooltip);
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip); Assert.Contains(fullPath, tooltip);
Assert.DoesNotContain("line", tooltip); Assert.DoesNotContain("line", tooltip);
} }
[Fact] [Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber() public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
{ {
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: 3); var fileName = "02.conf";
var fullPath = $"/etc/dnsmasq.d/{fileName}";
int? lineNumber = 3;
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: lineNumber);
var tooltip = source.GetReadOnlyTooltip(); var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip); Assert.NotNull(tooltip);
Assert.Contains("02.conf", tooltip); Assert.Contains(fileName, tooltip);
Assert.Contains("line 3", tooltip); Assert.Contains($"line {lineNumber}", tooltip);
Assert.Contains("readonly", tooltip); Assert.Contains("readonly", tooltip);
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip); Assert.Contains(fullPath, tooltip);
} }
[Fact] [Fact]
@@ -20,136 +20,219 @@ public class DnsmasqConfDhcpHostLineParserTests
[Fact] [Fact]
public void ParseLine_BasicMacIpNameLease_Parses() public void ParseLine_BasicMacIpNameLease_Parses()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.False(e!.IsComment); Assert.False(e!.IsComment);
Assert.False(e.IsDeleted); Assert.False(e.IsDeleted);
Assert.Single(e.MacAddresses); Assert.Single(e.MacAddresses);
Assert.Equal("aa:bb:cc:dd:ee:ff", e.MacAddresses[0]); Assert.Equal(mac, e.MacAddresses[0]);
Assert.Equal("192.168.1.10", e.Address); Assert.Equal(address, e.Address);
Assert.Equal("testpc", e.Name); Assert.Equal(name, e.Name);
Assert.Equal("infinite", e.Lease); Assert.Equal(lease, e.Lease);
Assert.Empty(e.Extra); Assert.Empty(e.Extra);
} }
[Fact] [Fact]
public void ParseLine_CommentedLine_ParsesWithIsComment() public void ParseLine_CommentedLine_ParsesWithIsComment()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"#dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.False(e.IsDeleted); Assert.False(e.IsDeleted);
Assert.Single(e.MacAddresses); Assert.Single(e.MacAddresses);
Assert.Equal("192.168.1.10", e.Address); Assert.Equal(address, e.Address);
} }
[Fact] [Fact]
public void ParseLine_DeletedLine_ParsesWithIsDeleted() public void ParseLine_DeletedLine_ParsesWithIsDeleted()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "oldpc";
var line = $"##dhcp-host={mac},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.True(e.IsDeleted); Assert.True(e.IsDeleted);
Assert.Single(e.MacAddresses); Assert.Single(e.MacAddresses);
Assert.Equal("192.168.1.10", e.Address); Assert.Equal(address, e.Address);
} }
[Fact] [Fact]
public void ParseLine_TrailingComment_ParsesComment() public void ParseLine_TrailingComment_ParsesComment()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1); string mac = "11:22:33:44:55:66";
string address = "192.168.1.11";
string name = "laptop";
string lease = "infinite";
string comment = "main laptop";
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("main laptop", e!.Comment); Assert.Equal(comment, e!.Comment);
Assert.Single(e.MacAddresses); Assert.Single(e.MacAddresses);
} }
[Fact] [Fact]
public void ParseLine_MultipleMacs_SameIp_Parses() public void ParseLine_MultipleMacs_SameIp_Parses()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1); string mac1 = "11:22:33:44:55:66";
string mac2 = "12:34:56:78:90:12";
string address = "192.168.0.2";
var line = $"dhcp-host={mac1},{mac2},{address}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal(2, e!.MacAddresses.Count); Assert.Equal(2, e!.MacAddresses.Count);
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]); Assert.Equal(mac1, e.MacAddresses[0]);
Assert.Equal("12:34:56:78:90:12", e.MacAddresses[1]); Assert.Equal(mac2, e.MacAddresses[1]);
Assert.Equal("192.168.0.2", e.Address); Assert.Equal(address, e.Address);
} }
[Fact] [Fact]
public void ParseLine_WithSetTag_PutsInExtra() public void ParseLine_WithSetTag_PutsInExtra()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1); string mac = "AA:BB:CC:DD:CC:BB";
string name = "redhost1";
string address = "192.168.1.41";
string lease = "infinite";
string extraTag = "set:red";
var line = $"dhcp-host={mac},{name},{address},{lease},{extraTag}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("192.168.1.41", e!.Address); Assert.Equal(address, e!.Address);
Assert.Equal("redhost1", e.Name); Assert.Equal(name, e.Name);
Assert.Contains(e.Extra, x => x == "set:red"); Assert.Contains(e.Extra, x => x == extraTag);
} }
[Fact] [Fact]
public void ParseLine_HostnameOnly_Parses() public void ParseLine_HostnameOnly_Parses()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=lap,192.168.0.199", 1); string name = "lap";
string address = "192.168.0.199";
var line = $"dhcp-host={name},{address}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("lap", e!.Name); Assert.Equal(name, e!.Name);
Assert.Equal("192.168.0.199", e.Address); Assert.Equal(address, e.Address);
Assert.Empty(e.MacAddresses); Assert.Empty(e.MacAddresses);
} }
[Fact] [Fact]
public void ParseLine_Ignore_Parses() public void ParseLine_Ignore_Parses()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1); string mac = "00:20:e0:3b:13:af";
var line = $"dhcp-host={mac},ignore";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.Ignore); Assert.True(e!.Ignore);
Assert.Single(e.MacAddresses); Assert.Single(e.MacAddresses);
Assert.Equal("00:20:e0:3b:13:af", e.MacAddresses[0]); Assert.Equal(mac, e.MacAddresses[0]);
} }
[Fact] [Fact]
public void ParseLine_NumericLease_Parses() public void ParseLine_NumericLease_Parses()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "pc";
string lease = "3600";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("3600", e!.Lease); Assert.Equal(lease, e!.Lease);
} }
[Fact] [Fact]
public void ParseLine_WithIdClient_PutsInExtra() public void ParseLine_WithIdClient_PutsInExtra()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1); string idPart = "id:01:02:03:04";
string address = "192.168.1.50";
string name = "myhost";
var line = $"dhcp-host={idPart},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase)); Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
Assert.Equal("192.168.1.50", e.Address); Assert.Equal(address, e.Address);
} }
[Fact] [Fact]
public void ToLine_Roundtrip_Basic() public void ToLine_Roundtrip_Basic()
{ {
var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"; string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back); Assert.StartsWith("dhcp-host=", back);
Assert.Contains("aa:bb:cc:dd:ee:ff", back); Assert.Contains(mac, back);
Assert.Contains("192.168.1.10", back); Assert.Contains(address, back);
Assert.Contains("testpc", back); Assert.Contains(name, back);
Assert.Contains("infinite", back); Assert.Contains(lease, back);
} }
[Fact] [Fact]
public void ToLine_Roundtrip_WithComment() public void ToLine_Roundtrip_WithComment()
{ {
var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop"; string mac = "11:22:33:44:55:66";
string address = "192.168.1.11";
string name = "laptop";
string lease = "infinite";
string comment = "main laptop";
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1); var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("dhcp-host=", back); Assert.StartsWith("dhcp-host=", back);
Assert.Contains("main laptop", back); Assert.Contains(comment, back);
} }
[Fact] [Fact]
public void ToLine_CommentedEntry_Prefix() public void ToLine_CommentedEntry_Prefix()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
var line = $"#dhcp-host={mac},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("#dhcp-host=", back); Assert.StartsWith("#dhcp-host=", back);
@@ -158,7 +241,13 @@ public class DnsmasqConfDhcpHostLineParserTests
[Fact] [Fact]
public void ToLine_DeletedEntry_DoubleHash() public void ToLine_DeletedEntry_DoubleHash()
{ {
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "old";
var line = $"##dhcp-host={mac},{address},{name}";
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
Assert.StartsWith("##dhcp-host=", back); Assert.StartsWith("##dhcp-host=", back);
@@ -26,10 +26,12 @@ public class DnsmasqConfDirectiveParserTests
[Fact] [Fact]
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue() public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
{ {
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53"); var value = "53";
var line = $"port={value}";
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
Assert.NotNull(kv); Assert.NotNull(kv);
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key); Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
Assert.Equal("53", kv.Value.value); Assert.Equal(value, kv.Value.value);
} }
[Fact] [Fact]
@@ -50,16 +52,22 @@ public class DnsmasqConfDirectiveParserTests
[Fact] [Fact]
public void StripComment_CommentAfterSpace_StripsToEnd() public void StripComment_CommentAfterSpace_StripsToEnd()
{ {
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # DNS port")); var before = "port=53";
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment")); var comment1 = " DNS port";
var comment2 = " comment";
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment1));
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment2));
} }
[Fact] [Fact]
public void TryParseKeyValue_LineWithComment_ValueExcludesComment() public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
{ {
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port"); var value = "53";
var comment = " DNS port";
var line = $"port={value} #{comment}";
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
Assert.NotNull(kv); Assert.NotNull(kv);
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key); Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
Assert.Equal("53", kv.Value.value); Assert.Equal(value, kv.Value.value);
} }
} }
@@ -19,73 +19,89 @@ public class DnsmasqConfFileLineParserTests
[Fact] [Fact]
public void ParseFile_BlankLine_BlankKind() public void ParseFile_BlankLine_BlankKind()
{ {
var lines = DnsmasqConfFileLineParser.ParseFile([""]); var raw = "";
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
Assert.Single(lines); Assert.Single(lines);
var blank = Assert.IsType<BlankLine>(lines[0]); var blank = Assert.IsType<BlankLine>(lines[0]);
Assert.Equal(1, blank.LineNumber); Assert.Equal(1, blank.LineNumber);
Assert.Equal("", blank.RawLine); Assert.Equal(raw, blank.RawLine);
} }
[Fact] [Fact]
public void ParseFile_BlankLineWithSpaces_BlankKind() public void ParseFile_BlankLineWithSpaces_BlankKind()
{ {
var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]); var raw = " \t ";
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
Assert.Single(lines); Assert.Single(lines);
var blank = Assert.IsType<BlankLine>(lines[0]); var blank = Assert.IsType<BlankLine>(lines[0]);
Assert.Equal(" \t ", blank.RawLine); Assert.Equal(raw, blank.RawLine);
} }
[Fact] [Fact]
public void ParseFile_CommentLine_CommentKind() public void ParseFile_CommentLine_CommentKind()
{ {
var lines = DnsmasqConfFileLineParser.ParseFile(["# Not managed by this app."]); var commentLine = "# Not managed by this app.";
var lines = DnsmasqConfFileLineParser.ParseFile([commentLine]);
Assert.Single(lines); Assert.Single(lines);
var comment = Assert.IsType<CommentLine>(lines[0]); var comment = Assert.IsType<CommentLine>(lines[0]);
Assert.Equal(1, comment.LineNumber); Assert.Equal(1, comment.LineNumber);
Assert.Equal("# Not managed by this app.", comment.RawLine); Assert.Equal(commentLine, comment.RawLine);
} }
[Fact] [Fact]
public void ParseFile_OtherDirective_OtherKind() public void ParseFile_OtherDirective_OtherKind()
{ {
var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]); var raw = "domain=local";
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
Assert.Single(lines); Assert.Single(lines);
var other = Assert.IsType<OtherLine>(lines[0]); var other = Assert.IsType<OtherLine>(lines[0]);
Assert.Equal("domain=local", other.RawLine); Assert.Equal(raw, other.RawLine);
} }
[Fact] [Fact]
public void ParseFile_AddnHostsLine_AddnHostsKind() public void ParseFile_AddnHostsLine_AddnHostsKind()
{ {
var lines = DnsmasqConfFileLineParser.ParseFile(["addn-hosts=/var/lib/dnsmasq/hosts"]); var path = "/var/lib/dnsmasq/hosts";
var line = $"addn-hosts={path}";
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
Assert.Single(lines); Assert.Single(lines);
var addn = Assert.IsType<AddnHostsLine>(lines[0]); var addn = Assert.IsType<AddnHostsLine>(lines[0]);
Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath); Assert.Equal(path, addn.AddnHostsPath);
} }
[Fact] [Fact]
public void ToLine_AddnHosts_EmitsAddnHostsLine() public void ToLine_AddnHosts_EmitsAddnHostsLine()
{ {
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" }; var path = "/etc/hosts";
Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line)); var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = path };
Assert.Equal($"addn-hosts={path}", DnsmasqConfFileLineParser.ToLine(line));
} }
[Fact] [Fact]
public void ParseFile_DhcpHostLine_DhcpHostKind() public void ParseFile_DhcpHostLine_DhcpHostKind()
{ {
var lines = DnsmasqConfFileLineParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var line = $"dhcp-host={mac},{address},{name},{lease}";
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
Assert.Single(lines); Assert.Single(lines);
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[0]); var dhcpLine = Assert.IsType<DhcpHostLine>(lines[0]);
Assert.Single(dhcpLine.DhcpHost.MacAddresses); Assert.Single(dhcpLine.DhcpHost.MacAddresses);
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]); Assert.Equal(mac, dhcpLine.DhcpHost.MacAddresses[0]);
Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address); Assert.Equal(address, dhcpLine.DhcpHost.Address);
} }
[Fact] [Fact]
public void ParseFile_CommentedDhcpHost_TreatedAsComment() public void ParseFile_CommentedDhcpHost_TreatedAsComment()
{ {
// Parser treats any line starting with # as Comment (does not parse dhcp-host content) // Parser treats any line starting with # as Comment (does not parse dhcp-host content)
var lines = DnsmasqConfFileLineParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]); string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "oldpc";
var line = $"#dhcp-host={mac},{address},{name}";
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
Assert.Single(lines); Assert.Single(lines);
Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind); Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind);
} }
@@ -93,13 +109,21 @@ public class DnsmasqConfFileLineParserTests
[Fact] [Fact]
public void ParseFile_MixedFile_ParsesAll() public void ParseFile_MixedFile_ParsesAll()
{ {
var domainLine = "domain=local";
string dhcpMac = "11:22:33:44:55:66";
string dhcpAddress = "192.168.1.11";
string dhcpName = "laptop";
string dhcpLease = "infinite";
var rangeLine = "dhcp-range=192.168.1.100,192.168.1.200";
var dhcpLine = $"dhcp-host={dhcpMac},{dhcpAddress},{dhcpName},{dhcpLease}";
var input = new[] var input = new[]
{ {
"# Sample config", "# Sample config",
"", "",
"domain=local", domainLine,
"dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite", dhcpLine,
"dhcp-range=192.168.1.100,192.168.1.200" rangeLine
}; };
var lines = DnsmasqConfFileLineParser.ParseFile(input); var lines = DnsmasqConfFileLineParser.ParseFile(input);
Assert.Equal(5, lines.Count); Assert.Equal(5, lines.Count);
@@ -107,48 +131,55 @@ public class DnsmasqConfFileLineParserTests
Assert.IsType<CommentLine>(lines[0]); Assert.IsType<CommentLine>(lines[0]);
Assert.IsType<BlankLine>(lines[1]); Assert.IsType<BlankLine>(lines[1]);
var other2 = Assert.IsType<OtherLine>(lines[2]); var other2 = Assert.IsType<OtherLine>(lines[2]);
Assert.Equal("domain=local", other2.RawLine); Assert.Equal(domainLine, other2.RawLine);
var dhcp3 = Assert.IsType<DhcpHostLine>(lines[3]); var dhcp3 = Assert.IsType<DhcpHostLine>(lines[3]);
Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address); Assert.Equal(dhcpAddress, dhcp3.DhcpHost.Address);
var other4 = Assert.IsType<OtherLine>(lines[4]); var other4 = Assert.IsType<OtherLine>(lines[4]);
Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine); Assert.Equal(rangeLine, other4.RawLine);
} }
[Fact] [Fact]
public void ToLine_Blank_PreservesRawLine() public void ToLine_Blank_PreservesRawLine()
{ {
var line = new BlankLine { LineNumber = 1, RawLine = " " }; var raw = " ";
Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line)); var line = new BlankLine { LineNumber = 1, RawLine = raw };
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
} }
[Fact] [Fact]
public void ToLine_Comment_PreservesRawLine() public void ToLine_Comment_PreservesRawLine()
{ {
var line = new CommentLine { LineNumber = 1, RawLine = "# comment" }; var raw = "# comment";
Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line)); var line = new CommentLine { LineNumber = 1, RawLine = raw };
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
} }
[Fact] [Fact]
public void ToLine_Other_PreservesRawLine() public void ToLine_Other_PreservesRawLine()
{ {
var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" }; var raw = "domain=local";
Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line)); var line = new OtherLine { LineNumber = 1, RawLine = raw };
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
} }
[Fact] [Fact]
public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser() public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser()
{ {
string mac = "aa:bb:cc:dd:ee:ff";
string address = "192.168.1.10";
string name = "testpc";
string lease = "infinite";
var dhcp = new DhcpHostEntry var dhcp = new DhcpHostEntry
{ {
LineNumber = 1, LineNumber = 1,
MacAddresses = ["aa:bb:cc:dd:ee:ff"], MacAddresses = [mac],
Address = "192.168.1.10", Address = address,
Name = "testpc", Name = name,
Lease = "infinite" Lease = lease
}; };
var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp }; var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp };
var back = DnsmasqConfFileLineParser.ToLine(line); var back = DnsmasqConfFileLineParser.ToLine(line);
Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back); Assert.Equal($"dhcp-host={mac}, {name}, {address}, {lease}", back);
} }
[Fact] [Fact]
@@ -46,10 +46,11 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var includePath = Path.Combine(dir, "extra.conf"); var includeFileName = "extra.conf";
var includePath = Path.Combine(dir, includeFileName);
File.WriteAllText(includePath, "# extra\n"); File.WriteAllText(includePath, "# extra\n");
var main = Path.Combine(dir, "dnsmasq.conf"); var main = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(main, "conf-file=extra.conf\n"); File.WriteAllText(main, $"conf-file={includeFileName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count); Assert.Equal(2, paths.Count);
Assert.Equal(Path.GetFullPath(main), paths[0]); Assert.Equal(Path.GetFullPath(main), paths[0]);
@@ -66,19 +67,22 @@ public class DnsmasqConfIncludeParserTests
{ {
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N")); var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir); Directory.CreateDirectory(baseDir);
var subDir = Path.Combine(baseDir, "d"); var confDirName = "d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir); Directory.CreateDirectory(subDir);
var file1 = "aa.conf";
var file2 = "zz.conf";
try try
{ {
File.WriteAllText(Path.Combine(subDir, "zz.conf"), ""); File.WriteAllText(Path.Combine(subDir, file2), "");
File.WriteAllText(Path.Combine(subDir, "aa.conf"), ""); File.WriteAllText(Path.Combine(subDir, file1), "");
var main = Path.Combine(baseDir, "dnsmasq.conf"); var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d\n"); File.WriteAllText(main, $"conf-dir={confDirName}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical) Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
Assert.Equal(Path.GetFullPath(main), paths[0]); Assert.Equal(Path.GetFullPath(main), paths[0]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "aa.conf")), paths[1]); Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "zz.conf")), paths[2]); Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
} }
finally finally
{ {
@@ -109,12 +113,13 @@ public class DnsmasqConfIncludeParserTests
{ {
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N")); var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(baseDir); Directory.CreateDirectory(baseDir);
var subDir = Path.Combine(baseDir, "dnsmasq.d"); var confDirName = "dnsmasq.d";
var subDir = Path.Combine(baseDir, confDirName);
Directory.CreateDirectory(subDir); Directory.CreateDirectory(subDir);
try try
{ {
var main = Path.Combine(baseDir, "dnsmasq.conf"); var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=dnsmasq.d\n"); File.WriteAllText(main, $"conf-dir={confDirName}\n");
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main); var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
Assert.NotNull(first); Assert.NotNull(first);
Assert.Equal(Path.GetFullPath(subDir), first); Assert.Equal(Path.GetFullPath(subDir), first);
@@ -240,12 +245,14 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var firstLeasePath = "/var/first.leases";
var secondLeasePath = "/var/second.leases";
var first = Path.Combine(dir, "first.conf"); var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, "dhcp-leasefile=/var/first.leases\n"); File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
var second = Path.Combine(dir, "second.conf"); var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n"); File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second }); var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
Assert.Equal(Path.GetFullPath("/var/second.leases"), result); Assert.Equal(Path.GetFullPath(secondLeasePath), result);
} }
finally finally
{ {
@@ -260,10 +267,13 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var subdir = "subdir";
var leasesFile = "leases";
var relativePath = $"{subdir}/{leasesFile}";
var conf = Path.Combine(dir, "dnsmasq.conf"); var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "dhcp-leasefile=subdir/leases\n"); File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf }); var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), result); Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
} }
finally finally
{ {
@@ -285,15 +295,18 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var path1 = "/etc/hosts.d/first";
var path2 = "/data/hosts";
var path3 = "/data/extra.hosts";
var first = Path.Combine(dir, "first.conf"); var first = Path.Combine(dir, "first.conf");
File.WriteAllText(first, "addn-hosts=/etc/hosts.d/first\n"); File.WriteAllText(first, $"addn-hosts={path1}\n");
var second = Path.Combine(dir, "second.conf"); var second = Path.Combine(dir, "second.conf");
File.WriteAllText(second, "addn-hosts=/data/hosts\naddn-hosts=/data/extra.hosts\n"); File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second }); var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
Assert.Equal(3, result.Count); Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]); Assert.Equal(Path.GetFullPath(path1), result[0]);
Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]); Assert.Equal(Path.GetFullPath(path2), result[1]);
Assert.Equal(Path.GetFullPath("/data/extra.hosts"), result[2]); Assert.Equal(Path.GetFullPath(path3), result[2]);
} }
finally finally
{ {
@@ -308,11 +321,14 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var pathPart1 = "hosts.d";
var pathPart2 = "app.hosts";
var addnRelativePath = $"{pathPart1}/{pathPart2}";
var conf = Path.Combine(dir, "dnsmasq.conf"); var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "addn-hosts=hosts.d/app.hosts\n"); File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf }); var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
Assert.Single(result); Assert.Single(result);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "hosts.d", "app.hosts")), result[0]); Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
} }
finally finally
{ {
@@ -364,12 +380,14 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf"); var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf"); var f2 = Path.Combine(dir, "02.conf");
var firstVal = "100";
var secondVal = "200";
try try
{ {
File.WriteAllText(f1, "cache-size=100\n"); File.WriteAllText(f1, $"cache-size={firstVal}\n");
File.WriteAllText(f2, "cache-size=200\n"); File.WriteAllText(f2, $"cache-size={secondVal}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize); var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
Assert.Equal("200", value); Assert.Equal(secondVal, value);
Assert.Equal(dir, configDir); Assert.Equal(dir, configDir);
} }
finally finally
@@ -385,10 +403,11 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var logPath = "/data/dnsmasq.log";
var conf = Path.Combine(dir, "dnsmasq.conf"); var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "log-facility=/data/dnsmasq.log\n"); File.WriteAllText(conf, $"log-facility={logPath}\n");
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility); var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
Assert.Equal("/data/dnsmasq.log", value); Assert.Equal(logPath, value);
Assert.Equal(dir, configDir); Assert.Equal(dir, configDir);
} }
finally finally
@@ -404,12 +423,14 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf"); var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf"); var f2 = Path.Combine(dir, "02.conf");
var firstLogPath = "/var/log/dnsmasq.log";
var secondLogPath = "/data/dnsmasq.log";
try try
{ {
File.WriteAllText(f1, "log-facility=/var/log/dnsmasq.log\n"); File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
File.WriteAllText(f2, "log-facility=/data/dnsmasq.log\n"); File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility); var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
Assert.Equal("/data/dnsmasq.log", value); Assert.Equal(secondLogPath, value);
} }
finally finally
{ {
@@ -421,16 +442,19 @@ public class DnsmasqConfIncludeParserTests
public void ResolvePath_Relative_ResolvesAgainstDir() public void ResolvePath_Relative_ResolvesAgainstDir()
{ {
var dir = Path.GetTempPath(); var dir = Path.GetTempPath();
var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir); var subDirName = "sub";
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result); var fileName = "pid.pid";
var relativePath = $"{subDirName}/{fileName}";
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
} }
[Fact] [Fact]
public void ResolvePath_Absolute_ReturnsAsIs() public void ResolvePath_Absolute_ReturnsAsIs()
{ {
var abs = Path.Combine(Path.GetTempPath(), "absolute.pid"); var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir"); var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
Assert.Equal(Path.GetFullPath(abs), result); Assert.Equal(Path.GetFullPath(absPath), result);
} }
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) --- // --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
@@ -443,8 +467,9 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var invalidValue = "1";
var conf = Path.Combine(dir, "dnsmasq.conf"); var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "expand-hosts=1\n"); File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts); var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
Assert.False(result); Assert.False(result);
} }
@@ -462,10 +487,12 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
try try
{ {
var wrongKey = "Port";
var value = "53";
var conf = Path.Combine(dir, "dnsmasq.conf"); var conf = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(conf, "Port=53\n"); File.WriteAllText(conf, $"{wrongKey}={value}\n");
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port); var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
Assert.Null(value); Assert.Null(parsedValue);
} }
finally finally
{ {
@@ -481,13 +508,16 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var main = Path.Combine(dir, "dnsmasq.conf"); var main = Path.Combine(dir, "dnsmasq.conf");
var extra = Path.Combine(dir, "extra.conf"); var extra = Path.Combine(dir, "extra.conf");
var port1 = "53";
var port2 = "5353";
var port3 = "54";
try try
{ {
File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n"); File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
File.WriteAllText(extra, "port=54\n"); File.WriteAllText(extra, $"port={port3}\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port); var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.Equal("54", value); Assert.Equal(port3, value);
} }
finally finally
{ {
@@ -502,18 +532,21 @@ public class DnsmasqConfIncludeParserTests
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N")); var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d"); var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir); Directory.CreateDirectory(subDir);
var fileConf1 = "a.conf";
var fileTxt = "b.txt";
var fileConf2 = "c.conf";
try try
{ {
File.WriteAllText(Path.Combine(subDir, "a.conf"), ""); File.WriteAllText(Path.Combine(subDir, fileConf1), "");
File.WriteAllText(Path.Combine(subDir, "b.txt"), ""); File.WriteAllText(Path.Combine(subDir, fileTxt), "");
File.WriteAllText(Path.Combine(subDir, "c.conf"), ""); File.WriteAllText(Path.Combine(subDir, fileConf2), "");
var main = Path.Combine(baseDir, "dnsmasq.conf"); var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,*.conf\n"); File.WriteAllText(main, "conf-dir=d,*.conf\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(3, paths.Count); Assert.Equal(3, paths.Count);
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal)); Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
Assert.Contains(paths, p => p.EndsWith("c.conf", StringComparison.Ordinal)); Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith("b.txt", StringComparison.Ordinal)); Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
} }
finally finally
{ {
@@ -528,16 +561,18 @@ public class DnsmasqConfIncludeParserTests
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N")); var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
var subDir = Path.Combine(baseDir, "d"); var subDir = Path.Combine(baseDir, "d");
Directory.CreateDirectory(subDir); Directory.CreateDirectory(subDir);
var fileIncluded = "a.conf";
var fileExcluded = "b.conf.bak";
try try
{ {
File.WriteAllText(Path.Combine(subDir, "a.conf"), ""); File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), ""); File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
var main = Path.Combine(baseDir, "dnsmasq.conf"); var main = Path.Combine(baseDir, "dnsmasq.conf");
File.WriteAllText(main, "conf-dir=d,.bak\n"); File.WriteAllText(main, "conf-dir=d,.bak\n");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
Assert.Equal(2, paths.Count); Assert.Equal(2, paths.Count);
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal)); Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
Assert.DoesNotContain(paths, p => p.EndsWith("b.conf.bak", StringComparison.Ordinal)); Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
} }
finally finally
{ {
@@ -553,20 +588,23 @@ public class DnsmasqConfIncludeParserTests
var f1 = Path.Combine(dir, "01.conf"); var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf"); var f2 = Path.Combine(dir, "02.conf");
var managedPath = Path.Combine(dir, "managed.conf"); var managedPath = Path.Combine(dir, "managed.conf");
var path1 = "/etc/hosts.a";
var path2 = "/etc/hosts.b";
var path3 = "/etc/hosts.c";
try try
{ {
File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n"); File.WriteAllText(f1, $"addn-hosts={path1}\n");
File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\n"); File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath); var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
Assert.Equal(3, result.Count); Assert.Equal(3, result.Count);
Assert.Equal(Path.GetFullPath("/etc/hosts.a"), result[0].Path); Assert.Equal(Path.GetFullPath(path1), result[0].Path);
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName); Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
Assert.False(result[0].Source.IsManaged); Assert.False(result[0].Source.IsManaged);
Assert.Equal(1, result[0].Source.LineNumber); Assert.Equal(1, result[0].Source.LineNumber);
Assert.Equal(Path.GetFullPath("/etc/hosts.b"), result[1].Path); Assert.Equal(Path.GetFullPath(path2), result[1].Path);
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName); Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
Assert.Equal(1, result[1].Source.LineNumber); Assert.Equal(1, result[1].Source.LineNumber);
Assert.Equal(Path.GetFullPath("/etc/hosts.c"), result[2].Path); Assert.Equal(Path.GetFullPath(path3), result[2].Path);
Assert.Equal(2, result[2].Source.LineNumber); Assert.Equal(2, result[2].Source.LineNumber);
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged); Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
} }
@@ -583,10 +621,12 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf"); var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedPath = Path.Combine(dir, "zz-managed.conf"); var managedPath = Path.Combine(dir, "zz-managed.conf");
var readOnlyPath = "/other/hosts";
var managedAddnPath = "/managed/hosts";
try try
{ {
File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n"); File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\n"); File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath); var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
Assert.Equal(2, result.Count); Assert.Equal(2, result.Count);
Assert.False(result[0].Source.IsManaged); Assert.False(result[0].Source.IsManaged);
@@ -607,15 +647,18 @@ public class DnsmasqConfIncludeParserTests
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var f1 = Path.Combine(dir, "01.conf"); var f1 = Path.Combine(dir, "01.conf");
var f2 = Path.Combine(dir, "02.conf"); var f2 = Path.Combine(dir, "02.conf");
var v1 = "1.1.1.1";
var v2 = "/local/";
var v3 = "/example.com/192.168.1.1";
try try
{ {
File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n"); File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
File.WriteAllText(f2, "server=/example.com/192.168.1.1\n"); File.WriteAllText(f2, $"server={v3}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys); var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
Assert.Equal(3, result.Count); Assert.Equal(3, result.Count);
Assert.Equal("1.1.1.1", result[0]); Assert.Equal(v1, result[0]);
Assert.Equal("/local/", result[1]); Assert.Equal(v2, result[1]);
Assert.Equal("/example.com/192.168.1.1", result[2]); Assert.Equal(v3, result[2]);
} }
finally finally
{ {
@@ -629,13 +672,15 @@ public class DnsmasqConfIncludeParserTests
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N")); var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf"); var conf = Path.Combine(dir, "dnsmasq.conf");
var range1 = "172.28.0.10,172.28.0.50,12h";
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
try try
{ {
File.WriteAllText(conf, "dhcp-range=172.28.0.10,172.28.0.50,12h\ndhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,24h\n"); File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange); var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
Assert.Equal(2, result.Count); Assert.Equal(2, result.Count);
Assert.Equal("172.28.0.10,172.28.0.50,12h", result[0]); Assert.Equal(range1, result[0]);
Assert.Equal("192.168.1.10,192.168.1.100,255.255.255.0,24h", result[1]); Assert.Equal(range2, result[1]);
} }
finally finally
{ {
@@ -41,7 +41,10 @@ public class DnsmasqConfigSetServiceTests
ConfigSetCache? cache = null; ConfigSetCache? cache = null;
try try
{ {
File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n"); int port = 53;
int cacheSize = 500;
var addnHostsPath = "/etc/hosts.extra";
File.WriteAllText(mainPath, $"port={port}\ncache-size={cacheSize}\naddn-hosts={addnHostsPath}\n");
var options = Options.Create(new DnsmasqOptions var options = Options.Create(new DnsmasqOptions
{ {
MainConfigPath = mainPath, MainConfigPath = mainPath,
@@ -52,10 +55,10 @@ public class DnsmasqConfigSetServiceTests
var (config, sources) = service.GetEffectiveConfigWithSources(); var (config, sources) = service.GetEffectiveConfigWithSources();
Assert.NotNull(config); Assert.NotNull(config);
Assert.Equal(53, config.Port); Assert.Equal(port, config.Port);
Assert.Equal(500, config.CacheSize); Assert.Equal(cacheSize, config.CacheSize);
Assert.Single(config.AddnHostsPaths); Assert.Single(config.AddnHostsPaths);
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), config.AddnHostsPaths[0]); Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
Assert.Empty(config.ServerLocalValues); Assert.Empty(config.ServerLocalValues);
Assert.Empty(config.DhcpRanges); Assert.Empty(config.DhcpRanges);
@@ -64,7 +67,7 @@ public class DnsmasqConfigSetServiceTests
Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName); Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName);
Assert.NotNull(sources.CacheSize); Assert.NotNull(sources.CacheSize);
Assert.Single(sources.AddnHostsPaths); Assert.Single(sources.AddnHostsPaths);
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), sources.AddnHostsPaths[0].Path); Assert.Equal(Path.GetFullPath(addnHostsPath), sources.AddnHostsPaths[0].Path);
Assert.NotNull(sources.AddnHostsPaths[0].Source); Assert.NotNull(sources.AddnHostsPaths[0].Source);
Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName); Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName);
} }
@@ -19,63 +19,113 @@ public class DnsmasqLeasesFileLineParserTests
[Fact] [Fact]
public void ParseLine_ValidFiveFields_Parses() public void ParseLine_ValidFiveFields_Parses()
{ {
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal(946689575, e!.Epoch); Assert.Equal(epoch, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac); Assert.Equal(mac, e.Mac);
Assert.Equal("192.168.1.155", e.Address); Assert.Equal(address, e.Address);
Assert.Equal("wdt", e.Name); Assert.Equal(name, e.Name);
Assert.Equal("01:00:00:00:00:00:05", e.ClientId); Assert.Equal(clientId, e.ClientId);
} }
[Fact] [Fact]
public void ParseLine_UnknownHostname_Asterisk() public void ParseLine_UnknownHostname_Asterisk()
{ {
var e = DnsmasqLeasesFileLineParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04"); long epoch = 946689522;
string mac = "00:00:00:00:00:04";
string address = "192.168.1.237";
string name = "*";
string clientId = "01:00:00:00:00:00:04";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("*", e!.Name); Assert.Equal(name, e!.Name);
Assert.Equal("192.168.1.237", e.Address); Assert.Equal(address, e.Address);
} }
[Fact] [Fact]
public void ParseLine_UnknownClientId_Asterisk() public void ParseLine_UnknownClientId_Asterisk()
{ {
var e = DnsmasqLeasesFileLineParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *"); long epoch = 946689351;
string mac = "00:0f:b0:3a:b5:0b";
string address = "192.168.1.208";
string name = "colinux";
string clientId = "*";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("colinux", e!.Name); Assert.Equal(name, e!.Name);
Assert.Equal("*", e.ClientId); Assert.Equal(clientId, e.ClientId);
} }
[Fact] [Fact]
public void ParseLine_MultipleSpacesBetweenFields_Parses() public void ParseLine_MultipleSpacesBetweenFields_Parses()
{ {
var e = DnsmasqLeasesFileLineParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 "); long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $" {epoch} {mac} {address} {name} {clientId} ";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal(946689575, e!.Epoch); Assert.Equal(epoch, e!.Epoch);
Assert.Equal("00:00:00:00:00:05", e.Mac); Assert.Equal(mac, e.Mac);
Assert.Equal("192.168.1.155", e.Address); Assert.Equal(address, e.Address);
Assert.Equal("wdt", e.Name); Assert.Equal(name, e.Name);
Assert.Equal("01:00:00:00:00:00:05", e.ClientId); Assert.Equal(clientId, e.ClientId);
} }
[Fact] [Fact]
public void ParseLine_TooFewFields_ReturnsNull() public void ParseLine_TooFewFields_ReturnsNull()
{ {
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155")); long epoch = 946689575;
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05")); string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac} {address}"));
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac}"));
} }
[Fact] [Fact]
public void ParseLine_TrailingJunk_ReturnsNull() public void ParseLine_TrailingJunk_ReturnsNull()
{ {
// Parser uses .End() so extra text after fifth field fails // Parser uses .End() so extra text after fifth field fails
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra")); long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{epoch} {mac} {address} {name} {clientId} extra";
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
} }
[Fact] [Fact]
public void ParseLine_InvalidEpoch_ReturnsNull() public void ParseLine_InvalidEpoch_ReturnsNull()
{ {
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05")); string invalidEpoch = "notanum";
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{invalidEpoch} {mac} {address} {name} {clientId}";
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
} }
/// <summary> /// <summary>
@@ -90,9 +140,18 @@ public class DnsmasqLeasesFileLineParserTests
[Fact] [Fact]
public void Timestamp_ConvertsEpochToDateTime() public void Timestamp_ConvertsEpochToDateTime()
{ {
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"); long epoch = 946689575;
string mac = "00:00:00:00:00:05";
string address = "192.168.1.155";
string name = "wdt";
string clientId = "01:00:00:00:00:00:05";
var line = $"{epoch} {mac} {address} {name} {clientId}";
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
Assert.NotNull(e); Assert.NotNull(e);
var dt = e!.Timestamp; Assert.Equal(epoch, e!.Epoch);
var dt = e.Timestamp;
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12 Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
} }
@@ -76,35 +76,41 @@ public class EffectiveDnsmasqConfigTests
[Fact] [Fact]
public void GetLogsPath_AbsolutePath_ReturnsPath() public void GetLogsPath_AbsolutePath_ReturnsPath()
{ {
var config = CreateConfig(logFacility: "/data/dnsmasq.log"); var path = "/data/dnsmasq.log";
Assert.Equal("/data/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config)); var config = CreateConfig(logFacility: path);
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
} }
[Fact] [Fact]
public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath() public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath()
{ {
var config = CreateConfig(logFacility: " /var/log/dnsmasq.log "); var path = "/var/log/dnsmasq.log";
Assert.Equal("/var/log/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config)); var pathWithSpaces = $" {path} ";
var config = CreateConfig(logFacility: pathWithSpaces);
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
} }
[Fact] [Fact]
public void GetLogsPath_RelativePathWithSlash_ReturnsPath() public void GetLogsPath_RelativePathWithSlash_ReturnsPath()
{ {
var config = CreateConfig(logFacility: "logs/dnsmasq.log"); var path = "logs/dnsmasq.log";
Assert.Equal("logs/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config)); var config = CreateConfig(logFacility: path);
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
} }
[Fact] [Fact]
public void GetLogsPath_SyslogFacilityName_ReturnsNull() public void GetLogsPath_SyslogFacilityName_ReturnsNull()
{ {
var config = CreateConfig(logFacility: "local0"); var facility = "local0";
var config = CreateConfig(logFacility: facility);
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
} }
[Fact] [Fact]
public void GetLogsPath_Stderr_ReturnsNull() public void GetLogsPath_Stderr_ReturnsNull()
{ {
var config = CreateConfig(logFacility: "-"); var facility = "-";
var config = CreateConfig(logFacility: facility);
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
} }
} }
@@ -22,7 +22,8 @@ public class HostsFileLineParserTests
[Fact] [Fact]
public void ParseLine_WhitespaceOnly_ReturnsPassthrough() public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
{ {
var e = HostsFileLineParser.ParseLine(" \t ", 2); var raw = " \t ";
var e = HostsFileLineParser.ParseLine(raw, 2);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsPassthrough); Assert.True(e!.IsPassthrough);
} }
@@ -30,7 +31,8 @@ public class HostsFileLineParserTests
[Fact] [Fact]
public void ParseLine_CommentOnly_TreatsWholeLineAsComment() public void ParseLine_CommentOnly_TreatsWholeLineAsComment()
{ {
var e = HostsFileLineParser.ParseLine("# This is a comment", 1); var commentLine = "# This is a comment";
var e = HostsFileLineParser.ParseLine(commentLine, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.True(e.IsPassthrough); Assert.True(e.IsPassthrough);
@@ -42,7 +44,10 @@ public class HostsFileLineParserTests
public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress() public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress()
{ {
// Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment // Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1); string address = "127.0.0.1";
string name = "localhost";
var line = $"# {address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsComment); Assert.True(e!.IsComment);
Assert.Equal("", e.Address); Assert.Equal("", e.Address);
@@ -52,75 +57,99 @@ public class HostsFileLineParserTests
[Fact] [Fact]
public void ParseLine_IPv4Localhost_ParsesAddressAndName() public void ParseLine_IPv4Localhost_ParsesAddressAndName()
{ {
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost", 1); string address = "127.0.0.1";
string name = "localhost";
var line = $"{address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.False(e!.IsPassthrough); Assert.False(e!.IsPassthrough);
Assert.Equal("127.0.0.1", e.Address); Assert.Equal(address, e.Address);
Assert.Single(e.Names); Assert.Single(e.Names);
Assert.Equal("localhost", e.Names[0]); Assert.Equal(name, e.Names[0]);
} }
[Fact] [Fact]
public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll() public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll()
{ {
var e = HostsFileLineParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1); string address = "127.0.1.1";
string canonical = "thishost.example.org";
string alias = "thishost";
var line = $"{address} {canonical} {alias}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("127.0.1.1", e.Address); Assert.Equal(address, e.Address);
Assert.True(e.Names.Count >= 1); Assert.True(e.Names.Count >= 1);
Assert.Equal("thishost.example.org", e.Names[0]); Assert.Equal(canonical, e.Names[0]);
if (e.Names.Count >= 2) if (e.Names.Count >= 2)
Assert.Equal("thishost", e.Names[1]); Assert.Equal(alias, e.Names[1]);
} }
[Fact] [Fact]
public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly() public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly()
{ {
var e = HostsFileLineParser.ParseLine("192.168.1.10 foo.example.org foo", 1); string address = "192.168.1.10";
string name1 = "foo.example.org";
string name2 = "foo";
var line = $"{address} {name1} {name2}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("192.168.1.10", e.Address); Assert.Equal(address, e.Address);
Assert.True(e.Names.Count >= 1); Assert.True(e.Names.Count >= 1);
Assert.Equal("foo.example.org", e.Names[0]); Assert.Equal(name1, e.Names[0]);
} }
[Fact] [Fact]
public void ParseLine_IPv6_ParsesAddressAndNames() public void ParseLine_IPv6_ParsesAddressAndNames()
{ {
var e = HostsFileLineParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1); string address = "::1";
string name1 = "localhost";
var line = $"{address} {name1} ip6-localhost ip6-loopback";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("::1", e.Address); Assert.Equal(address, e.Address);
Assert.True(e.Names.Count >= 1); Assert.True(e.Names.Count >= 1);
Assert.Equal("localhost", e.Names[0]); Assert.Equal(name1, e.Names[0]);
} }
[Fact] [Fact]
public void ParseLine_IPv6Multicast_Parses() public void ParseLine_IPv6Multicast_Parses()
{ {
var e = HostsFileLineParser.ParseLine("ff02::1 ip6-allnodes", 1); string address = "ff02::1";
string name = "ip6-allnodes";
var line = $"{address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("ff02::1", e.Address); Assert.Equal(address, e.Address);
Assert.Single(e.Names); Assert.Single(e.Names);
Assert.Equal("ip6-allnodes", e.Names[0]); Assert.Equal(name, e.Names[0]);
} }
[Fact] [Fact]
public void ParseLine_InlineComment_StopsAtHash() public void ParseLine_InlineComment_StopsAtHash()
{ {
// Token stops at #; only address and names before # are parsed // Token stops at #; only address and names before # are parsed
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost # loopback", 1); string address = "127.0.0.1";
string name = "localhost";
string comment = "loopback";
var line = $"{address} {name} # {comment}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("127.0.0.1", e.Address); Assert.Equal(address, e.Address);
Assert.Single(e.Names); Assert.Single(e.Names);
Assert.Equal("localhost", e.Names[0]); Assert.Equal(name, e.Names[0]);
} }
[Fact] [Fact]
public void ParseLine_LeadingWhitespace_TrimmedAndParsed() public void ParseLine_LeadingWhitespace_TrimmedAndParsed()
{ {
var e = HostsFileLineParser.ParseLine(" 192.168.1.13 bar", 1); string address = "192.168.1.13";
string name = "bar";
var line = $" {address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.Equal("192.168.1.13", e.Address); Assert.Equal(address, e.Address);
Assert.Single(e.Names); Assert.Single(e.Names);
Assert.Equal("bar", e.Names[0]); Assert.Equal(name, e.Names[0]);
} }
[Fact] [Fact]
@@ -136,7 +165,8 @@ public class HostsFileLineParserTests
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough() public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
{ {
// Content requires address + whitespace + at least one name // Content requires address + whitespace + at least one name
var e = HostsFileLineParser.ParseLine("192.168.1.1", 1); string address = "192.168.1.1";
var e = HostsFileLineParser.ParseLine(address, 1);
Assert.NotNull(e); Assert.NotNull(e);
Assert.True(e!.IsPassthrough); Assert.True(e!.IsPassthrough);
} }
@@ -144,7 +174,9 @@ public class HostsFileLineParserTests
[Fact] [Fact]
public void ToLine_Entry_Roundtrips() public void ToLine_Entry_Roundtrips()
{ {
var line = "127.0.0.1 localhost"; string address = "127.0.0.1";
string name = "localhost";
var line = $"{address} {name}";
var e = HostsFileLineParser.ParseLine(line, 1); var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e); Assert.NotNull(e);
var back = HostsFileLineParser.ToLine(e!); var back = HostsFileLineParser.ToLine(e!);
@@ -154,19 +186,23 @@ public class HostsFileLineParserTests
[Fact] [Fact]
public void ToLine_CommentedEntry_PrefixPreserved() public void ToLine_CommentedEntry_PrefixPreserved()
{ {
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1); string address = "127.0.0.1";
string name = "localhost";
var commentLine = $"# {address} {name}";
var e = HostsFileLineParser.ParseLine(commentLine, 1);
Assert.NotNull(e); Assert.NotNull(e);
// When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine. // When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine.
e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true }; e = new HostEntry { LineNumber = 1, Address = address, Names = [name], IsComment = true };
var back = HostsFileLineParser.ToLine(e); var back = HostsFileLineParser.ToLine(e);
Assert.Equal("# 127.0.0.1 localhost", back); Assert.Equal(commentLine, back);
} }
[Fact] [Fact]
public void ToLine_Passthrough_ReturnsRawLine() public void ToLine_Passthrough_ReturnsRawLine()
{ {
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true }; var raw = " \t ";
Assert.Equal(" \t ", HostsFileLineParser.ToLine(e)); var e = new HostEntry { LineNumber = 1, RawLine = raw, IsPassthrough = true };
Assert.Equal(raw, HostsFileLineParser.ToLine(e));
} }
[Fact] [Fact]
+1 -1
View File
@@ -21,7 +21,7 @@
</head> </head>
<body> <body>
<Routes /> <Routes @rendermode="InteractiveServer" />
<script src="_framework/blazor.web.js"></script> <script src="_framework/blazor.web.js"></script>
</body> </body>
@@ -7,8 +7,9 @@
<strong>Service status</strong> <strong>Service status</strong>
@{ @{
var isActive = _status.DnsmasqStatus == "active"; var isActive = _status.DnsmasqStatus == "active";
var isUpdating = _refreshing || IsReloading;
} }
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger")" style="width: 0.5rem; height: 0.5rem;" title="@(isActive ? "Running" : "Not running")" aria-hidden="true"></span> <span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger") @(isUpdating ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(isUpdating ? "Updating…" : (isActive ? "Running" : "Not running"))" aria-hidden="true"></span>
</span> </span>
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval"> <button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i> <i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
@@ -17,6 +17,8 @@ public partial class StatusSection : IDisposable
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15; [Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
[Parameter] public bool IsReloading { get; set; }
[Parameter] public EventCallback OnOpenSettings { get; set; } [Parameter] public EventCallback OnOpenSettings { get; set; }
[Inject] private IStatusClient StatusClient { get; set; } = null!; [Inject] private IStatusClient StatusClient { get; set; } = null!;
@@ -52,9 +54,13 @@ public partial class StatusSection : IDisposable
StateHasChanged(); StateHasChanged();
} }
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
private async Task RefreshAsync() private async Task RefreshAsync()
{ {
var started = DateTime.UtcNow;
_refreshing = true; _refreshing = true;
StateHasChanged();
try try
{ {
var token = _cts.Token; var token = _cts.Token;
@@ -67,6 +73,13 @@ public partial class StatusSection : IDisposable
} }
finally finally
{ {
var elapsed = DateTime.UtcNow - started;
var remaining = MinUpdatingDuration - elapsed;
if (remaining > TimeSpan.Zero)
{
try { await Task.Delay(remaining, _cts.Token).ConfigureAwait(false); }
catch (OperationCanceledException) { }
}
_refreshing = false; _refreshing = false;
} }
} }
@@ -0,0 +1,13 @@
/* Status dot pulse when fetching/updating status */
@keyframes status-dot-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
.status-dot.status-dot-updating {
animation: status-dot-pulse 0.7s ease-in-out infinite;
}
@@ -1,5 +1,7 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@namespace DnsmasqWebUI.Components.Layout @namespace DnsmasqWebUI.Components.Layout
@inject ISettingsModalService SettingsModalService
@implements IDisposable
<div class="page"> <div class="page">
<div class="sidebar"> <div class="sidebar">
@@ -14,8 +16,41 @@
</main> </main>
</div> </div>
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred. An unhandled error has occurred.
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss" aria-label="Dismiss">×</span> <span class="dismiss" aria-label="Dismiss">×</span>
</div> </div>
@code {
private bool _settingsModalOpen;
private string _settingsModalTitle = "Settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
protected override void OnInitialized()
{
SettingsModalService.OpenRequested += OnSettingsOpenRequested;
}
private void OnSettingsOpenRequested(SettingsModalContext context, string title)
{
_settingsModalContext = context;
_settingsModalTitle = title;
_settingsModalOpen = true;
StateHasChanged();
}
private async Task CloseSettingsModal()
{
_settingsModalOpen = false;
SettingsModalService.NotifySettingsChanged();
await InvokeAsync(StateHasChanged);
}
public void Dispose()
{
SettingsModalService.OpenRequested -= OnSettingsOpenRequested;
}
}
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.Layout @namespace DnsmasqWebUI.Components.Layout
@inject IOptions<ApplicationOptions> AppOptions @inject IOptions<ApplicationOptions> AppOptions
@inject ISettingsModalService SettingsModalService
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center"> <div class="container-fluid d-flex align-items-center">
@@ -7,6 +8,9 @@
<span class="brand-logo-wrap"><img src="logo.webp" alt="" class="brand-logo" /></span> <span class="brand-logo-wrap"><img src="logo.webp" alt="" class="brand-logo" /></span>
<span>@AppTitle</span> <span>@AppTitle</span>
</a> </a>
<button type="button" class="nav-settings-btn" aria-label="Settings" title="Settings" @onclick="OpenAllSettings">
<i class="bi bi-gear-fill" aria-hidden="true"></i>
</button>
</div> </div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" /> <input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
</div> </div>
@@ -33,4 +37,6 @@
@code { @code {
private string? AppTitle => AppOptions?.Value?.EffectiveTitle; private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
} }
@@ -22,6 +22,38 @@
background-color: rgba(0,0,0,0.4); background-color: rgba(0,0,0,0.4);
} }
/* Settings button: right of brand, same visual style as nav. On mobile, leaves space for toggler. */
.nav-settings-btn {
margin-left: auto;
margin-right: 0.5rem;
padding: 0.375rem 0.5rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.375rem;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-settings-btn:hover {
background-color: rgba(255, 255, 255, 0.12);
color: white;
}
.nav-settings-btn .bi-gear-fill {
font-size: 1.25rem;
}
/* On mobile: leave space for toggler (right: 1rem, width 3.5rem) so settings doesn't overlap */
@media (max-width: 640.98px) {
.nav-settings-btn {
margin-right: 4rem;
}
}
.navbar-brand { .navbar-brand {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
@@ -16,33 +16,36 @@
{ {
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Search settings</label> <label class="form-label">Search settings</label>
<input type="search" class="form-control" placeholder="Search..." <input type="search" class="form-control" placeholder="Search..." autocomplete="off"
@bind="_searchTerm" @bind:event="oninput" /> @bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
</div> </div>
} }
@if (ShouldShowSection("ServiceStatus")) @if (ShouldShowSection(SettingsModalSections.ServiceStatus))
{ {
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
<section class="mb-4"> <section class="mb-4">
<h6 class="text-muted">Service status</h6> <h6 class="text-muted">@meta.DisplayName</h6>
<label for="status-interval" class="form-label">Polling interval (seconds)</label> <label for="status-interval" class="form-label">@meta.FieldLabel</label>
<input id="status-interval" type="number" class="form-control" min="5" step="1" <input id="status-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" /> @bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
</section> </section>
} }
@if (ShouldShowSection("Logs")) @if (ShouldShowSection(SettingsModalSections.Logs))
{ {
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
<section class="mb-4"> <section class="mb-4">
<h6 class="text-muted">Recent logs</h6> <h6 class="text-muted">@meta.DisplayName</h6>
<label for="logs-interval" class="form-label">Polling interval (seconds)</label> <label for="logs-interval" class="form-label">@meta.FieldLabel</label>
<input id="logs-interval" type="number" class="form-control" min="5" step="1" <input id="logs-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" /> @bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
</section> </section>
} }
@if (ShouldShowSection("Leases")) @if (ShouldShowSection(SettingsModalSections.Leases))
{ {
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
<section class="mb-4"> <section class="mb-4">
<h6 class="text-muted">DHCP leases</h6> <h6 class="text-muted">@meta.DisplayName</h6>
<label for="leases-interval" class="form-label">Refresh interval (seconds)</label> <label for="leases-interval" class="form-label">@meta.FieldLabel</label>
<input id="leases-interval" type="number" class="form-control" min="5" step="1" <input id="leases-interval" type="number" class="form-control" min="5" step="1"
@bind="_editingSettings.LeasesPollingIntervalSeconds" /> @bind="_editingSettings.LeasesPollingIntervalSeconds" />
</section> </section>
@@ -38,6 +38,7 @@ public partial class SettingsModal : IAsyncDisposable
// Only load from storage when the modal opens; parent re-renders (e.g. refresh timer) must not overwrite in-progress edits // Only load from storage when the modal opens; parent re-renders (e.g. refresh timer) must not overwrite in-progress edits
if (justOpened) if (justOpened)
{ {
_searchTerm = string.Empty;
_editingSettings = await ClientSettingsService.LoadSettingsAsync(); _editingSettings = await ClientSettingsService.LoadSettingsAsync();
_editingSettings = new ClientSettings _editingSettings = new ClientSettings
{ {
@@ -68,27 +69,14 @@ public partial class SettingsModal : IAsyncDisposable
} }
} }
private void FilterSettings() => StateHasChanged();
private bool ShouldShowSection(string key) private bool ShouldShowSection(string key)
{ {
if (SettingsContext == SettingsModalContext.ServicePolling) var focusedKey = SettingsModalSections.GetSectionKeyForContext(SettingsContext);
return key == "ServiceStatus"; if (focusedKey != null)
if (SettingsContext == SettingsModalContext.LogsPolling) return string.Equals(key, focusedKey, StringComparison.OrdinalIgnoreCase);
return key == "Logs"; return SettingsModalSections.MatchesSearch(key, _searchTerm);
if (SettingsContext == SettingsModalContext.LeasesPolling)
return key == "Leases";
if (SettingsContext == SettingsModalContext.All)
{
if (string.IsNullOrWhiteSpace(_searchTerm)) return true;
var term = _searchTerm.Trim();
return key switch
{
"ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase),
"Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase),
"Leases" => "dhcp leases refresh polling".Contains(term, StringComparison.OrdinalIgnoreCase),
_ => false
};
}
return false;
} }
private async Task Save() private async Task Save()
+13 -16
View File
@@ -7,6 +7,7 @@
@inject ILeasesClient LeasesClient @inject ILeasesClient LeasesClient
@inject IReloadClient ReloadClient @inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService @inject IClientSettingsService ClientSettingsService
@inject ISettingsModalService SettingsModalService
@inject IOptions<ApplicationOptions> AppOptions @inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable @implements IDisposable
@@ -320,7 +321,6 @@ else
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button> <button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
</div> </div>
} }
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section> </section>
} }
@@ -336,9 +336,6 @@ else
private Timer? _leasesRefreshTimer; private Timer? _leasesRefreshTimer;
private int _leasesRefreshIntervalSeconds = 5; private int _leasesRefreshIntervalSeconds = 5;
private string? _message; private string? _message;
private bool _settingsModalOpen;
private string _settingsModalTitle = "Client settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
private bool _saving; private bool _saving;
private IEnumerable<DhcpHostEntry> _visibleStaticEntries => private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
@@ -404,6 +401,7 @@ else
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
SettingsModalService.SettingsChanged += OnSettingsChanged;
await Load(); await Load();
} }
@@ -428,20 +426,18 @@ else
} }
} }
private void OpenLeasesSettings() private void OpenLeasesSettings() => SettingsModalService.Open(SettingsModalContext.LeasesPolling, "Leases refresh interval");
{
_settingsModalContext = SettingsModalContext.LeasesPolling;
_settingsModalTitle = "Leases refresh interval";
_settingsModalOpen = true;
}
private async Task CloseSettingsModal() private async void OnSettingsChanged()
{ {
_settingsModalOpen = false; await InvokeAsync(async () =>
var clientSettings = await ClientSettingsService.LoadSettingsAsync(); {
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300); var clientSettings = await ClientSettingsService.LoadSettingsAsync();
if (_status != null && _status.LeasesPathConfigured) _leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
StartLeasesRefreshTimer(); if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
StateHasChanged();
});
} }
private void StartLeasesRefreshTimer() private void StartLeasesRefreshTimer()
@@ -640,6 +636,7 @@ else
public void Dispose() public void Dispose()
{ {
SettingsModalService.SettingsChanged -= OnSettingsChanged;
_leasesRefreshTimer?.Dispose(); _leasesRefreshTimer?.Dispose();
_leasesRefreshTimer = null; _leasesRefreshTimer = null;
} }
+20 -28
View File
@@ -5,6 +5,7 @@
@inject IConfigSetClient ConfigSetClient @inject IConfigSetClient ConfigSetClient
@inject IReloadClient ReloadClient @inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService @inject IClientSettingsService ClientSettingsService
@inject ISettingsModalService SettingsModalService
@inject IOptions<ApplicationOptions> AppOptions @inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable @implements IDisposable
@@ -77,10 +78,8 @@ else if (_status != null)
</div> </div>
} }
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" OnOpenSettings="OpenServiceStatusSettings" /> <StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
<LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" /> <LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" />
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
</section> </section>
} }
@@ -88,10 +87,6 @@ else if (_status != null)
private string AppTitle => AppOptions!.Value!.EffectiveTitle; private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private int _serviceStatusIntervalSeconds = 15; private int _serviceStatusIntervalSeconds = 15;
private int _recentLogsIntervalSeconds = 15; private int _recentLogsIntervalSeconds = 15;
private bool _settingsModalOpen;
private string _settingsModalTitle = "Client settings";
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
private ClientSettings _clientSettings = new();
private DnsmasqServiceStatus? _status; private DnsmasqServiceStatus? _status;
private DnsmasqConfigSet? _configSet; private DnsmasqConfigSet? _configSet;
@@ -105,14 +100,15 @@ else if (_status != null)
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
SettingsModalService.SettingsChanged += OnSettingsChanged;
_loading = true; _loading = true;
try try
{ {
await Task.Delay(1, _cts.Token); await Task.Delay(1, _cts.Token);
var token = _cts.Token; var token = _cts.Token;
_clientSettings = await ClientSettingsService.LoadSettingsAsync(); var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300); _serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300); _recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
_status = await StatusClient.GetStatusAsync(token); _status = await StatusClient.GetStatusAsync(token);
if (_status != null) if (_status != null)
{ {
@@ -140,9 +136,20 @@ else if (_status != null)
} }
} }
private void OpenServiceStatusSettings() => OpenSettingsModal(SettingsModalContext.ServicePolling, "Service status polling"); private void OpenServiceStatusSettings() => SettingsModalService.Open(SettingsModalContext.ServicePolling, "Service status polling");
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling"); private void OpenRecentLogsSettings() => SettingsModalService.Open(SettingsModalContext.LogsPolling, "Recent logs polling");
private async void OnSettingsChanged()
{
await InvokeAsync(async () =>
{
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
StateHasChanged();
});
}
private async Task ReloadDnsmasq() private async Task ReloadDnsmasq()
{ {
@@ -193,24 +200,9 @@ else if (_status != null)
return parts.Count > 0 ? string.Join("\n\n", parts) : null; return parts.Count > 0 ? string.Join("\n\n", parts) : null;
} }
private void OpenSettingsModal(SettingsModalContext context, string title)
{
_settingsModalContext = context;
_settingsModalTitle = title;
_settingsModalOpen = true;
}
private async Task CloseSettingsModal()
{
_settingsModalOpen = false;
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
StateHasChanged();
}
public void Dispose() public void Dispose()
{ {
SettingsModalService.SettingsChanged -= OnSettingsChanged;
_cts.Cancel(); _cts.Cancel();
_cts.Dispose(); _cts.Dispose();
} }
@@ -0,0 +1,23 @@
using DnsmasqWebUI.Models.Client;
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
/// <summary>
/// Scoped service to open the app-level settings modal from anywhere (e.g. NavMenu, pages).
/// MainLayout subscribes to <see cref="OpenRequested"/> and hosts the modal.
/// Subscribe to <see cref="SettingsChanged"/> to refresh when settings are saved/closed.
/// </summary>
public interface ISettingsModalService
{
/// <summary>Raised when <see cref="Open"/> is called. MainLayout subscribes and shows the modal.</summary>
event Action<SettingsModalContext, string>? OpenRequested;
/// <summary>Raised when the modal is closed (save or cancel). Pages subscribe to reload their intervals.</summary>
event Action? SettingsChanged;
/// <summary>Opens the settings modal with the given context and title.</summary>
void Open(SettingsModalContext context, string title);
/// <summary>Called when the modal closes. Raises <see cref="SettingsChanged"/>.</summary>
void NotifySettingsChanged();
}
@@ -0,0 +1,15 @@
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using DnsmasqWebUI.Models.Client;
namespace DnsmasqWebUI.Infrastructure.Services;
/// <inheritdoc cref="ISettingsModalService" />
public sealed class SettingsModalService : ISettingsModalService
{
public event Action<SettingsModalContext, string>? OpenRequested;
public event Action? SettingsChanged;
public void Open(SettingsModalContext context, string title) => OpenRequested?.Invoke(context, title);
public void NotifySettingsChanged() => SettingsChanged?.Invoke();
}
@@ -1,19 +0,0 @@
namespace DnsmasqWebUI.Models.Client;
/// <summary>
/// Which subset of client settings to show in the settings modal.
/// </summary>
public enum SettingsModalContext
{
/// <summary>Show all settings with optional search.</summary>
All,
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
LogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling
}
@@ -0,0 +1,55 @@
namespace DnsmasqWebUI.Models.Client;
/// <summary>
/// Which subset of client settings to show in the settings modal.
/// </summary>
public enum SettingsModalContext
{
/// <summary>Show all settings with optional search.</summary>
All,
/// <summary>Show only service status polling interval.</summary>
ServicePolling,
/// <summary>Show only recent logs polling interval.</summary>
LogsPolling,
/// <summary>Show only DHCP leases refresh interval.</summary>
LeasesPolling
}
/// <summary>
/// Central definition of settings modal sections: keys, display names, field labels, and searchable text.
/// </summary>
public static class SettingsModalSections
{
public const string ServiceStatus = "ServiceStatus";
public const string Logs = "Logs";
public const string Leases = "Leases";
/// <summary>Section metadata: display name (header), field label (for input), and searchable text for filtering.</summary>
public sealed record SectionMeta(string DisplayName, string FieldLabel, string SearchableText);
public static readonly IReadOnlyDictionary<string, SectionMeta> All = new Dictionary<string, SectionMeta>(StringComparer.OrdinalIgnoreCase)
{
[ServiceStatus] = new("Service status", "Polling interval (seconds)", "service status polling interval seconds"),
[Logs] = new("Recent logs", "Polling interval (seconds)", "recent logs polling interval seconds"),
[Leases] = new("DHCP leases", "Refresh interval (seconds)", "dhcp leases refresh interval seconds"),
};
/// <summary>Maps a focused context (single-section view) to its section key. Returns null for All.</summary>
public static string? GetSectionKeyForContext(SettingsModalContext context) => context switch
{
SettingsModalContext.ServicePolling => ServiceStatus,
SettingsModalContext.LogsPolling => Logs,
SettingsModalContext.LeasesPolling => Leases,
_ => null
};
public static bool MatchesSearch(string key, string searchTerm)
{
if (string.IsNullOrWhiteSpace(searchTerm)) return true;
if (!All.TryGetValue(key, out var meta)) return false;
return meta.SearchableText.Contains(searchTerm.Trim(), StringComparison.OrdinalIgnoreCase);
}
}
+3
View File
@@ -2,6 +2,8 @@ using DnsmasqWebUI.Components;
using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Extensions;
using DnsmasqWebUI.Infrastructure.Helpers.Http; using DnsmasqWebUI.Infrastructure.Helpers.Http;
using DnsmasqWebUI.Infrastructure.Services;
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
// When not in Development, use the app's directory (not CWD) so static assets work when run via symlink or from any CWD. // When not in Development, use the app's directory (not CWD) so static assets work when run via symlink or from any CWD.
@@ -22,6 +24,7 @@ builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsVa
// ---- Application services ---- // ---- Application services ----
builder.Services.AddApplicationServices(); builder.Services.AddApplicationServices();
builder.Services.AddScoped<ISettingsModalService, SettingsModalService>();
builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpContextAccessor();
builder.Services.AddDnsmasqApiHttpClients(); builder.Services.AddDnsmasqApiHttpClients();
+1 -3
View File
@@ -1,4 +1,4 @@
# Config for the dnsmasq test container only (docker-compose.test.yml). # Config for the dnsmasq test container only (docker-compose.test.yml).
# DNS + DHCP: forwards DNS to Cloudflare/Google with caching; hands out DHCP on testnet. # DNS + DHCP: forwards DNS to Cloudflare/Google with caching; hands out DHCP on testnet.
# Uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases. # Uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases.
# Richer config so UI shows multi-value lists (server, addn-hosts, address, dhcp-range) and readonly sources. # Richer config so UI shows multi-value lists (server, addn-hosts, address, dhcp-range) and readonly sources.
@@ -32,5 +32,3 @@ addn-hosts=/data/hosts.extra
# Log to file so the harness can show real logs (LogsCommand tails this) # Log to file so the harness can show real logs (LogsCommand tails this)
log-facility=/data/dnsmasq.log log-facility=/data/dnsmasq.log
log-queries log-queries
conf-file=zz-dnsmasq-webui.conf