diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..172f10e --- /dev/null +++ b/agents.md @@ -0,0 +1,80 @@ +# Agent instructions: dnsmasq-webui + +## Test harness (Docker) + +The test harness runs the app and dnsmasq in Docker with DHCP clients so you can test the UI and effective config against real dnsmasq. + +**Script:** `scripts/prepare-test-mount.sh` +**Compose file:** `docker-compose.test.yml` + +### Start the harness + +From the repo root: + +```bash +./scripts/prepare-test-mount.sh +``` + +This will: + +1. Clear `testdata-mount/` (unless you pass `--no-clear`) +2. Sync `testdata/` → `testdata-mount/` +3. Remove any `*dnsmasq-webui*.conf` in the mount so dnsmasq starts clean +4. Run `docker compose -f docker-compose.test.yml up -d --build` + +- **Quick restart (no image rebuild):** `./scripts/prepare-test-mount.sh --no-build` +- **Prepare mount only (no start):** `./scripts/prepare-test-mount.sh --prepare-only` + Then start manually: `TESTDATA_MOUNT=./testdata-mount docker compose -f docker-compose.test.yml up -d [--build]` +- **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-clear --no-build` + +App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory. + +### Stop the harness + +```bash +./scripts/prepare-test-mount.sh --stop +``` + +Runs `docker compose -f docker-compose.test.yml down` (stops and removes containers/networks). Does not change the mount directory. + +### Clean (stop + clear mount) + +```bash +./scripts/prepare-test-mount.sh --tidy +``` + +Stops the harness and deletes the contents of `testdata-mount/` so the next run starts from a clean sync of `testdata/`. + +### Custom paths + +- `--source DIR` — Source to sync from (default: `testdata`) +- `--mount DIR` — Mount directory (default: `testdata-mount`). The script exports `TESTDATA_MOUNT=./DIR` when you use `--mount`, so compose uses it. + +Example: sync from a custom dir and start: + +```bash +./scripts/prepare-test-mount.sh --source myfixtures --mount mymount +``` + +### Help + +```bash +./scripts/prepare-test-mount.sh -h +``` + +## Unit tests + +From the repo root: + +```bash +dotnet test +``` + +Build only: `dotnet build` + +## Test data + +- **testdata/** — Source for the harness mount. Synced to `testdata-mount/` by `prepare-test-mount.sh`. Unit tests read from the copy in the test output dir. See **testdata/README** for the full layout. +- **testdata/dnsmasq-test.conf** — Harness main config (`/data/dnsmasq-test.conf`). Richer config: multiple `server=`, `addn-hosts=`, `address=`, `listen-address=`, `conf-dir=/data/dnsmasq.d`. +- **testdata/dnsmasq.d/** — Included configs (01-other.conf, 02-servers.conf, dhcp.conf). App creates `zz-dnsmasq-webui.conf` here when running. +- **testdata/hosts**, **testdata/hosts.extra** — Addn-hosts files so effective config has multiple addn-hosts; UI can show source per path. diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs index d6e2645..2977444 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfDirectiveParserTests.cs @@ -61,7 +61,7 @@ public class DnsmasqConfDirectiveParserTests [Fact] public void ParseLine_DhcpLeaseFile_AlternativeKey_ReturnsDhcpLeaseFileOption() { - var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-lease-file=/run/dnsmasq.leases", 1, SourcePath); + var d = DnsmasqConfDirectiveParser.ParseLine("dhcp-lease=/run/dnsmasq.leases", 1, SourcePath); Assert.NotNull(d); Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, d!.Kind); var opt = Assert.IsType(d.TypedOption); @@ -220,4 +220,22 @@ public class DnsmasqConfDirectiveParserTests Assert.Equal("aa:bb:cc:dd:ee:ff", opt.MacAddresses[0]); Assert.Equal("192.168.1.10", opt.Address); } + + /// dnsmasq read_file: strip from first '#' that is at word start (after whitespace) to EOL. + [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")); + } + + /// TryParseKeyValue uses StripComment; value should not include # or rest of line. + [Fact] + public void TryParseKeyValue_LineWithComment_ValueExcludesComment() + { + var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port"); + Assert.NotNull(kv); + Assert.Equal("port", kv!.Value.key); + Assert.Equal("53", kv.Value.value); + } } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs index ae5ef8a..1387a51 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs @@ -166,6 +166,27 @@ public class DnsmasqConfIncludeParserTests Assert.Equal(Path.GetFullPath("/data/leases"), result); } + /// Uses testdata/dnsmasq-test.conf (harness main) to assert multi-value options are collected from testdata. + [Fact] + public void GetMultiValueFromConfigFiles_FromTestdata_ReturnsServerAndAddnHosts() + { + var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf"); + if (!File.Exists(mainPath)) + return; + var paths = new[] { mainPath }; + var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, new[] { "server", "local" }); + var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths); + var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "address"); + var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, "listen-address"); + Assert.True(servers.Count >= 2, "testdata dnsmasq-test.conf should have at least 2 server= lines"); + Assert.Contains("1.1.1.1", servers); + Assert.Contains("8.8.8.8", servers); + Assert.True(addnHosts.Count >= 2, "testdata should have at least 2 addn-hosts paths"); + Assert.True(addresses.Count >= 2, "testdata should have at least 2 address= lines"); + Assert.Single(listenAddrs); + Assert.Equal("172.28.0.2", listenAddrs[0]); + } + [Fact] public void GetDhcpLeaseFilePathFromConfigFiles_LastWins() { @@ -365,4 +386,209 @@ public class DnsmasqConfIncludeParserTests var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir"); Assert.Equal(Path.GetFullPath(abs), result); } + + // --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) --- + + /// dnsmasq option.c: opts[i].has_arg == 0 && arg -> "extraneous parameter"; flag with value is invalid, so we must not count it. + [Fact] + public void GetFlagFromConfigFiles_FlagWithValue_ReturnsFalse() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-flag-val-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(conf, "expand-hosts=1\n"); + var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, "expand-hosts"); + Assert.False(result); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + /// dnsmasq option.c: strcmp(opts[i].name, start) == 0 — option names are case-sensitive; "Port" is bad option. + [Fact] + public void GetLastValueFromConfigFiles_CaseSensitive_PortWithCapitalP_ReturnsNull() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-case-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(conf, "Port=53\n"); + var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, "port"); + Assert.Null(value); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + /// Path list is [main, conf-file...]. We read each file entirely in that order, so last port is last occurrence in that stream (main lines then extra). Main has port=53, port=5353; extra has port=54 → last is 54. + [Fact] + public void GetLastValueFromConfigFiles_TwoFiles_LastOccurrenceInPathOrderWins() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-two-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var main = Path.Combine(dir, "dnsmasq.conf"); + var extra = Path.Combine(dir, "extra.conf"); + try + { + File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n"); + File.WriteAllText(extra, "port=54\n"); + var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main); + var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "port"); + Assert.Equal("54", value); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + /// dnsmasq conf-dir=dir,*.suffix only includes files ending with suffix (option.c match_suffix). + [Fact] + public void GetIncludedPaths_ConfDir_WithMatchSuffix_OnlyIncludesMatchingFiles() + { + var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N")); + var subDir = Path.Combine(baseDir, "d"); + Directory.CreateDirectory(subDir); + try + { + File.WriteAllText(Path.Combine(subDir, "a.conf"), ""); + File.WriteAllText(Path.Combine(subDir, "b.txt"), ""); + File.WriteAllText(Path.Combine(subDir, "c.conf"), ""); + 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)); + } + finally + { + Directory.Delete(baseDir, recursive: true); + } + } + + /// dnsmasq conf-dir=dir,.suffix ignores files ending with suffix (option.c ignore_suffix). + [Fact] + public void GetIncludedPaths_ConfDir_WithIgnoreSuffix_ExcludesMatchingFiles() + { + var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N")); + var subDir = Path.Combine(baseDir, "d"); + Directory.CreateDirectory(subDir); + try + { + File.WriteAllText(Path.Combine(subDir, "a.conf"), ""); + File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), ""); + 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)); + } + finally + { + Directory.Delete(baseDir, recursive: true); + } + } + + [Fact] + public void GetAddnHostsPathsFromConfigFilesWithSource_ReturnsSourcePerPath() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-src-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var f1 = Path.Combine(dir, "01.conf"); + var f2 = Path.Combine(dir, "02.conf"); + var managedPath = Path.Combine(dir, "managed.conf"); + try + { + File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n"); + File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\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.GetFileName(f1), result[0].Source.FileName); + Assert.False(result[0].Source.IsManaged); + Assert.Equal(Path.GetFullPath("/etc/hosts.b"), result[1].Path); + Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName); + Assert.Equal(Path.GetFullPath("/etc/hosts.c"), result[2].Path); + Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetAddnHostsPathsFromConfigFilesWithSource_WhenManagedFileInList_IsManagedTrue() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-addn-mgd-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + var managedPath = Path.Combine(dir, "zz-managed.conf"); + try + { + File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n"); + File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\n"); + var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath); + Assert.Equal(2, result.Count); + Assert.False(result[0].Source.IsManaged); + Assert.True(result[1].Source.IsManaged); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetMultiValueFromConfigFiles_ServerAndLocal_CollectsBothInOrder() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-multi-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var f1 = Path.Combine(dir, "01.conf"); + var f2 = Path.Combine(dir, "02.conf"); + try + { + File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n"); + File.WriteAllText(f2, "server=/example.com/192.168.1.1\n"); + var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, new[] { "server", "local" }); + 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]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetMultiValueFromConfigFiles_DhcpRange_CollectsAllInOrder() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var conf = Path.Combine(dir, "dnsmasq.conf"); + 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"); + var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, "dhcp-range"); + 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]); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } } diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs index 7487e51..1fd0a4f 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfOptionRegistryTests.cs @@ -37,7 +37,7 @@ public class DnsmasqConfOptionRegistryTests public void GetKind_DhcpLeasefile_ReturnsDhcpLeaseFile() { Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-leasefile")); - Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-lease-file")); + Assert.Equal(DnsmasqOptionKind.DhcpLeaseFile, DnsmasqConfOptionRegistry.GetKind("dhcp-lease")); } [Fact] @@ -76,7 +76,7 @@ public class DnsmasqConfOptionRegistryTests var registry = DnsmasqConfOptionRegistry.OptionKindByKey; Assert.True(registry.ContainsKey("addn-hosts")); Assert.True(registry.ContainsKey("dhcp-leasefile")); - Assert.True(registry.ContainsKey("dhcp-lease-file")); + Assert.True(registry.ContainsKey("dhcp-lease")); Assert.True(registry.ContainsKey("conf-file")); Assert.True(registry.ContainsKey("conf-dir")); Assert.True(registry.ContainsKey("domain")); diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs new file mode 100644 index 0000000..09b5c56 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfigSetServiceTests.cs @@ -0,0 +1,72 @@ +using DnsmasqWebUI.Configuration; +using DnsmasqWebUI.Models; +using DnsmasqWebUI.Services; +using Microsoft.Extensions.Options; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for DnsmasqConfigSetService, including GetEffectiveConfigWithSources. +/// +public class DnsmasqConfigSetServiceTests +{ + [Fact] + public void GetEffectiveConfigWithSources_NoMainPath_ReturnsDefaultConfigAndDefaultSources() + { + var options = Options.Create(new DnsmasqOptions { MainConfigPath = "" }); + var service = new DnsmasqConfigSetService(options); + var (config, sources) = service.GetEffectiveConfigWithSources(); + + Assert.NotNull(config); + Assert.False(config.NoHosts); + Assert.Empty(config.AddnHostsPaths); + Assert.NotNull(sources); + Assert.Empty(sources.AddnHostsPaths); + Assert.Empty(sources.ServerLocalValues); + Assert.Empty(sources.DhcpRanges); + Assert.Null(sources.NoHosts); + Assert.Null(sources.Port); + Assert.Null(sources.DhcpLeaseFilePath); + } + + [Fact] + public void GetEffectiveConfigWithSources_WithTempConfig_ReturnsConfigAndSourcesFromFiles() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-svc-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + var mainPath = Path.Combine(dir, "dnsmasq.conf"); + var managedPath = Path.Combine(dir, "zz-dnsmasq-webui.conf"); + try + { + File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n"); + var options = Options.Create(new DnsmasqOptions + { + MainConfigPath = mainPath, + ManagedFileName = "zz-dnsmasq-webui.conf" + }); + var service = new DnsmasqConfigSetService(options); + var (config, sources) = service.GetEffectiveConfigWithSources(); + + Assert.NotNull(config); + Assert.Equal(53, config.Port); + Assert.Equal(500, config.CacheSize); + Assert.Single(config.AddnHostsPaths); + Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), config.AddnHostsPaths[0]); + Assert.Empty(config.ServerLocalValues); + Assert.Empty(config.DhcpRanges); + + Assert.NotNull(sources); + Assert.NotNull(sources.Port); + 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.NotNull(sources.AddnHostsPaths[0].Source); + Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } +} diff --git a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs index a93a2eb..d27a155 100644 --- a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs +++ b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs @@ -11,6 +11,15 @@ public class EffectiveDnsmasqConfigTests new( NoHosts: false, AddnHostsPaths: Array.Empty(), + ServerLocalValues: Array.Empty(), + AddressValues: Array.Empty(), + Interfaces: Array.Empty(), + ListenAddresses: Array.Empty(), + ExceptInterfaces: Array.Empty(), + DhcpRanges: Array.Empty(), + DhcpHostLines: Array.Empty(), + DhcpOptionLines: Array.Empty(), + ResolvFiles: Array.Empty(), ExpandHosts: false, BogusPriv: false, StrictOrder: false, diff --git a/src/DnsmasqWebUI/Client/Http/StatusClient.cs b/src/DnsmasqWebUI/Client/Http/StatusClient.cs index 521fa57..d925150 100644 --- a/src/DnsmasqWebUI/Client/Http/StatusClient.cs +++ b/src/DnsmasqWebUI/Client/Http/StatusClient.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using DnsmasqWebUI.Client.Http.Abstractions; using DnsmasqWebUI.Models; using System.Net.Http.Json; @@ -8,9 +9,16 @@ public sealed class StatusClient : IStatusClient { private readonly HttpClient _http; + /// Matches API serialization (camelCase) so nested EffectiveConfigSources and tuple (value, source) deserialize correctly. + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + public StatusClient(HttpClient http) => _http = http; public async Task GetStatusAsync(CancellationToken ct = default) => - await _http.GetFromJsonAsync("api/status", ct) + await _http.GetFromJsonAsync("api/status", JsonOptions, ct) ?? throw new InvalidOperationException("Unexpected null from api/status."); } diff --git a/src/DnsmasqWebUI/Components/App.razor b/src/DnsmasqWebUI/Components/App.razor index 7530f58..a704564 100644 --- a/src/DnsmasqWebUI/Components/App.razor +++ b/src/DnsmasqWebUI/Components/App.razor @@ -23,5 +23,5 @@ @code { - private string _appTitle => AppOptions!.Value!.EffectiveTitle; + private string? _appTitle => AppOptions?.Value?.EffectiveTitle; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/EffectiveConfigMultiValueRow.razor new file mode 100644 index 0000000..216fd42 --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/EffectiveConfigMultiValueRow.razor @@ -0,0 +1,10 @@ +@namespace DnsmasqWebUI.Components.EffectiveConfig + +
  • + @Label: @Content +
  • + +@code { + [Parameter] public string Label { get; set; } = ""; + [Parameter] public MarkupString Content { get; set; } +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/EffectiveConfigOptionItem.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/EffectiveConfigOptionItem.razor new file mode 100644 index 0000000..9fbc7e2 --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/EffectiveConfigOptionItem.razor @@ -0,0 +1,15 @@ +@namespace DnsmasqWebUI.Components.EffectiveConfig + +
  • + @Label: @DisplayValue + @if (Source?.IsReadOnly == true) + { + readonly + } +
  • + +@code { + [Parameter] public string Label { get; set; } = ""; + [Parameter] public string DisplayValue { get; set; } = ""; + [Parameter] public ConfigValueSource? Source { get; set; } +} diff --git a/src/DnsmasqWebUI/Components/Home/LogsSection.razor b/src/DnsmasqWebUI/Components/Home/LogsSection.razor index 8dc9af5..ebc085d 100644 --- a/src/DnsmasqWebUI/Components/Home/LogsSection.razor +++ b/src/DnsmasqWebUI/Components/Home/LogsSection.razor @@ -1,4 +1,4 @@ -@namespace DnsmasqWebUI.Components.Home +@namespace DnsmasqWebUI.Components.Dnsmasq @* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@ @if (_status != null && (_status.LogsConfigured || _status.LogsPath != null)) { diff --git a/src/DnsmasqWebUI/Components/Home/LogsSection.razor.cs b/src/DnsmasqWebUI/Components/Home/LogsSection.razor.cs index bff9156..8b4b54e 100644 --- a/src/DnsmasqWebUI/Components/Home/LogsSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Home/LogsSection.razor.cs @@ -2,7 +2,7 @@ using DnsmasqWebUI.Client.Http.Abstractions; using DnsmasqWebUI.Models; using Microsoft.AspNetCore.Components; -namespace DnsmasqWebUI.Components.Home; +namespace DnsmasqWebUI.Components.Dnsmasq; /// /// Recent logs block: polls LogsCommand output at its own interval and re-renders only itself. diff --git a/src/DnsmasqWebUI/Components/Home/StatusSection.razor b/src/DnsmasqWebUI/Components/Home/StatusSection.razor index 99132d4..1748e1a 100644 --- a/src/DnsmasqWebUI/Components/Home/StatusSection.razor +++ b/src/DnsmasqWebUI/Components/Home/StatusSection.razor @@ -1,4 +1,4 @@ -@namespace DnsmasqWebUI.Components.Home +@namespace DnsmasqWebUI.Components.Dnsmasq @* Service status — output of StatusShowCommand; only this component re-renders on its timer *@ @if (_status?.StatusShowConfigured == true) { diff --git a/src/DnsmasqWebUI/Components/Home/StatusSection.razor.cs b/src/DnsmasqWebUI/Components/Home/StatusSection.razor.cs index 5db7c3f..c1742f2 100644 --- a/src/DnsmasqWebUI/Components/Home/StatusSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Home/StatusSection.razor.cs @@ -2,7 +2,7 @@ using DnsmasqWebUI.Client.Http.Abstractions; using DnsmasqWebUI.Models; using Microsoft.AspNetCore.Components; -namespace DnsmasqWebUI.Components.Home; +namespace DnsmasqWebUI.Components.Dnsmasq; /// /// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself. diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index f044b76..99c43df 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -16,7 +16,7 @@