WIP: layout, status, hosts, dhcp, error pages and tests

This commit is contained in:
2026-02-01 17:05:37 +10:00
parent d0391ef058
commit e50cfc986c
21 changed files with 813 additions and 205 deletions
+44 -6
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# Simulates "systemctl status dnsmasq" output when run in a container without systemd. # 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) pid=$(pgrep -x dnsmasq)
if [ -z "$pid" ]; then if [ -z "$pid" ]; then
@@ -11,20 +11,58 @@ if [ -z "$pid" ]; then
exit 0 exit 0
fi fi
# One ps + awk: parse pid,user,etime,rss,args and print systemctl-style (rss in KB -> MB) # Process start time for "Active: ... since ..." (optional; fallback if ps has no lstart)
ps -o pid=,user=,etime=,rss=,args= -p "$pid" 2>/dev/null | awk '{ 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 pid=$1; user=$2; etime=$3; rss=$4
args="" args=""
for (i=5;i<=NF;i++) args = args (i>5?" ":"") $i for (i=5;i<=NF;i++) args = args (i>5?" ":"") $i
rss_mb = (rss+0) / 1024 rss_mb = (rss+0) / 1024
if (rss_mb < 0.1) rss_mb = 0.0 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 "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server\n"
printf " Loaded: loaded (container, no systemd)\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 " Main PID: %s (dnsmasq)\n", pid
printf " Tasks: 1 (limit: unknown)\n"
printf " Memory: %.1fM\n", rss_mb printf " Memory: %.1fM\n", rss_mb
printf " CPU: %s\n", etime printf " Uptime: %s\n", etime
printf " CGroup: (container)\n" printf " CGroup: (container)\n"
printf " └─%s %s\n", pid, args printf " └─%s %s\n", pid, args
}' }'
@@ -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] [Fact]
public void ResolvePath_Relative_ResolvesAgainstDir() public void ResolvePath_Relative_ResolvesAgainstDir()
{ {
@@ -0,0 +1,101 @@
using DnsmasqWebUI.Models;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
/// </summary>
public class EffectiveDnsmasqConfigTests
{
private static EffectiveDnsmasqConfig CreateConfig(string? logFacility = null) =>
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
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));
}
}
+2 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -10,6 +10,7 @@
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" /> <link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
<ImportMap /> <ImportMap />
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<title>Local DNS</title>
<HeadOutlet /> <HeadOutlet />
</head> </head>
@@ -5,16 +5,17 @@
<div class="sidebar"> <div class="sidebar">
<NavMenu /> <NavMenu />
</div> </div>
<main class="main">
<main> <div class="content-wrapper">
<article class="content px-4"> <article class="content">
@Body @Body
</article> </article>
</div>
</main> </main>
</div> </div>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred. An unhandled error has occurred.
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss" aria-label="Dismiss">×</span>
</div> </div>
@@ -2,48 +2,76 @@
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh;
} }
main { main.main {
flex: 1; 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 { .sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); background: linear-gradient(180deg, #052767 0%, #3a0647 70%);
} }
.top-row { .top-row {
background-color: #f7f7f7; background-color: rgba(0, 0, 0, 0.35);
border-bottom: 1px solid #d6d5d5; border-bottom: 1px solid rgba(255, 255, 255, 0.08);
justify-content: flex-end; justify-content: flex-end;
height: 3.5rem; min-height: 3.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.top-row ::deep a, .top-row ::deep .btn-link { .top-row ::deep a,
white-space: nowrap; .top-row ::deep .btn-link {
margin-left: 1.5rem; white-space: nowrap;
text-decoration: none; margin-left: 1.5rem;
} text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover { .top-row ::deep a:hover,
text-decoration: underline; .top-row ::deep .btn-link:hover {
} text-decoration: underline;
}
.top-row ::deep a:first-child { .top-row ::deep a:first-child {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@media (max-width: 640.98px) { @media (max-width: 640.98px) {
.top-row { .top-row {
justify-content: space-between; 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; margin-left: 0;
} }
.content-wrapper {
padding-left: 1rem;
padding-right: 1rem;
}
} }
@media (min-width: 641px) { @media (min-width: 641px) {
@@ -52,7 +80,8 @@ main {
} }
.sidebar { .sidebar {
width: 250px; width: 260px;
min-width: 260px;
height: 100vh; height: 100vh;
position: sticky; position: sticky;
top: 0; top: 0;
@@ -61,7 +90,7 @@ main {
.top-row { .top-row {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 1; z-index: 10;
} }
.top-row.auth ::deep a:first-child { .top-row.auth ::deep a:first-child {
@@ -69,30 +98,33 @@ main {
text-align: right; text-align: right;
width: 0; width: 0;
} }
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
} }
#blazor-error-ui { #blazor-error-ui {
color-scheme: light only; color-scheme: light only;
background: lightyellow; background: #fff3cd;
border-top: 1px solid #ffc107;
bottom: 0; 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; box-sizing: border-box;
display: none; display: none;
left: 0; left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem; padding: 0.75rem 1.25rem;
position: fixed; position: fixed;
width: 100%; width: 100%;
z-index: 1000; z-index: 1000;
} }
#blazor-error-ui .dismiss { #blazor-error-ui .dismiss {
cursor: pointer; cursor: pointer;
position: absolute; position: absolute;
right: 0.75rem; right: 1rem;
top: 0.5rem; top: 0.75rem;
} font-size: 1.5rem;
line-height: 1;
opacity: 0.8;
}
#blazor-error-ui .dismiss:hover {
opacity: 1;
}
@@ -1,30 +1,33 @@
@namespace DnsmasqWebUI.Components.Layout @namespace DnsmasqWebUI.Components.Layout
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid"> <div class="container-fluid d-flex align-items-center">
<a class="navbar-brand" href="">DnsmasqWebUI</a> <a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="Local DNS home">
<span class="brand-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<span>Local DNS</span>
</a>
</div> </div>
</div> </div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" /> <input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()"> <div class="nav-scrollable" onclick="document.getElementById('nav-toggler')?.click()">
<nav class="nav flex-column"> <nav class="nav flex-column" aria-label="Main navigation">
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All"> <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Dnsmasq <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Overview
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="hosts"> <NavLink class="nav-link" href="hosts">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Hosts <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Hosts
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="dhcp"> <NavLink class="nav-link" href="dhcp">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> DHCP <span class="bi bi-hdd-network-nav-menu" aria-hidden="true"></span> DHCP
</NavLink> </NavLink>
</div> </div>
</nav> </nav>
@@ -22,6 +22,24 @@
.navbar-brand { .navbar-brand {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600;
text-decoration: none;
color: rgba(255, 255, 255, 0.95) !important;
}
.navbar-brand:hover {
color: white !important;
}
.brand-icon {
display: inline-flex;
flex-shrink: 0;
}
.brand-icon svg {
width: 1.5rem;
height: 1.5rem;
color: rgba(255, 255, 255, 0.95);
} }
.bi { .bi {
@@ -46,30 +64,36 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
} }
.nav-item { .bi-hdd-network-nav-menu {
font-size: 0.9rem; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1zM3 4.5a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm2 7a.5.5 0 1 1-1 0 .5.5 0 0 1 1 0zm-2.5.5a.5.5 0 1 0 0-1 .5.5 0 0 0 0 1z'/%3E%3Cpath d='M2 2a2 2 0 0 0-2 2v1a2 2 0 0 0 2 2h1v3H2a2 2 0 0 1-2 2H1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1h-1a2 2 0 0 1-2 2h-3v-3h1a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H2zm13 2h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1h-1a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM4 5a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5zm7 4a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1-.5-.5V9z'/%3E%3C/svg%3E");
padding-bottom: 0.5rem;
} }
.nav-item:first-of-type { .nav-item {
padding-top: 1rem; font-size: 0.9375rem;
} padding: 0 0.75rem;
margin-bottom: 0.25rem;
}
.nav-item:last-of-type { .nav-item:first-of-type {
padding-bottom: 1rem; padding-top: 0.5rem;
} }
.nav-item ::deep .nav-link { .nav-item:last-of-type {
color: #d7d7d7; padding-bottom: 1rem;
background: none; }
border: none;
border-radius: 4px; .nav-item ::deep .nav-link {
height: 3rem; color: rgba(255, 255, 255, 0.85);
display: flex; background: none;
align-items: center; border: none;
line-height: 3rem; border-radius: 6px;
width: 100%; min-height: 2.75rem;
} display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
width: 100%;
transition: background-color 0.15s ease, color 0.15s ease;
}
.nav-item ::deep a.active { .nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37); background-color: rgba(255,255,255,0.37);
+53 -49
View File
@@ -7,9 +7,9 @@
@inject ILeasesClient LeasesClient @inject ILeasesClient LeasesClient
@inject IReloadClient ReloadClient @inject IReloadClient ReloadClient
<PageTitle>DHCP</PageTitle> <PageTitle>DHCP | Local DNS</PageTitle>
<h1 class="mb-4">DHCP</h1> <h1 class="page-title">DHCP</h1>
@if (_status == null) @if (_status == null)
{ {
@@ -18,8 +18,8 @@
else else
{ {
@* ---- Static hosts (dhcp-host= in managed file) ---- *@ @* ---- Static hosts (dhcp-host= in managed file) ---- *@
<section class="mb-4"> <section class="page-section">
<h2 class="h5 mb-2">Static hosts</h2> <h2 class="page-section-title">Static hosts</h2>
@if (!_status.ManagedFilePathExists) @if (!_status.ManagedFilePathExists)
{ {
<p class="text-warning">Managed config file not found: @_status.ManagedFilePath</p> <p class="text-warning">Managed config file not found: @_status.ManagedFilePath</p>
@@ -38,42 +38,44 @@ else
{ {
<div class="alert alert-success">@_message</div> <div class="alert alert-success">@_message</div>
} }
<table class="table table-bordered"> <div class="table-responsive-cards">
<thead> <table class="table table-bordered">
<tr> <thead>
<th>#</th><th>Valid</th><th>MAC</th><th>Name</th><th>Address</th><th>Lease</th><th>Ignore</th><th>Comment</th><th></th> <tr>
</tr> <th>#</th><th>Valid</th><th>MAC</th><th>Name</th><th>Address</th><th>Lease</th><th>Ignore</th><th>Comment</th><th></th>
</thead>
<tbody>
@foreach (var e in _entries.Where(x => !x.IsDeleted))
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.LineNumber</td>
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" /></td>
<td><input class="form-control form-control-sm" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => SetMac(e, ev.Value?.ToString())" style="min-width:140px" /></td>
<td><input class="form-control form-control-sm" @bind="e.Name" /></td>
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" /></td>
<td><input class="form-control form-control-sm" @bind="e.Lease" style="min-width:70px" /></td>
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /></td>
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
</tr> </tr>
} </thead>
</tbody> <tbody>
</table> @foreach (var e in _entries.Where(x => !x.IsDeleted))
<button class="btn btn-secondary mt-2" @onclick="Add">Add host</button> {
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.LineNumber</td>
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" /></td>
<td><input class="form-control form-control-sm" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => SetMac(e, ev.Value?.ToString())" style="min-width:140px" /></td>
<td><input class="form-control form-control-sm" @bind="e.Name" /></td>
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" /></td>
<td><input class="form-control form-control-sm" @bind="e.Lease" style="min-width:70px" /></td>
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /></td>
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
</tr>
}
</tbody>
</table>
</div>
<button class="btn btn-primary mt-2" @onclick="Add">Add host</button>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button> <button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
@if (_status.ReloadCommandConfigured) @if (_status.ReloadCommandConfigured)
{ {
<button class="btn btn-outline-primary mt-2" @onclick="Reload" disabled="@_saving">Reload dnsmasq</button> <button class="btn btn-primary mt-2" @onclick="Reload" disabled="@_saving">Reload config</button>
} }
<p class="text-muted small mt-2">Managed file: @_status.ManagedFilePath</p> <p class="text-muted small mt-2">Managed file: @_status.ManagedFilePath</p>
} }
</section> </section>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@ @* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="mb-4"> <section class="page-section">
<h2 class="h5 mb-2">Leases</h2> <h2 class="page-section-title">Leases</h2>
@if (!_status.LeasesPathConfigured) @if (!_status.LeasesPathConfigured)
{ {
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p> <p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
@@ -90,29 +92,31 @@ else
{ {
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p> <p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<p class="text-muted small">Path: @_status.LeasesPath</p> <p class="text-muted small">Path: @_status.LeasesPath</p>
<button class="btn btn-outline-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button> <button class="btn btn-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button>
} }
else else
{ {
<table class="table table-bordered"> <div class="table-responsive-cards">
<thead> <table class="table table-bordered">
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr> <thead>
</thead> <tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr>
<tbody> </thead>
@foreach (var e in _leases) <tbody>
{ @foreach (var e in _leases)
<tr> {
<td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td> <tr>
<td>@e.Mac</td> <td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>@e.Address</td> <td>@e.Mac</td>
<td>@e.Name</td> <td>@e.Address</td>
<td>@e.ClientId</td> <td>@e.Name</td>
</tr> <td>@e.ClientId</td>
} </tr>
</tbody> }
</table> </tbody>
</table>
</div>
<p class="text-muted small">Path: @_status.LeasesPath (read-only). Cached and refreshed when the file changes.</p> <p class="text-muted small">Path: @_status.LeasesPath (read-only). Cached and refreshed when the file changes.</p>
<button class="btn btn-outline-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button> <button class="btn btn-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button>
} }
</section> </section>
} }
@@ -0,0 +1,47 @@
.page-title {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
@media (min-width: 641px) {
.page-title {
font-size: 2rem;
}
}
::deep .page-section {
margin-bottom: 2rem;
}
::deep .page-section-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
::deep .table-responsive-cards {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
::deep .table th,
::deep .table td {
vertical-align: middle;
}
@media (max-width: 640.98px) {
::deep .table {
font-size: 0.8125rem;
}
::deep .table th,
::deep .table td {
padding: 0.375rem 0.5rem;
}
::deep .table input.form-control {
min-width: 0;
font-size: 0.875rem;
}
}
@@ -2,9 +2,9 @@
@namespace DnsmasqWebUI.Components.Pages @namespace DnsmasqWebUI.Components.Pages
@using System.Diagnostics @using System.Diagnostics
<PageTitle>Error</PageTitle> <PageTitle>Error | Local DNS</PageTitle>
<h1 class="text-danger">Error.</h1> <h1 class="page-title text-danger">Error</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2> <h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId) @if (ShowRequestId)
@@ -0,0 +1,23 @@
.page-title {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
::deep h2 {
font-size: 1.125rem;
font-weight: 500;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
::deep h3 {
font-size: 1rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
::deep p {
margin-bottom: 0.75rem;
}
+43 -39
View File
@@ -6,15 +6,11 @@
@inject IReloadClient ReloadClient @inject IReloadClient ReloadClient
@implements IDisposable @implements IDisposable
<PageTitle>Dnsmasq</PageTitle> <PageTitle>Overview | Local DNS</PageTitle>
<h1 class="mb-4">Dnsmasq</h1>
<p class="lead text-muted mb-4">Configuration and service status at a glance.</p>
@if (_loading) @if (_loading)
{ {
<p><em>Loading…</em></p> <p class="text-muted"><em>Loading…</em></p>
} }
else if (_error != null) else if (_error != null)
{ {
@@ -22,9 +18,9 @@ else if (_error != null)
} }
else if (_status != null) else if (_status != null)
{ {
<section class="mb-4"> <section class="page-section">
<h2 class="h5 mb-2">Service</h2> <h2 class="page-section-title">Service</h2>
<div class="alert alert-@(_status.DnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-0"> <div class="alert alert-@(_status.DnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-2">
<strong>Dnsmasq:</strong> @GetStatusLabel(_status) <strong>Dnsmasq:</strong> @GetStatusLabel(_status)
@if (_status.StatusCommandExitCode.HasValue) @if (_status.StatusCommandExitCode.HasValue)
{ {
@@ -37,7 +33,7 @@ else if (_status != null)
</div> </div>
@if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null)) @if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null))
{ {
<pre class="small bg-dark text-light p-2 rounded mt-1 mb-0" style="max-height: 12rem; overflow: auto;">@(_status.StatusCommandStdout != null ? _status.StatusCommandStdout : "")@(_status.StatusCommandStdout != null && _status.StatusCommandStderr != null ? "\n" : "")@(_status.StatusCommandStderr ?? "")</pre> <pre class="pre-block mt-2 mb-0" style="max-height: 12rem;">@(_status.StatusCommandStdout != null ? _status.StatusCommandStdout : "")@(_status.StatusCommandStdout != null && _status.StatusCommandStderr != null ? "\n" : "")@(_status.StatusCommandStderr ?? "")</pre>
} }
@if (_status.ReloadCommandConfigured) @if (_status.ReloadCommandConfigured)
{ {
@@ -47,7 +43,7 @@ else if (_status != null)
{ {
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
} }
@(_reloading ? "Reloading…" : "Reload dnsmasq") @(_reloading ? "Reloading…" : "Reload config")
</button> </button>
@if (!string.IsNullOrEmpty(_reloadMessage)) @if (!string.IsNullOrEmpty(_reloadMessage))
{ {
@@ -55,49 +51,65 @@ else if (_status != null)
} }
@if (!_reloadSuccess && _reloadDetail != null) @if (!_reloadSuccess && _reloadDetail != null)
{ {
<pre class="small bg-dark text-light p-2 rounded mt-1 mb-0" style="max-height: 10rem; overflow: auto;">@_reloadDetail</pre> <pre class="pre-block mt-2 mb-0" style="max-height: 10rem;">@_reloadDetail</pre>
} }
</div> </div>
} }
@* Full service status — output of StatusShowCommand; format depends on command (systemctl on host, ps/pgrep in containers) *@ @* Full service status — output of StatusShowCommand *@
@if (_status.StatusShowConfigured) @if (_status.StatusShowConfigured)
{ {
<p class="mb-1 mt-3"><strong>Service status</strong> <span class="text-muted small">(output of StatusShowCommand)</span></p> <p class="mb-1 mt-3"><strong>Service status</strong></p>
<p class="text-muted small mb-1"><code>@_status.StatusShowCommand</code></p>
@if (string.IsNullOrWhiteSpace(_status.StatusShowOutput)) @if (string.IsNullOrWhiteSpace(_status.StatusShowOutput))
{ {
<p class="text-muted small">No output. On a systemd host use <code>systemctl status dnsmasq --no-pager</code> for full status; in Docker or without systemd use <code>ps -C dnsmasq -o pid,user,args</code> or <code>pgrep -a dnsmasq</code> for minimal process info.</p> <p class="text-muted small">No output. On a systemd host use <code>systemctl status dnsmasq --no-pager</code> for full status; in Docker or without systemd use <code>ps -C dnsmasq -o pid,user,args</code> or <code>pgrep -a dnsmasq</code> for minimal process info.</p>
} }
else else
{ {
<pre class="small bg-dark text-light p-2 rounded mb-0" style="max-height: 22rem; overflow: auto;">@_status.StatusShowOutput</pre> <pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
} }
} }
@* Recent logs — live view, auto-refreshes *@ @* Recent logs — live view from LogsCommand (show command so it's clear what produces the output) *@
@if (_status.LogsConfigured) @if (_status.LogsConfigured || _status.LogsPath != null)
{ {
<p class="mb-1 mt-3"><strong>Recent logs</strong> <span class="text-muted small">(live, refreshes every @(_refreshIntervalSeconds)s)</span></p> <p class="mb-1 mt-3"><strong>Recent logs</strong></p>
@if (string.IsNullOrWhiteSpace(_status.LogsOutput)) @if (_status.LogsConfigured)
{ {
<p class="text-muted small">No output.</p> <p class="text-muted small mb-1"><code>@_status.LogsCommand</code> <span class="text-muted">(refreshes every @(_refreshIntervalSeconds)s)</span></p>
} }
else @if (_status.LogsConfigured)
{ {
<pre class="small bg-dark text-light p-2 rounded mb-0" style="max-height: 24rem; overflow: auto;">@_status.LogsOutput</pre> @if (string.IsNullOrWhiteSpace(_status.LogsOutput))
{
<p class="text-muted small">No output.</p>
}
else
{
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
}
} }
} }
@if (_status.StatusShowConfigured || _status.LogsConfigured) @if (_status.StatusShowConfigured || _status.LogsConfigured)
{ {
<div class="mt-2"> <div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshStatus" disabled="@_refreshing">Refresh status &amp; logs</button> <button class="btn btn-primary btn-sm" @onclick="RefreshStatus" disabled="@_refreshing">Refresh status &amp; logs</button>
@if (_status.LogsPath != null)
{
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
}
else
{
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
}
</div> </div>
} }
</section> </section>
<section class="mb-4"> <section class="page-section">
<h2 class="h5 mb-2">Effective config</h2> <h2 class="page-section-title">Effective config</h2>
<p class="text-muted small mb-2">What dnsmasq uses after reading all config files (main + conf-file + conf-dir). Only single-value / overridable options are shown here.</p> <p class="text-muted small mb-2">What dnsmasq uses after reading all config files (main + conf-file + conf-dir). Only single-value / overridable options are shown here.</p>
<ul class="list-unstyled small"> <ul class="list-unstyled small">
<li><strong>no-hosts:</strong> @(_status.NoHosts ? "Yes (hosts files disabled)" : "No")</li> <li><strong>no-hosts:</strong> @(_status.NoHosts ? "Yes (hosts files disabled)" : "No")</li>
@@ -131,7 +143,7 @@ else if (_status != null)
<li><strong>dhcp-authoritative:</strong> @(ec.DhcpAuthoritative ? "Yes" : "No")</li> <li><strong>dhcp-authoritative:</strong> @(ec.DhcpAuthoritative ? "Yes" : "No")</li>
<li><strong>dhcp-leasefile:</strong> @(ec.DhcpLeaseFilePath ?? "(default)")</li> <li><strong>dhcp-leasefile:</strong> @(ec.DhcpLeaseFilePath ?? "(default)")</li>
@if (ec.CacheSize.HasValue) { <li><strong>cache-size:</strong> @ec.CacheSize.Value</li> } @if (ec.CacheSize.HasValue) { <li><strong>cache-size:</strong> @ec.CacheSize.Value</li> }
@if (ec.Port.HasValue) { <li><strong>port:</strong> @ec.Port.Value</li> } <li><strong>port:</strong> @(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })</li>
@if (ec.LocalTtl.HasValue) { <li><strong>local-ttl:</strong> @ec.LocalTtl.Value</li> } @if (ec.LocalTtl.HasValue) { <li><strong>local-ttl:</strong> @ec.LocalTtl.Value</li> }
@if (ec.PidFilePath != null) { <li><strong>pid-file:</strong> @ec.PidFilePath</li> } @if (ec.PidFilePath != null) { <li><strong>pid-file:</strong> @ec.PidFilePath</li> }
@if (ec.User != null) { <li><strong>user:</strong> @ec.User</li> } @if (ec.User != null) { <li><strong>user:</strong> @ec.User</li> }
@@ -141,8 +153,8 @@ else if (_status != null)
} }
</section> </section>
<section class="mb-4"> <section class="page-section">
<h2 class="h5 mb-2">Config</h2> <h2 class="page-section-title">Config</h2>
<ul class="list-unstyled small"> <ul class="list-unstyled small">
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li> <li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li> <li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
@@ -170,8 +182,8 @@ else if (_status != null)
} }
</section> </section>
<section class="mb-4"> <section class="page-section">
<h2 class="h5 mb-2">Paths</h2> <h2 class="page-section-title">Paths</h2>
<ul class="list-unstyled small"> <ul class="list-unstyled small">
<li> <li>
<strong>Leases file:</strong> @(_status.LeasesPath ?? "—") <strong>Leases file:</strong> @(_status.LeasesPath ?? "—")
@@ -186,14 +198,6 @@ else if (_status != null)
</li> </li>
</ul> </ul>
</section> </section>
<section class="mb-4">
<h2 class="h5 mb-2">Quick links</h2>
<div class="d-flex flex-wrap gap-2">
<a href="hosts" class="btn btn-outline-primary btn-sm">Hosts</a>
<a href="dhcp" class="btn btn-outline-primary btn-sm">DHCP</a>
</div>
</section>
} }
@code { @code {
@@ -0,0 +1,48 @@
.page-title {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.page-lead {
margin-bottom: 1.5rem;
}
@media (min-width: 641px) {
.page-title {
font-size: 2rem;
}
.page-lead {
margin-bottom: 2rem;
}
}
::deep .page-section {
margin-bottom: 2rem;
}
::deep .page-section:last-child {
margin-bottom: 0;
}
::deep .page-section-title,
::deep h2.page-section-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
::deep pre.small,
::deep .pre-block {
font-size: 0.8125rem;
max-height: 24rem;
overflow-x: hidden;
overflow-y: auto;
padding: 1rem;
border-radius: 0.375rem;
background: #212529;
color: #f8f9fa;
white-space: pre-wrap;
overflow-wrap: break-word;
}
+19 -17
View File
@@ -4,9 +4,9 @@
@inject IStatusClient StatusClient @inject IStatusClient StatusClient
@inject IHostsClient HostsClient @inject IHostsClient HostsClient
<PageTitle>Hosts</PageTitle> <PageTitle>Hosts | Local DNS</PageTitle>
<h1>Hosts file</h1> <h1 class="page-title">Hosts file</h1>
@if (_status == null) @if (_status == null)
{ {
@@ -46,21 +46,23 @@ else
{ {
<div class="alert alert-success">@_message</div> <div class="alert alert-success">@_message</div>
} }
<table class="table table-bordered"> <div class="table-responsive-cards">
<thead> <table class="table table-bordered">
<tr><th>#</th><th>Address</th><th>Names</th></tr> <thead>
</thead> <tr><th>#</th><th>Address</th><th>Names</th></tr>
<tbody> </thead>
@foreach (var e in _entries.Where(x => !x.IsPassthrough)) <tbody>
{ @foreach (var e in _entries.Where(x => !x.IsPassthrough))
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id"> {
<td>@e.LineNumber</td> <tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.Address</td> <td>@e.LineNumber</td>
<td>@string.Join(", ", e.Names)</td> <td>@e.Address</td>
</tr> <td>@string.Join(", ", e.Names)</td>
} </tr>
</tbody> }
</table> </tbody>
</table>
</div>
<p class="text-muted small">Path: @_status.SystemHostsPath</p> <p class="text-muted small">Path: @_status.SystemHostsPath</p>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button> <button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
} }
@@ -0,0 +1,40 @@
.page-title {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
@media (min-width: 641px) {
.page-title {
font-size: 2rem;
}
}
::deep .page-section {
margin-bottom: 2rem;
}
::deep .table-responsive-cards {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
::deep .table {
margin-bottom: 1rem;
}
::deep .table th,
::deep .table td {
vertical-align: middle;
}
@media (max-width: 640.98px) {
::deep .table {
font-size: 0.875rem;
}
::deep .table th,
::deep .table td {
padding: 0.5rem;
}
}
@@ -75,6 +75,9 @@ public class StatusController : ControllerBase
StatusCommandConfigured: !string.IsNullOrWhiteSpace(_options.StatusCommand), StatusCommandConfigured: !string.IsNullOrWhiteSpace(_options.StatusCommand),
StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand), StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand),
LogsConfigured: !string.IsNullOrWhiteSpace(_options.LogsCommand), LogsConfigured: !string.IsNullOrWhiteSpace(_options.LogsCommand),
LogsPath: EffectiveDnsmasqConfig.GetLogsPath(effectiveConfig),
StatusShowCommand: string.IsNullOrWhiteSpace(_options.StatusShowCommand) ? null : _options.StatusShowCommand!.Trim(),
LogsCommand: string.IsNullOrWhiteSpace(_options.LogsCommand) ? null : _options.LogsCommand!.Trim(),
DnsmasqStatus: dnsmasqStatus, DnsmasqStatus: dnsmasqStatus,
StatusCommandExitCode: dnsmasqStatus != "active" && statusResult.ExitCode.HasValue ? statusResult.ExitCode.Value : null, StatusCommandExitCode: dnsmasqStatus != "active" && statusResult.ExitCode.HasValue ? statusResult.ExitCode.Value : null,
StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null, StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null,
@@ -89,4 +92,33 @@ public class StatusController : ControllerBase
return StatusCode(500, new { error = ex.Message }); return StatusCode(500, new { error = ex.Message });
} }
} }
/// <summary>Returns the full log file from the path in effective config (log-facility). Untruncated.</summary>
[HttpGet("logs/download")]
public async Task<IActionResult> GetLogsDownload(CancellationToken ct)
{
var effectiveConfig = _configSetService.GetEffectiveConfig();
var logsPath = EffectiveDnsmasqConfig.GetLogsPath(effectiveConfig);
if (string.IsNullOrEmpty(logsPath))
return NotFound();
try
{
var fullPath = Path.IsPathRooted(logsPath) ? Path.GetFullPath(logsPath) : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), logsPath));
if (!System.IO.File.Exists(fullPath))
return NotFound();
var content = await System.IO.File.ReadAllTextAsync(fullPath, System.Text.Encoding.UTF8, ct);
var fileName = Path.GetFileName(fullPath);
if (string.IsNullOrEmpty(fileName)) fileName = "dnsmasq.log";
return File(
System.Text.Encoding.UTF8.GetBytes(content),
"text/plain",
fileName);
}
catch (Exception ex)
{
return StatusCode(500, new { error = ex.Message });
}
}
} }
@@ -1,6 +1,34 @@
namespace DnsmasqWebUI.Models; namespace DnsmasqWebUI.Models;
/// <summary>Dnsmasq service and config paths status returned by api/status.</summary> /// <summary>
/// Dnsmasq service and config status returned by GET api/status.
/// Includes paths from config, command configuration flags, and live output from status/logs commands.
/// </summary>
/// <param name="SystemHostsPath">Path to the hosts file the app can edit (e.g. /etc/hosts). Null when hosts UI is disabled.</param>
/// <param name="SystemHostsPathExists">True if <paramref name="SystemHostsPath"/> is set and the file exists on disk.</param>
/// <param name="NoHosts">True when dnsmasq has no-hosts set (hosts files disabled).</param>
/// <param name="AddnHostsPaths">Effective addn-hosts paths dnsmasq loads. Empty when none configured.</param>
/// <param name="EffectiveConfig">Effective dnsmasq config (single-value and flag options) after parsing all config files.</param>
/// <param name="MainConfigPath">Path to the main dnsmasq config file (e.g. /etc/dnsmasq.conf).</param>
/// <param name="ManagedFilePath">Path to the app's managed config file (e.g. dnsmasq.d/zz-dnsmasq-webui.conf).</param>
/// <param name="LeasesPath">Path to the DHCP leases file from effective config (dhcp-leasefile). Null when not configured.</param>
/// <param name="MainConfigPathExists">True if <paramref name="MainConfigPath"/> exists on disk.</param>
/// <param name="ManagedFilePathExists">True if <paramref name="ManagedFilePath"/> exists on disk.</param>
/// <param name="LeasesPathConfigured">True when dhcp-leasefile is set in effective config.</param>
/// <param name="LeasesPathExists">True if <paramref name="LeasesPath"/> is set and the file exists on disk.</param>
/// <param name="ReloadCommandConfigured">True when ReloadCommand is set in app config (reload after save).</param>
/// <param name="StatusCommandConfigured">True when StatusCommand is set (used to determine dnsmasq active/inactive).</param>
/// <param name="StatusShowConfigured">True when StatusShowCommand is set (full service status output on Overview).</param>
/// <param name="LogsConfigured">True when LogsCommand is set (recent logs preview on Overview).</param>
/// <param name="LogsPath">Log file path from effective config (log-facility when it is a file path). Used for full download. Null when log-facility is not a file path or not set.</param>
/// <param name="StatusShowCommand">The StatusShowCommand string from config (e.g. systemctl status dnsmasq --no-pager). Null when not configured.</param>
/// <param name="LogsCommand">The LogsCommand string from config (e.g. tail -n 100 /var/log/dnsmasq.log). Null when not configured.</param>
/// <param name="DnsmasqStatus">Service state: "active", "inactive", or "unknown".</param>
/// <param name="StatusCommandExitCode">Exit code of StatusCommand when dnsmasq is not active. Null when active or not configured.</param>
/// <param name="StatusCommandStdout">Stdout of StatusCommand when dnsmasq is not active. Null when active or not configured.</param>
/// <param name="StatusCommandStderr">Stderr of StatusCommand when dnsmasq is not active. Null when active or not configured.</param>
/// <param name="StatusShowOutput">Output of StatusShowCommand (full service status). Null when not configured or command produced no output.</param>
/// <param name="LogsOutput">Output of LogsCommand (recent logs preview). Null when not configured or command produced no output.</param>
public record DnsmasqServiceStatus( public record DnsmasqServiceStatus(
string? SystemHostsPath, string? SystemHostsPath,
bool SystemHostsPathExists, bool SystemHostsPathExists,
@@ -18,6 +46,9 @@ public record DnsmasqServiceStatus(
bool StatusCommandConfigured, bool StatusCommandConfigured,
bool StatusShowConfigured, bool StatusShowConfigured,
bool LogsConfigured, bool LogsConfigured,
string? LogsPath,
string? StatusShowCommand,
string? LogsCommand,
string DnsmasqStatus, string DnsmasqStatus,
int? StatusCommandExitCode, int? StatusCommandExitCode,
string? StatusCommandStdout, string? StatusCommandStdout,
@@ -40,4 +40,19 @@ public record EffectiveDnsmasqConfig(
int? MaxCacheTtl, int? MaxCacheTtl,
int? MinCacheTtl, int? MinCacheTtl,
int? DhcpTtl int? DhcpTtl
); )
{
/// <summary>
/// Returns the log file path when <see cref="LogFacility"/> is a file path (e.g. starts with / or contains /).
/// Returns null when LogFacility is not set, is a syslog facility name (e.g. local0), or is not a file path.
/// Used for full log download in the UI.
/// </summary>
public static string? GetLogsPath(EffectiveDnsmasqConfig? config)
{
var logFacility = config?.LogFacility?.Trim();
if (string.IsNullOrEmpty(logFacility)) return null;
if (logFacility.StartsWith('/') || logFacility.Contains('/'))
return logFacility;
return null;
}
}
+125 -17
View File
@@ -1,23 +1,93 @@
html, body { /* Global styles – mobile-first, consistent spacing */
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; :root {
--app-font-sans: 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif;
--app-font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
--app-spacing-xs: 0.25rem;
--app-spacing-sm: 0.5rem;
--app-spacing-md: 1rem;
--app-spacing-lg: 1.5rem;
--app-spacing-xl: 2rem;
--app-spacing-2xl: 2.5rem;
--app-content-max-width: 52rem;
--app-radius: 0.375rem;
--app-radius-lg: 0.5rem;
--app-link-color: #0d6efd;
--app-link-hover: #0a58ca;
--app-focus-ring: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--app-font-sans);
line-height: 1.5;
color: #212529;
margin: 0;
min-height: 100vh;
} }
a, .btn-link { a, .btn-link {
color: #006bb7; color: var(--app-link-color);
text-decoration: none;
}
a:hover, .btn-link:hover {
color: var(--app-link-hover);
text-decoration: underline;
} }
.btn-primary { .btn-primary {
color: #fff; color: #fff;
background-color: #1b6ec2; background-color: #0d6efd;
border-color: #1861ac; border-color: #0d6efd;
} }
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { .btn-primary:hover {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; background-color: #0b5ed7;
border-color: #0a58ca;
}
/* Consistent secondary/outline buttons – same visual weight as primary */
.btn-outline-primary {
color: #0d6efd;
border-color: #0d6efd;
}
.btn-outline-primary:hover:not(:disabled) {
color: #fff;
background-color: #0d6efd;
border-color: #0d6efd;
}
.btn-outline-secondary {
color: #6c757d;
border-color: #dee2e6;
}
.btn-outline-secondary:hover:not(:disabled) {
color: #6c757d;
background-color: #e9ecef;
border-color: #dee2e6;
}
.btn:focus-visible, .btn:active:focus, .btn-link.nav-link:focus,
.form-control:focus, .form-check-input:focus {
box-shadow: var(--app-focus-ring);
outline: 0;
} }
.content { .content {
padding-top: 1.1rem; padding-top: var(--app-spacing-lg);
padding-bottom: var(--app-spacing-2xl);
}
@media (min-width: 641px) {
.content {
padding-top: var(--app-spacing-xl);
}
} }
h1:focus { h1:focus {
@@ -25,15 +95,16 @@ h1:focus {
} }
.valid.modified:not([type=checkbox]) { .valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050; outline: 2px solid #198754;
} }
.invalid { .invalid {
outline: 1px solid #e50000; outline: 2px solid #dc3545;
} }
.validation-message { .validation-message {
color: #e50000; color: #dc3545;
font-size: 0.875rem;
} }
.blazor-error-boundary { .blazor-error-boundary {
@@ -42,19 +113,56 @@ h1:focus {
color: white; color: white;
} }
.blazor-error-boundary::after { .blazor-error-boundary::after {
content: "An error has occurred." content: "An error has occurred.";
} }
.darker-border-checkbox.form-check-input { .darker-border-checkbox.form-check-input {
border-color: #929292; border-color: #929292;
} }
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { .form-floating > .form-control-plaintext::placeholder,
.form-floating > .form-control::placeholder {
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
text-align: end; text-align: end;
} }
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { .form-floating > .form-control-plaintext:focus::placeholder,
.form-floating > .form-control:focus::placeholder {
text-align: start; text-align: start;
} }
/* Page section – shared by pages for consistent layout */
.page-section {
margin-bottom: var(--app-spacing-xl);
}
.page-section:last-child {
margin-bottom: 0;
}
.page-section-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: var(--app-spacing-sm);
}
/* Wrap text instead of horizontal scroll (WCAG-friendly, better on mobile). pre-wrap keeps newlines so line-by-line is clear. */
.pre-block {
font-family: var(--app-font-mono);
font-size: 0.8125rem;
max-height: 24rem;
overflow-x: hidden;
overflow-y: auto;
padding: var(--app-spacing-md);
border-radius: var(--app-radius);
background: #212529;
color: #f8f9fa;
white-space: pre-wrap;
overflow-wrap: break-word;
}
.table-responsive-cards {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
+18 -3
View File
@@ -1,12 +1,27 @@
# Config for the dnsmasq test container only (docker-compose.test.yml). # Config for the dnsmasq test container only (docker-compose.test.yml).
# DHCP-only; uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases. # DNS + DHCP: forwards DNS to Cloudflare/Google with caching; hands out DHCP on testnet.
port=0 # Uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases.
port=53
interface=eth0 interface=eth0
bind-interfaces bind-interfaces
# Upstream DNS (forward queries not in cache/hosts to these)
server=1.1.1.1
server=8.8.8.8
no-resolv
# Caching
cache-size=1000
# Typical options
domain-needed
bogus-priv
# DHCP
dhcp-range=172.28.0.10,172.28.0.50,255.255.255.0,12h dhcp-range=172.28.0.10,172.28.0.50,255.255.255.0,12h
dhcp-leasefile=/data/leases dhcp-leasefile=/data/leases
# Additional config and hosts
conf-dir=/data/dnsmasq.d conf-dir=/data/dnsmasq.d
addn-hosts=/data/hosts addn-hosts=/data/hosts
# Log to file so the harness can show real logs (LogsCommand tails this) # Log to file so the harness can show real logs (LogsCommand tails this)
log-facility=/data/dnsmasq.log log-facility=/data/dnsmasq.log
log-queries log-queries