From b70aa02017c1012cbcbaf1bf2dce54481390bfde Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Mon, 9 Feb 2026 14:59:01 +1000 Subject: [PATCH] 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 --- .../ConfigValueSourceTests.cs | 19 +- .../DnsmasqConfDhcpHostLineParserTests.cs | 163 ++++++++++++---- .../DnsmasqConfDirectiveParserTests.cs | 20 +- .../DnsmasqConfFileLineParserTests.cs | 97 ++++++---- .../DnsmasqConfIncludeParserTests.cs | 179 +++++++++++------- .../DnsmasqConfigSetServiceTests.cs | 13 +- .../DnsmasqLeasesFileLineParserTests.cs | 107 ++++++++--- .../EffectiveDnsmasqConfigTests.cs | 22 ++- .../HostsFileLineParserTests.cs | 100 ++++++---- src/DnsmasqWebUI/Components/App.razor | 2 +- .../Components/Dnsmasq/StatusSection.razor | 3 +- .../Components/Dnsmasq/StatusSection.razor.cs | 13 ++ .../Dnsmasq/StatusSection.razor.css | 13 ++ .../Components/Layout/MainLayout.razor | 35 ++++ .../Components/Layout/NavMenu.razor | 6 + .../Components/Layout/NavMenu.razor.css | 32 ++++ .../Components/Modals/SettingsModal.razor | 25 +-- .../Components/Modals/SettingsModal.razor.cs | 26 +-- src/DnsmasqWebUI/Components/Pages/Dhcp.razor | 29 ++- .../Components/Pages/Dnsmasq.razor | 48 ++--- .../Abstractions/ISettingsModalService.cs | 23 +++ .../Services/SettingsModalService.cs | 15 ++ .../Models/Client/SettingsModalContext.cs | 19 -- .../Models/Client/SettingsModalSections.cs | 55 ++++++ src/DnsmasqWebUI/Program.cs | 3 + testdata/dnsmasq-test.conf | 4 +- 26 files changed, 754 insertions(+), 317 deletions(-) create mode 100644 src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor.css create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/Abstractions/ISettingsModalService.cs create mode 100644 src/DnsmasqWebUI/Infrastructure/Services/SettingsModalService.cs delete mode 100644 src/DnsmasqWebUI/Models/Client/SettingsModalContext.cs create mode 100644 src/DnsmasqWebUI/Models/Client/SettingsModalSections.cs diff --git a/src/DnsmasqWebUI.Tests/ConfigValueSourceTests.cs b/src/DnsmasqWebUI.Tests/ConfigValueSourceTests.cs index 263ebba..0a9abd7 100644 --- a/src/DnsmasqWebUI.Tests/ConfigValueSourceTests.cs +++ b/src/DnsmasqWebUI.Tests/ConfigValueSourceTests.cs @@ -17,25 +17,30 @@ public class ConfigValueSourceTests [Fact] 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(); Assert.NotNull(tooltip); - Assert.Contains("02.conf", tooltip); + Assert.Contains(fileName, tooltip); Assert.Contains("readonly", tooltip); - Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip); + Assert.Contains(fullPath, tooltip); Assert.DoesNotContain("line", tooltip); } [Fact] 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(); Assert.NotNull(tooltip); - Assert.Contains("02.conf", tooltip); - Assert.Contains("line 3", tooltip); + Assert.Contains(fileName, tooltip); + Assert.Contains($"line {lineNumber}", tooltip); Assert.Contains("readonly", tooltip); - Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip); + Assert.Contains(fullPath, tooltip); } [Fact] diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs index 43f4494..a1cb55c 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDhcpHostLineParserTests.cs @@ -20,136 +20,219 @@ public class DnsmasqConfDhcpHostLineParserTests [Fact] 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.False(e!.IsComment); Assert.False(e.IsDeleted); Assert.Single(e.MacAddresses); - Assert.Equal("aa:bb:cc:dd:ee:ff", e.MacAddresses[0]); - Assert.Equal("192.168.1.10", e.Address); - Assert.Equal("testpc", e.Name); - Assert.Equal("infinite", e.Lease); + Assert.Equal(mac, e.MacAddresses[0]); + Assert.Equal(address, e.Address); + Assert.Equal(name, e.Name); + Assert.Equal(lease, e.Lease); Assert.Empty(e.Extra); } [Fact] 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.True(e!.IsComment); Assert.False(e.IsDeleted); Assert.Single(e.MacAddresses); - Assert.Equal("192.168.1.10", e.Address); + Assert.Equal(address, e.Address); } [Fact] 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.True(e!.IsComment); Assert.True(e.IsDeleted); Assert.Single(e.MacAddresses); - Assert.Equal("192.168.1.10", e.Address); + Assert.Equal(address, e.Address); } [Fact] 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.Equal("main laptop", e!.Comment); + Assert.Equal(comment, e!.Comment); Assert.Single(e.MacAddresses); } [Fact] 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.Equal(2, e!.MacAddresses.Count); - Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]); - Assert.Equal("12:34:56:78:90:12", e.MacAddresses[1]); - Assert.Equal("192.168.0.2", e.Address); + Assert.Equal(mac1, e.MacAddresses[0]); + Assert.Equal(mac2, e.MacAddresses[1]); + Assert.Equal(address, e.Address); } [Fact] 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.Equal("192.168.1.41", e!.Address); - Assert.Equal("redhost1", e.Name); - Assert.Contains(e.Extra, x => x == "set:red"); + Assert.Equal(address, e!.Address); + Assert.Equal(name, e.Name); + Assert.Contains(e.Extra, x => x == extraTag); } [Fact] 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.Equal("lap", e!.Name); - Assert.Equal("192.168.0.199", e.Address); + Assert.Equal(name, e!.Name); + Assert.Equal(address, e.Address); Assert.Empty(e.MacAddresses); } [Fact] 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.True(e!.Ignore); Assert.Single(e.MacAddresses); - Assert.Equal("00:20:e0:3b:13:af", e.MacAddresses[0]); + Assert.Equal(mac, e.MacAddresses[0]); } [Fact] 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.Equal("3600", e!.Lease); + Assert.Equal(lease, e!.Lease); } [Fact] 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.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase)); - Assert.Equal("192.168.1.50", e.Address); + Assert.Equal(address, e.Address); } [Fact] 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); + Assert.NotNull(e); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("dhcp-host=", back); - Assert.Contains("aa:bb:cc:dd:ee:ff", back); - Assert.Contains("192.168.1.10", back); - Assert.Contains("testpc", back); - Assert.Contains("infinite", back); + Assert.Contains(mac, back); + Assert.Contains(address, back); + Assert.Contains(name, back); + Assert.Contains(lease, back); } [Fact] 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); + Assert.NotNull(e); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("dhcp-host=", back); - Assert.Contains("main laptop", back); + Assert.Contains(comment, back); } [Fact] 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); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("#dhcp-host=", back); @@ -158,7 +241,13 @@ public class DnsmasqConfDhcpHostLineParserTests [Fact] 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); var back = DnsmasqConfDhcpHostLineParser.ToLine(e!); Assert.StartsWith("##dhcp-host=", back); diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs index 2651dbd..2719f34 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs @@ -26,10 +26,12 @@ public class DnsmasqConfDirectiveParserTests [Fact] 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.Equal(DnsmasqConfKeys.Port, kv!.Value.key); - Assert.Equal("53", kv.Value.value); + Assert.Equal(value, kv.Value.value); } [Fact] @@ -50,16 +52,22 @@ public class DnsmasqConfDirectiveParserTests [Fact] public void StripComment_CommentAfterSpace_StripsToEnd() { - Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # DNS port")); - Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment")); + var before = "port=53"; + var comment1 = " DNS port"; + var comment2 = " comment"; + Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment1)); + Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment2)); } [Fact] 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.Equal(DnsmasqConfKeys.Port, kv!.Value.key); - Assert.Equal("53", kv.Value.value); + Assert.Equal(value, kv.Value.value); } } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs index 02bf3fa..0c64829 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfFileLineParserTests.cs @@ -19,73 +19,89 @@ public class DnsmasqConfFileLineParserTests [Fact] public void ParseFile_BlankLine_BlankKind() { - var lines = DnsmasqConfFileLineParser.ParseFile([""]); + var raw = ""; + var lines = DnsmasqConfFileLineParser.ParseFile([raw]); Assert.Single(lines); var blank = Assert.IsType(lines[0]); Assert.Equal(1, blank.LineNumber); - Assert.Equal("", blank.RawLine); + Assert.Equal(raw, blank.RawLine); } [Fact] public void ParseFile_BlankLineWithSpaces_BlankKind() { - var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]); + var raw = " \t "; + var lines = DnsmasqConfFileLineParser.ParseFile([raw]); Assert.Single(lines); var blank = Assert.IsType(lines[0]); - Assert.Equal(" \t ", blank.RawLine); + Assert.Equal(raw, blank.RawLine); } [Fact] 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); var comment = Assert.IsType(lines[0]); Assert.Equal(1, comment.LineNumber); - Assert.Equal("# Not managed by this app.", comment.RawLine); + Assert.Equal(commentLine, comment.RawLine); } [Fact] public void ParseFile_OtherDirective_OtherKind() { - var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]); + var raw = "domain=local"; + var lines = DnsmasqConfFileLineParser.ParseFile([raw]); Assert.Single(lines); var other = Assert.IsType(lines[0]); - Assert.Equal("domain=local", other.RawLine); + Assert.Equal(raw, other.RawLine); } [Fact] 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); var addn = Assert.IsType(lines[0]); - Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath); + Assert.Equal(path, addn.AddnHostsPath); } [Fact] public void ToLine_AddnHosts_EmitsAddnHostsLine() { - var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" }; - Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line)); + var path = "/etc/hosts"; + var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = path }; + Assert.Equal($"addn-hosts={path}", DnsmasqConfFileLineParser.ToLine(line)); } [Fact] 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); var dhcpLine = Assert.IsType(lines[0]); Assert.Single(dhcpLine.DhcpHost.MacAddresses); - Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]); - Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address); + Assert.Equal(mac, dhcpLine.DhcpHost.MacAddresses[0]); + Assert.Equal(address, dhcpLine.DhcpHost.Address); } [Fact] public void ParseFile_CommentedDhcpHost_TreatedAsComment() { // 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.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind); } @@ -93,13 +109,21 @@ public class DnsmasqConfFileLineParserTests [Fact] 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[] { "# Sample config", "", - "domain=local", - "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite", - "dhcp-range=192.168.1.100,192.168.1.200" + domainLine, + dhcpLine, + rangeLine }; var lines = DnsmasqConfFileLineParser.ParseFile(input); Assert.Equal(5, lines.Count); @@ -107,48 +131,55 @@ public class DnsmasqConfFileLineParserTests Assert.IsType(lines[0]); Assert.IsType(lines[1]); var other2 = Assert.IsType(lines[2]); - Assert.Equal("domain=local", other2.RawLine); + Assert.Equal(domainLine, other2.RawLine); var dhcp3 = Assert.IsType(lines[3]); - Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address); + Assert.Equal(dhcpAddress, dhcp3.DhcpHost.Address); var other4 = Assert.IsType(lines[4]); - Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine); + Assert.Equal(rangeLine, other4.RawLine); } [Fact] public void ToLine_Blank_PreservesRawLine() { - var line = new BlankLine { LineNumber = 1, RawLine = " " }; - Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line)); + var raw = " "; + var line = new BlankLine { LineNumber = 1, RawLine = raw }; + Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line)); } [Fact] public void ToLine_Comment_PreservesRawLine() { - var line = new CommentLine { LineNumber = 1, RawLine = "# comment" }; - Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line)); + var raw = "# comment"; + var line = new CommentLine { LineNumber = 1, RawLine = raw }; + Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line)); } [Fact] public void ToLine_Other_PreservesRawLine() { - var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" }; - Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line)); + var raw = "domain=local"; + var line = new OtherLine { LineNumber = 1, RawLine = raw }; + Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line)); } [Fact] 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 { LineNumber = 1, - MacAddresses = ["aa:bb:cc:dd:ee:ff"], - Address = "192.168.1.10", - Name = "testpc", - Lease = "infinite" + MacAddresses = [mac], + Address = address, + Name = name, + Lease = lease }; var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp }; 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] diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs index 44d0efe..b4a3dbb 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs @@ -46,10 +46,11 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { - var includePath = Path.Combine(dir, "extra.conf"); + var includeFileName = "extra.conf"; + var includePath = Path.Combine(dir, includeFileName); File.WriteAllText(includePath, "# extra\n"); 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); Assert.Equal(2, paths.Count); 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")); Directory.CreateDirectory(baseDir); - var subDir = Path.Combine(baseDir, "d"); + var confDirName = "d"; + var subDir = Path.Combine(baseDir, confDirName); Directory.CreateDirectory(subDir); + var file1 = "aa.conf"; + var file2 = "zz.conf"; try { - File.WriteAllText(Path.Combine(subDir, "zz.conf"), ""); - File.WriteAllText(Path.Combine(subDir, "aa.conf"), ""); + File.WriteAllText(Path.Combine(subDir, file2), ""); + File.WriteAllText(Path.Combine(subDir, file1), ""); 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); Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical) 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, "zz.conf")), paths[2]); + Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]); + Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]); } finally { @@ -109,12 +113,13 @@ public class DnsmasqConfIncludeParserTests { var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(baseDir); - var subDir = Path.Combine(baseDir, "dnsmasq.d"); + var confDirName = "dnsmasq.d"; + var subDir = Path.Combine(baseDir, confDirName); Directory.CreateDirectory(subDir); try { 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); Assert.NotNull(first); Assert.Equal(Path.GetFullPath(subDir), first); @@ -240,12 +245,14 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { + var firstLeasePath = "/var/first.leases"; + var secondLeasePath = "/var/second.leases"; 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"); - File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n"); + File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n"); var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second }); - Assert.Equal(Path.GetFullPath("/var/second.leases"), result); + Assert.Equal(Path.GetFullPath(secondLeasePath), result); } finally { @@ -260,10 +267,13 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { + var subdir = "subdir"; + var leasesFile = "leases"; + var relativePath = $"{subdir}/{leasesFile}"; 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 }); - Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), result); + Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result); } finally { @@ -285,15 +295,18 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { + var path1 = "/etc/hosts.d/first"; + var path2 = "/data/hosts"; + var path3 = "/data/extra.hosts"; 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"); - 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 }); Assert.Equal(3, result.Count); - Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]); - Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]); - Assert.Equal(Path.GetFullPath("/data/extra.hosts"), result[2]); + Assert.Equal(Path.GetFullPath(path1), result[0]); + Assert.Equal(Path.GetFullPath(path2), result[1]); + Assert.Equal(Path.GetFullPath(path3), result[2]); } finally { @@ -308,11 +321,14 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { + var pathPart1 = "hosts.d"; + var pathPart2 = "app.hosts"; + var addnRelativePath = $"{pathPart1}/{pathPart2}"; 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 }); 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 { @@ -364,12 +380,14 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); var f1 = Path.Combine(dir, "01.conf"); var f2 = Path.Combine(dir, "02.conf"); + var firstVal = "100"; + var secondVal = "200"; try { - File.WriteAllText(f1, "cache-size=100\n"); - File.WriteAllText(f2, "cache-size=200\n"); + File.WriteAllText(f1, $"cache-size={firstVal}\n"); + File.WriteAllText(f2, $"cache-size={secondVal}\n"); var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize); - Assert.Equal("200", value); + Assert.Equal(secondVal, value); Assert.Equal(dir, configDir); } finally @@ -385,10 +403,11 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { + var logPath = "/data/dnsmasq.log"; 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); - Assert.Equal("/data/dnsmasq.log", value); + Assert.Equal(logPath, value); Assert.Equal(dir, configDir); } finally @@ -404,12 +423,14 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); var f1 = Path.Combine(dir, "01.conf"); var f2 = Path.Combine(dir, "02.conf"); + var firstLogPath = "/var/log/dnsmasq.log"; + var secondLogPath = "/data/dnsmasq.log"; try { - File.WriteAllText(f1, "log-facility=/var/log/dnsmasq.log\n"); - File.WriteAllText(f2, "log-facility=/data/dnsmasq.log\n"); + File.WriteAllText(f1, $"log-facility={firstLogPath}\n"); + File.WriteAllText(f2, $"log-facility={secondLogPath}\n"); var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility); - Assert.Equal("/data/dnsmasq.log", value); + Assert.Equal(secondLogPath, value); } finally { @@ -421,16 +442,19 @@ public class DnsmasqConfIncludeParserTests public void ResolvePath_Relative_ResolvesAgainstDir() { var dir = Path.GetTempPath(); - var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir); - Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result); + var subDirName = "sub"; + 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] public void ResolvePath_Absolute_ReturnsAsIs() { - var abs = Path.Combine(Path.GetTempPath(), "absolute.pid"); - var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir"); - Assert.Equal(Path.GetFullPath(abs), result); + var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid"); + var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir"); + Assert.Equal(Path.GetFullPath(absPath), result); } // --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) --- @@ -443,8 +467,9 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { + var invalidValue = "1"; 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); Assert.False(result); } @@ -462,10 +487,12 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); try { + var wrongKey = "Port"; + var value = "53"; var conf = Path.Combine(dir, "dnsmasq.conf"); - File.WriteAllText(conf, "Port=53\n"); - var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port); - Assert.Null(value); + File.WriteAllText(conf, $"{wrongKey}={value}\n"); + var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port); + Assert.Null(parsedValue); } finally { @@ -481,13 +508,16 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); var main = Path.Combine(dir, "dnsmasq.conf"); var extra = Path.Combine(dir, "extra.conf"); + var port1 = "53"; + var port2 = "5353"; + var port3 = "54"; try { - File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n"); - File.WriteAllText(extra, "port=54\n"); + File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n"); + File.WriteAllText(extra, $"port={port3}\n"); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port); - Assert.Equal("54", value); + Assert.Equal(port3, value); } finally { @@ -502,18 +532,21 @@ public class DnsmasqConfIncludeParserTests var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N")); var subDir = Path.Combine(baseDir, "d"); Directory.CreateDirectory(subDir); + var fileConf1 = "a.conf"; + var fileTxt = "b.txt"; + var fileConf2 = "c.conf"; try { - File.WriteAllText(Path.Combine(subDir, "a.conf"), ""); - File.WriteAllText(Path.Combine(subDir, "b.txt"), ""); - File.WriteAllText(Path.Combine(subDir, "c.conf"), ""); + File.WriteAllText(Path.Combine(subDir, fileConf1), ""); + File.WriteAllText(Path.Combine(subDir, fileTxt), ""); + File.WriteAllText(Path.Combine(subDir, fileConf2), ""); var main = Path.Combine(baseDir, "dnsmasq.conf"); File.WriteAllText(main, "conf-dir=d,*.conf\n"); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); Assert.Equal(3, paths.Count); - Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal)); - Assert.Contains(paths, p => p.EndsWith("c.conf", StringComparison.Ordinal)); - Assert.DoesNotContain(paths, p => p.EndsWith("b.txt", StringComparison.Ordinal)); + Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal)); + Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal)); + Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal)); } finally { @@ -528,16 +561,18 @@ public class DnsmasqConfIncludeParserTests var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N")); var subDir = Path.Combine(baseDir, "d"); Directory.CreateDirectory(subDir); + var fileIncluded = "a.conf"; + var fileExcluded = "b.conf.bak"; try { - File.WriteAllText(Path.Combine(subDir, "a.conf"), ""); - File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), ""); + File.WriteAllText(Path.Combine(subDir, fileIncluded), ""); + File.WriteAllText(Path.Combine(subDir, fileExcluded), ""); var main = Path.Combine(baseDir, "dnsmasq.conf"); File.WriteAllText(main, "conf-dir=d,.bak\n"); var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); Assert.Equal(2, paths.Count); - Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal)); - Assert.DoesNotContain(paths, p => p.EndsWith("b.conf.bak", StringComparison.Ordinal)); + Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal)); + Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal)); } finally { @@ -553,20 +588,23 @@ public class DnsmasqConfIncludeParserTests var f1 = Path.Combine(dir, "01.conf"); var f2 = Path.Combine(dir, "02.conf"); var managedPath = Path.Combine(dir, "managed.conf"); + var path1 = "/etc/hosts.a"; + var path2 = "/etc/hosts.b"; + var path3 = "/etc/hosts.c"; try { - File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n"); - File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\n"); + File.WriteAllText(f1, $"addn-hosts={path1}\n"); + File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n"); var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath); 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.False(result[0].Source.IsManaged); 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(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.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged); } @@ -583,10 +621,12 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); var mainPath = Path.Combine(dir, "dnsmasq.conf"); var managedPath = Path.Combine(dir, "zz-managed.conf"); + var readOnlyPath = "/other/hosts"; + var managedAddnPath = "/managed/hosts"; try { - File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n"); - File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\n"); + File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n"); + File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n"); var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath); Assert.Equal(2, result.Count); Assert.False(result[0].Source.IsManaged); @@ -607,15 +647,18 @@ public class DnsmasqConfIncludeParserTests Directory.CreateDirectory(dir); var f1 = Path.Combine(dir, "01.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 { - File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n"); - File.WriteAllText(f2, "server=/example.com/192.168.1.1\n"); + File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n"); + File.WriteAllText(f2, $"server={v3}\n"); var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys); Assert.Equal(3, result.Count); - Assert.Equal("1.1.1.1", result[0]); - Assert.Equal("/local/", result[1]); - Assert.Equal("/example.com/192.168.1.1", result[2]); + Assert.Equal(v1, result[0]); + Assert.Equal(v2, result[1]); + Assert.Equal(v3, result[2]); } finally { @@ -629,13 +672,15 @@ public class DnsmasqConfIncludeParserTests var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(dir); 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 { - 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); Assert.Equal(2, result.Count); - Assert.Equal("172.28.0.10,172.28.0.50,12h", result[0]); - Assert.Equal("192.168.1.10,192.168.1.100,255.255.255.0,24h", result[1]); + Assert.Equal(range1, result[0]); + Assert.Equal(range2, result[1]); } finally { diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs index 000686e..5bbc74e 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs @@ -41,7 +41,10 @@ public class DnsmasqConfigSetServiceTests ConfigSetCache? cache = null; 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 { MainConfigPath = mainPath, @@ -52,10 +55,10 @@ public class DnsmasqConfigSetServiceTests var (config, sources) = service.GetEffectiveConfigWithSources(); Assert.NotNull(config); - Assert.Equal(53, config.Port); - Assert.Equal(500, config.CacheSize); + Assert.Equal(port, config.Port); + Assert.Equal(cacheSize, config.CacheSize); 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.DhcpRanges); @@ -64,7 +67,7 @@ public class DnsmasqConfigSetServiceTests Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName); Assert.NotNull(sources.CacheSize); 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.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName); } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs index dbfc44d..e7fa5a4 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqLeasesFileLineParserTests.cs @@ -19,63 +19,113 @@ public class DnsmasqLeasesFileLineParserTests [Fact] 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.Equal(946689575, e!.Epoch); - Assert.Equal("00:00:00:00:00:05", e.Mac); - Assert.Equal("192.168.1.155", e.Address); - Assert.Equal("wdt", e.Name); - Assert.Equal("01:00:00:00:00:00:05", e.ClientId); + Assert.Equal(epoch, e!.Epoch); + Assert.Equal(mac, e.Mac); + Assert.Equal(address, e.Address); + Assert.Equal(name, e.Name); + Assert.Equal(clientId, e.ClientId); } [Fact] 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.Equal("*", e!.Name); - Assert.Equal("192.168.1.237", e.Address); + Assert.Equal(name, e!.Name); + Assert.Equal(address, e.Address); } [Fact] 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.Equal("colinux", e!.Name); - Assert.Equal("*", e.ClientId); + Assert.Equal(name, e!.Name); + Assert.Equal(clientId, e.ClientId); } [Fact] 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.Equal(946689575, e!.Epoch); - Assert.Equal("00:00:00:00:00:05", e.Mac); - Assert.Equal("192.168.1.155", e.Address); - Assert.Equal("wdt", e.Name); - Assert.Equal("01:00:00:00:00:00:05", e.ClientId); + Assert.Equal(epoch, e!.Epoch); + Assert.Equal(mac, e.Mac); + Assert.Equal(address, e.Address); + Assert.Equal(name, e.Name); + Assert.Equal(clientId, e.ClientId); } [Fact] public void ParseLine_TooFewFields_ReturnsNull() { - Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155")); - Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05")); + long epoch = 946689575; + 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] public void ParseLine_TrailingJunk_ReturnsNull() { // 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] 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)); } /// @@ -90,9 +140,18 @@ public class DnsmasqLeasesFileLineParserTests [Fact] 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); - 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 } diff --git a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs index d8ee717..fbc7982 100644 --- a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs +++ b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs @@ -76,35 +76,41 @@ public class EffectiveDnsmasqConfigTests [Fact] public void GetLogsPath_AbsolutePath_ReturnsPath() { - var config = CreateConfig(logFacility: "/data/dnsmasq.log"); - Assert.Equal("/data/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config)); + var path = "/data/dnsmasq.log"; + var config = CreateConfig(logFacility: path); + Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config)); } [Fact] public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath() { - var config = CreateConfig(logFacility: " /var/log/dnsmasq.log "); - Assert.Equal("/var/log/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config)); + var path = "/var/log/dnsmasq.log"; + var pathWithSpaces = $" {path} "; + var config = CreateConfig(logFacility: pathWithSpaces); + Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config)); } [Fact] public void GetLogsPath_RelativePathWithSlash_ReturnsPath() { - var config = CreateConfig(logFacility: "logs/dnsmasq.log"); - Assert.Equal("logs/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config)); + var path = "logs/dnsmasq.log"; + var config = CreateConfig(logFacility: path); + Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config)); } [Fact] public void GetLogsPath_SyslogFacilityName_ReturnsNull() { - var config = CreateConfig(logFacility: "local0"); + var facility = "local0"; + var config = CreateConfig(logFacility: facility); Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); } [Fact] public void GetLogsPath_Stderr_ReturnsNull() { - var config = CreateConfig(logFacility: "-"); + var facility = "-"; + var config = CreateConfig(logFacility: facility); Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); } } diff --git a/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs index a4a94f2..58b29ba 100644 --- a/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs +++ b/src/DnsmasqWebUI.Tests/HostsFileLineParserTests.cs @@ -22,7 +22,8 @@ public class HostsFileLineParserTests [Fact] public void ParseLine_WhitespaceOnly_ReturnsPassthrough() { - var e = HostsFileLineParser.ParseLine(" \t ", 2); + var raw = " \t "; + var e = HostsFileLineParser.ParseLine(raw, 2); Assert.NotNull(e); Assert.True(e!.IsPassthrough); } @@ -30,7 +31,8 @@ public class HostsFileLineParserTests [Fact] 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.True(e!.IsComment); Assert.True(e.IsPassthrough); @@ -42,7 +44,10 @@ public class HostsFileLineParserTests public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress() { // 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.True(e!.IsComment); Assert.Equal("", e.Address); @@ -52,75 +57,99 @@ public class HostsFileLineParserTests [Fact] 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.False(e!.IsPassthrough); - Assert.Equal("127.0.0.1", e.Address); + Assert.Equal(address, e.Address); Assert.Single(e.Names); - Assert.Equal("localhost", e.Names[0]); + Assert.Equal(name, e.Names[0]); } [Fact] 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.Equal("127.0.1.1", e.Address); + Assert.Equal(address, e.Address); 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) - Assert.Equal("thishost", e.Names[1]); + Assert.Equal(alias, e.Names[1]); } [Fact] 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.Equal("192.168.1.10", e.Address); + Assert.Equal(address, e.Address); Assert.True(e.Names.Count >= 1); - Assert.Equal("foo.example.org", e.Names[0]); + Assert.Equal(name1, e.Names[0]); } [Fact] 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.Equal("::1", e.Address); + Assert.Equal(address, e.Address); Assert.True(e.Names.Count >= 1); - Assert.Equal("localhost", e.Names[0]); + Assert.Equal(name1, e.Names[0]); } [Fact] 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.Equal("ff02::1", e.Address); + Assert.Equal(address, e.Address); Assert.Single(e.Names); - Assert.Equal("ip6-allnodes", e.Names[0]); + Assert.Equal(name, e.Names[0]); } [Fact] public void ParseLine_InlineComment_StopsAtHash() { // 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.Equal("127.0.0.1", e.Address); + Assert.Equal(address, e.Address); Assert.Single(e.Names); - Assert.Equal("localhost", e.Names[0]); + Assert.Equal(name, e.Names[0]); } [Fact] 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.Equal("192.168.1.13", e.Address); + Assert.Equal(address, e.Address); Assert.Single(e.Names); - Assert.Equal("bar", e.Names[0]); + Assert.Equal(name, e.Names[0]); } [Fact] @@ -136,7 +165,8 @@ public class HostsFileLineParserTests public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough() { // 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.True(e!.IsPassthrough); } @@ -144,7 +174,9 @@ public class HostsFileLineParserTests [Fact] 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); Assert.NotNull(e); var back = HostsFileLineParser.ToLine(e!); @@ -154,19 +186,23 @@ public class HostsFileLineParserTests [Fact] 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); // 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); - Assert.Equal("# 127.0.0.1 localhost", back); + Assert.Equal(commentLine, back); } [Fact] public void ToLine_Passthrough_ReturnsRawLine() { - var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true }; - Assert.Equal(" \t ", HostsFileLineParser.ToLine(e)); + var raw = " \t "; + var e = new HostEntry { LineNumber = 1, RawLine = raw, IsPassthrough = true }; + Assert.Equal(raw, HostsFileLineParser.ToLine(e)); } [Fact] diff --git a/src/DnsmasqWebUI/Components/App.razor b/src/DnsmasqWebUI/Components/App.razor index 4e0e089..4504bb7 100644 --- a/src/DnsmasqWebUI/Components/App.razor +++ b/src/DnsmasqWebUI/Components/App.razor @@ -21,7 +21,7 @@ - + diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor index c5e11e2..365e228 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/StatusSection.razor @@ -7,8 +7,9 @@ Service status @{ var isActive = _status.DnsmasqStatus == "active"; + var isUpdating = _refreshing || IsReloading; } - + @@ -33,4 +37,6 @@ @code { private string? AppTitle => AppOptions?.Value?.EffectiveTitle; + + private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings"); } diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css index 0664247..6a09520 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor.css @@ -22,6 +22,38 @@ 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 { font-size: 1.1rem; font-weight: 600; diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor index 5d608ba..90d5ae8 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor @@ -16,33 +16,36 @@ {
- +
} - @if (ShouldShowSection("ServiceStatus")) + @if (ShouldShowSection(SettingsModalSections.ServiceStatus)) { + var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
-
Service status
- +
@meta.DisplayName
+
} - @if (ShouldShowSection("Logs")) + @if (ShouldShowSection(SettingsModalSections.Logs)) { + var meta = SettingsModalSections.All[SettingsModalSections.Logs];
-
Recent logs
- +
@meta.DisplayName
+
} - @if (ShouldShowSection("Leases")) + @if (ShouldShowSection(SettingsModalSections.Leases)) { + var meta = SettingsModalSections.All[SettingsModalSections.Leases];
-
DHCP leases
- +
@meta.DisplayName
+
diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs index be77fca..6108777 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs @@ -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 if (justOpened) { + _searchTerm = string.Empty; _editingSettings = await ClientSettingsService.LoadSettingsAsync(); _editingSettings = new ClientSettings { @@ -68,27 +69,14 @@ public partial class SettingsModal : IAsyncDisposable } } + private void FilterSettings() => StateHasChanged(); + private bool ShouldShowSection(string key) { - if (SettingsContext == SettingsModalContext.ServicePolling) - return key == "ServiceStatus"; - if (SettingsContext == SettingsModalContext.LogsPolling) - return key == "Logs"; - 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; + var focusedKey = SettingsModalSections.GetSectionKeyForContext(SettingsContext); + if (focusedKey != null) + return string.Equals(key, focusedKey, StringComparison.OrdinalIgnoreCase); + return SettingsModalSections.MatchesSearch(key, _searchTerm); } private async Task Save() diff --git a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor index ffa20ca..a0112cb 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dhcp.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dhcp.razor @@ -7,6 +7,7 @@ @inject ILeasesClient LeasesClient @inject IReloadClient ReloadClient @inject IClientSettingsService ClientSettingsService +@inject ISettingsModalService SettingsModalService @inject IOptions AppOptions @implements IDisposable @@ -320,7 +321,6 @@ else } - } @@ -336,9 +336,6 @@ else private Timer? _leasesRefreshTimer; private int _leasesRefreshIntervalSeconds = 5; private string? _message; - private bool _settingsModalOpen; - private string _settingsModalTitle = "Client settings"; - private SettingsModalContext _settingsModalContext = SettingsModalContext.All; private bool _saving; private IEnumerable _visibleStaticEntries => @@ -404,6 +401,7 @@ else protected override async Task OnInitializedAsync() { + SettingsModalService.SettingsChanged += OnSettingsChanged; await Load(); } @@ -428,20 +426,18 @@ else } } - private void OpenLeasesSettings() - { - _settingsModalContext = SettingsModalContext.LeasesPolling; - _settingsModalTitle = "Leases refresh interval"; - _settingsModalOpen = true; - } + private void OpenLeasesSettings() => SettingsModalService.Open(SettingsModalContext.LeasesPolling, "Leases refresh interval"); - private async Task CloseSettingsModal() + private async void OnSettingsChanged() { - _settingsModalOpen = false; - var clientSettings = await ClientSettingsService.LoadSettingsAsync(); - _leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300); - if (_status != null && _status.LeasesPathConfigured) - StartLeasesRefreshTimer(); + await InvokeAsync(async () => + { + var clientSettings = await ClientSettingsService.LoadSettingsAsync(); + _leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300); + if (_status != null && _status.LeasesPathConfigured) + StartLeasesRefreshTimer(); + StateHasChanged(); + }); } private void StartLeasesRefreshTimer() @@ -640,6 +636,7 @@ else public void Dispose() { + SettingsModalService.SettingsChanged -= OnSettingsChanged; _leasesRefreshTimer?.Dispose(); _leasesRefreshTimer = null; } diff --git a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor index b3a0dbb..9f26764 100644 --- a/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor +++ b/src/DnsmasqWebUI/Components/Pages/Dnsmasq.razor @@ -5,6 +5,7 @@ @inject IConfigSetClient ConfigSetClient @inject IReloadClient ReloadClient @inject IClientSettingsService ClientSettingsService +@inject ISettingsModalService SettingsModalService @inject IOptions AppOptions @implements IDisposable @@ -77,10 +78,8 @@ else if (_status != null) } - + - - } @@ -88,10 +87,6 @@ else if (_status != null) private string AppTitle => AppOptions!.Value!.EffectiveTitle; private int _serviceStatusIntervalSeconds = 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 DnsmasqConfigSet? _configSet; @@ -105,14 +100,15 @@ else if (_status != null) protected override async Task OnInitializedAsync() { + SettingsModalService.SettingsChanged += OnSettingsChanged; _loading = true; try { await Task.Delay(1, _cts.Token); var token = _cts.Token; - _clientSettings = await ClientSettingsService.LoadSettingsAsync(); - _serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300); - _recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300); + var clientSettings = await ClientSettingsService.LoadSettingsAsync(); + _serviceStatusIntervalSeconds = Math.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300); + _recentLogsIntervalSeconds = Math.Clamp(clientSettings.RecentLogsPollingIntervalSeconds, 5, 300); _status = await StatusClient.GetStatusAsync(token); 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() { @@ -193,24 +200,9 @@ else if (_status != 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() { + SettingsModalService.SettingsChanged -= OnSettingsChanged; _cts.Cancel(); _cts.Dispose(); } diff --git a/src/DnsmasqWebUI/Infrastructure/Services/Abstractions/ISettingsModalService.cs b/src/DnsmasqWebUI/Infrastructure/Services/Abstractions/ISettingsModalService.cs new file mode 100644 index 0000000..ed43713 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/Abstractions/ISettingsModalService.cs @@ -0,0 +1,23 @@ +using DnsmasqWebUI.Models.Client; + +namespace DnsmasqWebUI.Infrastructure.Services.Abstractions; + +/// +/// Scoped service to open the app-level settings modal from anywhere (e.g. NavMenu, pages). +/// MainLayout subscribes to and hosts the modal. +/// Subscribe to to refresh when settings are saved/closed. +/// +public interface ISettingsModalService +{ + /// Raised when is called. MainLayout subscribes and shows the modal. + event Action? OpenRequested; + + /// Raised when the modal is closed (save or cancel). Pages subscribe to reload their intervals. + event Action? SettingsChanged; + + /// Opens the settings modal with the given context and title. + void Open(SettingsModalContext context, string title); + + /// Called when the modal closes. Raises . + void NotifySettingsChanged(); +} diff --git a/src/DnsmasqWebUI/Infrastructure/Services/SettingsModalService.cs b/src/DnsmasqWebUI/Infrastructure/Services/SettingsModalService.cs new file mode 100644 index 0000000..7015c14 --- /dev/null +++ b/src/DnsmasqWebUI/Infrastructure/Services/SettingsModalService.cs @@ -0,0 +1,15 @@ +using DnsmasqWebUI.Infrastructure.Services.Abstractions; +using DnsmasqWebUI.Models.Client; + +namespace DnsmasqWebUI.Infrastructure.Services; + +/// +public sealed class SettingsModalService : ISettingsModalService +{ + public event Action? OpenRequested; + public event Action? SettingsChanged; + + public void Open(SettingsModalContext context, string title) => OpenRequested?.Invoke(context, title); + + public void NotifySettingsChanged() => SettingsChanged?.Invoke(); +} diff --git a/src/DnsmasqWebUI/Models/Client/SettingsModalContext.cs b/src/DnsmasqWebUI/Models/Client/SettingsModalContext.cs deleted file mode 100644 index a500a9c..0000000 --- a/src/DnsmasqWebUI/Models/Client/SettingsModalContext.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace DnsmasqWebUI.Models.Client; - -/// -/// Which subset of client settings to show in the settings modal. -/// -public enum SettingsModalContext -{ - /// Show all settings with optional search. - All, - - /// Show only service status polling interval. - ServicePolling, - - /// Show only recent logs polling interval. - LogsPolling, - - /// Show only DHCP leases refresh interval. - LeasesPolling -} diff --git a/src/DnsmasqWebUI/Models/Client/SettingsModalSections.cs b/src/DnsmasqWebUI/Models/Client/SettingsModalSections.cs new file mode 100644 index 0000000..15741d3 --- /dev/null +++ b/src/DnsmasqWebUI/Models/Client/SettingsModalSections.cs @@ -0,0 +1,55 @@ +namespace DnsmasqWebUI.Models.Client; + +/// +/// Which subset of client settings to show in the settings modal. +/// +public enum SettingsModalContext +{ + /// Show all settings with optional search. + All, + + /// Show only service status polling interval. + ServicePolling, + + /// Show only recent logs polling interval. + LogsPolling, + + /// Show only DHCP leases refresh interval. + LeasesPolling +} + +/// +/// Central definition of settings modal sections: keys, display names, field labels, and searchable text. +/// +public static class SettingsModalSections +{ + public const string ServiceStatus = "ServiceStatus"; + public const string Logs = "Logs"; + public const string Leases = "Leases"; + + /// Section metadata: display name (header), field label (for input), and searchable text for filtering. + public sealed record SectionMeta(string DisplayName, string FieldLabel, string SearchableText); + + public static readonly IReadOnlyDictionary All = new Dictionary(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"), + }; + + /// Maps a focused context (single-section view) to its section key. Returns null for All. + 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); + } +} diff --git a/src/DnsmasqWebUI/Program.cs b/src/DnsmasqWebUI/Program.cs index 045eaf1..b895880 100644 --- a/src/DnsmasqWebUI/Program.cs +++ b/src/DnsmasqWebUI/Program.cs @@ -2,6 +2,8 @@ using DnsmasqWebUI.Components; using DnsmasqWebUI.Models.Config; using DnsmasqWebUI.Extensions; using DnsmasqWebUI.Infrastructure.Helpers.Http; +using DnsmasqWebUI.Infrastructure.Services; +using DnsmasqWebUI.Infrastructure.Services.Abstractions; 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. @@ -22,6 +24,7 @@ builder.Services.AddSingleton, DnsmasqOptionsVa // ---- Application services ---- builder.Services.AddApplicationServices(); +builder.Services.AddScoped(); builder.Services.AddHttpContextAccessor(); builder.Services.AddDnsmasqApiHttpClients(); diff --git a/testdata/dnsmasq-test.conf b/testdata/dnsmasq-test.conf index a0f2b5d..e365b75 100644 --- a/testdata/dnsmasq-test.conf +++ b/testdata/dnsmasq-test.conf @@ -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. # 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. @@ -32,5 +32,3 @@ addn-hosts=/data/hosts.extra # Log to file so the harness can show real logs (LogsCommand tails this) log-facility=/data/dnsmasq.log log-queries - -conf-file=zz-dnsmasq-webui.conf