From e50cfc986cd94dbe66391a66e7404ea211873d32 Mon Sep 17 00:00:00 2001 From: Alex Hope-O'Connor Date: Sun, 1 Feb 2026 17:05:37 +1000 Subject: [PATCH] WIP: layout, status, hosts, dhcp, error pages and tests --- scripts/dnsmasq-status.sh | 50 +++++- .../DnsmasqConfIncludeParserTests.cs | 39 +++++ .../EffectiveDnsmasqConfigTests.cs | 101 +++++++++++++ src/DnsmasqWebUI/Components/App.razor | 3 +- .../Components/Layout/MainLayout.razor | 13 +- .../Components/Layout/MainLayout.razor.css | 100 +++++++----- .../Components/Layout/NavMenu.razor | 21 +-- .../Components/Layout/NavMenu.razor.css | 64 +++++--- src/DnsmasqWebUI/Components/Pages/Dhcp.razor | 102 +++++++------ .../Components/Pages/Dhcp.razor.css | 47 ++++++ src/DnsmasqWebUI/Components/Pages/Error.razor | 4 +- .../Components/Pages/Error.razor.css | 23 +++ src/DnsmasqWebUI/Components/Pages/Home.razor | 82 +++++----- .../Components/Pages/Home.razor.css | 48 ++++++ src/DnsmasqWebUI/Components/Pages/Hosts.razor | 36 ++--- .../Components/Pages/Hosts.razor.css | 40 +++++ .../Controllers/StatusController.cs | 32 ++++ .../Models/DnsmasqServiceStatus.cs | 33 +++- .../Models/EffectiveDnsmasqConfig.cs | 17 ++- src/DnsmasqWebUI/wwwroot/app.css | 142 +++++++++++++++--- testdata/dnsmasq-test.conf | 21 ++- 21 files changed, 813 insertions(+), 205 deletions(-) create mode 100644 src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs create mode 100644 src/DnsmasqWebUI/Components/Pages/Dhcp.razor.css create mode 100644 src/DnsmasqWebUI/Components/Pages/Error.razor.css create mode 100644 src/DnsmasqWebUI/Components/Pages/Home.razor.css create mode 100644 src/DnsmasqWebUI/Components/Pages/Hosts.razor.css diff --git a/scripts/dnsmasq-status.sh b/scripts/dnsmasq-status.sh index 9a6655f..3a0fb11 100755 --- a/scripts/dnsmasq-status.sh +++ b/scripts/dnsmasq-status.sh @@ -1,6 +1,6 @@ #!/bin/sh # Simulates "systemctl status dnsmasq" output when run in a container without systemd. -# Uses ps/pgrep so the UI looks familiar. Usage: dnsmasq-status.sh +# Uses ps/pgrep. Memory is RSS; the time shown is process uptime (elapsed), not CPU usage. pid=$(pgrep -x dnsmasq) if [ -z "$pid" ]; then @@ -11,20 +11,58 @@ if [ -z "$pid" ]; then exit 0 fi -# One ps + awk: parse pid,user,etime,rss,args and print systemctl-style (rss in KB -> MB) -ps -o pid=,user=,etime=,rss=,args= -p "$pid" 2>/dev/null | awk '{ +# Process start time for "Active: ... since ..." (optional; fallback if ps has no lstart) +lstart=$(ps -o lstart= -p "$pid" 2>/dev/null) || lstart="" + +# pid, user, etime, rss, args -> systemctl-style output. rss in KB -> MB. +# etime is [[dd-]hh:]mm:ss (e.g. 12:45, 1:12:45, 6-11:22:33) +ps -o pid=,user=,etime=,rss=,args= -p "$pid" 2>/dev/null | awk -v lstart="$lstart" 'function etime_ago(et, n, t, x, days, hours, mins) { + days = 0; hours = 0; mins = 0 + n = split(et, t, "-") + if (n >= 2) { + days = t[1]+0 + split(t[2], x, ":") + if (length(x) >= 3) { hours = x[1]+0; mins = x[2]+0 } + else if (length(x) == 2) { hours = 0; mins = x[1]+0 } + } else { + split(et, x, ":") + if (length(x) == 3) { hours = x[1]+0; mins = x[2]+0 } + else if (length(x) == 2) { mins = x[1]+0 } + } + if (days > 0) return days " day" (days == 1 ? "" : "s") " ago" + if (hours > 0) return hours " hour" (hours == 1 ? "" : "s") " ago" + if (mins > 0) return mins " min ago" + return "just now" +} +function fmt_since(s, n, a, mon_num, day_pad) { + n = split(s, a, " ") + if (n < 5) return s + m = (index("JanFebMarAprMayJunJulAugSepOctNovDec", a[2]) + 2) / 3 + mon_num = m; if (m < 10) mon_num = "0" m + day_pad = a[3]; if (length(a[3]) == 1) day_pad = "0" a[3] + return sprintf("%s %s-%s-%s %s", a[1], a[5], mon_num, day_pad, a[4]) +} +{ pid=$1; user=$2; etime=$3; rss=$4 args="" for (i=5;i<=NF;i++) args = args (i>5?" ":"") $i rss_mb = (rss+0) / 1024 if (rss_mb < 0.1) rss_mb = 0.0 + + if (lstart != "") { + since = fmt_since(lstart) + ago = etime_ago(etime) + active_line = " Active: active (running) since " since "; " ago + } else { + active_line = " Active: active (running) since container start" + } + printf "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server\n" printf " Loaded: loaded (container, no systemd)\n" - printf " Active: active (running) since container start\n" + printf "%s\n", active_line printf " Main PID: %s (dnsmasq)\n", pid - printf " Tasks: 1 (limit: unknown)\n" printf " Memory: %.1fM\n", rss_mb - printf " CPU: %s\n", etime + printf " Uptime: %s\n", etime printf " CGroup: (container)\n" printf " └─%s %s\n", pid, args }' diff --git a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs index fbe9e27..ae5ef8a 100644 --- a/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs +++ b/src/DnsmasqWebUI.Tests/DnsmasqConfIncludeParserTests.cs @@ -311,6 +311,45 @@ public class DnsmasqConfIncludeParserTests } } + [Fact] + public void GetLastValueFromConfigFiles_LogFacility_ReturnsValue() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + try + { + var conf = Path.Combine(dir, "dnsmasq.conf"); + File.WriteAllText(conf, "log-facility=/data/dnsmasq.log\n"); + var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, "log-facility"); + Assert.Equal("/data/dnsmasq.log", value); + Assert.Equal(dir, configDir); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void GetLastValueFromConfigFiles_LogFacility_LastWins() + { + var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-logfac2-" + 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, "log-facility=/var/log/dnsmasq.log\n"); + File.WriteAllText(f2, "log-facility=/data/dnsmasq.log\n"); + var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, "log-facility"); + Assert.Equal("/data/dnsmasq.log", value); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + [Fact] public void ResolvePath_Relative_ResolvesAgainstDir() { diff --git a/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs new file mode 100644 index 0000000..a93a2eb --- /dev/null +++ b/src/DnsmasqWebUI.Tests/EffectiveDnsmasqConfigTests.cs @@ -0,0 +1,101 @@ +using DnsmasqWebUI.Models; + +namespace DnsmasqWebUI.Tests; + +/// +/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path). +/// +public class EffectiveDnsmasqConfigTests +{ + private static EffectiveDnsmasqConfig CreateConfig(string? logFacility = null) => + new( + NoHosts: false, + AddnHostsPaths: Array.Empty(), + ExpandHosts: false, + BogusPriv: false, + StrictOrder: false, + NoResolv: false, + DomainNeeded: false, + NoPoll: false, + BindInterfaces: false, + NoNegcache: false, + DhcpAuthoritative: false, + LeasefileRo: false, + DhcpLeaseFilePath: null, + CacheSize: null, + Port: null, + LocalTtl: null, + PidFilePath: null, + User: null, + Group: null, + LogFacility: logFacility, + DhcpLeaseMax: null, + NegTtl: null, + MaxTtl: null, + MaxCacheTtl: null, + MinCacheTtl: null, + DhcpTtl: null + ); + + [Fact] + public void GetLogsPath_NullConfig_ReturnsNull() + { + Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(null)); + } + + [Fact] + public void GetLogsPath_NullLogFacility_ReturnsNull() + { + var config = CreateConfig(logFacility: null); + Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); + } + + [Fact] + public void GetLogsPath_EmptyLogFacility_ReturnsNull() + { + var config = CreateConfig(logFacility: ""); + Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); + } + + [Fact] + public void GetLogsPath_WhitespaceLogFacility_ReturnsNull() + { + var config = CreateConfig(logFacility: " "); + Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); + } + + [Fact] + public void GetLogsPath_AbsolutePath_ReturnsPath() + { + var config = CreateConfig(logFacility: "/data/dnsmasq.log"); + Assert.Equal("/data/dnsmasq.log", 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)); + } + + [Fact] + public void GetLogsPath_RelativePathWithSlash_ReturnsPath() + { + var config = CreateConfig(logFacility: "logs/dnsmasq.log"); + Assert.Equal("logs/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config)); + } + + [Fact] + public void GetLogsPath_SyslogFacilityName_ReturnsNull() + { + var config = CreateConfig(logFacility: "local0"); + Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); + } + + [Fact] + public void GetLogsPath_Stderr_ReturnsNull() + { + var config = CreateConfig(logFacility: "-"); + Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config)); + } +} diff --git a/src/DnsmasqWebUI/Components/App.razor b/src/DnsmasqWebUI/Components/App.razor index 37b66fd..a63250c 100644 --- a/src/DnsmasqWebUI/Components/App.razor +++ b/src/DnsmasqWebUI/Components/App.razor @@ -1,4 +1,4 @@ - + @@ -10,6 +10,7 @@ + Local DNS diff --git a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor index 2c15d11..a6c5f8f 100644 --- a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor +++ b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor @@ -5,16 +5,17 @@ - -
-
- @Body -
+
+
+
+ @Body +
+
An unhandled error has occurred. Reload - 🗙 + ×
diff --git a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor.css b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor.css index 38d1f25..901704d 100644 --- a/src/DnsmasqWebUI/Components/Layout/MainLayout.razor.css +++ b/src/DnsmasqWebUI/Components/Layout/MainLayout.razor.css @@ -2,48 +2,76 @@ position: relative; display: flex; flex-direction: column; + min-height: 100vh; } -main { +main.main { flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.content-wrapper { + flex: 1; + width: 100%; + max-width: var(--app-content-max-width, 52rem); + margin: 0 auto; + padding-left: var(--app-spacing-md, 1rem); + padding-right: var(--app-spacing-md, 1rem); +} + +@media (min-width: 641px) { + .content-wrapper { + padding-left: var(--app-spacing-xl, 2rem); + padding-right: var(--app-spacing-xl, 2rem); + } } .sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); + background: linear-gradient(180deg, #052767 0%, #3a0647 70%); } .top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; + background-color: rgba(0, 0, 0, 0.35); + border-bottom: 1px solid rgba(255, 255, 255, 0.08); justify-content: flex-end; - height: 3.5rem; + min-height: 3.5rem; display: flex; align-items: center; } - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } +.top-row ::deep a, +.top-row ::deep .btn-link { + white-space: nowrap; + margin-left: 1.5rem; + text-decoration: none; +} - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } +.top-row ::deep a:hover, +.top-row ::deep .btn-link:hover { + text-decoration: underline; +} - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } +.top-row ::deep a:first-child { + overflow: hidden; + text-overflow: ellipsis; +} @media (max-width: 640.98px) { .top-row { justify-content: space-between; } - .top-row ::deep a, .top-row ::deep .btn-link { + .top-row ::deep a, + .top-row ::deep .btn-link { margin-left: 0; } + + .content-wrapper { + padding-left: 1rem; + padding-right: 1rem; + } } @media (min-width: 641px) { @@ -52,7 +80,8 @@ main { } .sidebar { - width: 250px; + width: 260px; + min-width: 260px; height: 100vh; position: sticky; top: 0; @@ -61,7 +90,7 @@ main { .top-row { position: sticky; top: 0; - z-index: 1; + z-index: 10; } .top-row.auth ::deep a:first-child { @@ -69,30 +98,33 @@ main { text-align: right; width: 0; } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } } #blazor-error-ui { color-scheme: light only; - background: lightyellow; + background: #fff3cd; + border-top: 1px solid #ffc107; bottom: 0; - box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); box-sizing: border-box; display: none; left: 0; - padding: 0.6rem 1.25rem 0.7rem 1.25rem; + padding: 0.75rem 1.25rem; position: fixed; width: 100%; z-index: 1000; } - #blazor-error-ui .dismiss { - cursor: pointer; - position: absolute; - right: 0.75rem; - top: 0.5rem; - } +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 1rem; + top: 0.75rem; + font-size: 1.5rem; + line-height: 1; + opacity: 0.8; +} + +#blazor-error-ui .dismiss:hover { + opacity: 1; +} diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index 4cc5318..35bcc74 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -1,30 +1,33 @@ @namespace DnsmasqWebUI.Components.Layout