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
# 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
}'
@@ -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()
{
@@ -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">
<head>
@@ -10,6 +10,7 @@
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<title>Local DNS</title>
<HeadOutlet />
</head>
@@ -5,16 +5,17 @@
<div class="sidebar">
<NavMenu />
</div>
<main>
<article class="content px-4">
@Body
</article>
<main class="main">
<div class="content-wrapper">
<article class="content">
@Body
</article>
</div>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
<span class="dismiss" aria-label="Dismiss">×</span>
</div>
@@ -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;
}
@@ -1,30 +1,33 @@
@namespace DnsmasqWebUI.Components.Layout
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">DnsmasqWebUI</a>
<div class="container-fluid d-flex align-items-center">
<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>
<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()">
<nav class="nav flex-column">
<div class="nav-scrollable" onclick="document.getElementById('nav-toggler')?.click()">
<nav class="nav flex-column" aria-label="Main navigation">
<div class="nav-item px-3">
<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>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="hosts">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Hosts
</NavLink>
</div>
<div class="nav-item px-3">
<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>
</div>
</nav>
@@ -22,6 +22,24 @@
.navbar-brand {
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 {
@@ -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");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
.bi-hdd-network-nav-menu {
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");
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item {
font-size: 0.9375rem;
padding: 0 0.75rem;
margin-bottom: 0.25rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item:first-of-type {
padding-top: 0.5rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: rgba(255, 255, 255, 0.85);
background: none;
border: none;
border-radius: 6px;
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 {
background-color: rgba(255,255,255,0.37);
+53 -49
View File
@@ -7,9 +7,9 @@
@inject ILeasesClient LeasesClient
@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)
{
@@ -18,8 +18,8 @@
else
{
@* ---- Static hosts (dhcp-host= in managed file) ---- *@
<section class="mb-4">
<h2 class="h5 mb-2">Static hosts</h2>
<section class="page-section">
<h2 class="page-section-title">Static hosts</h2>
@if (!_status.ManagedFilePathExists)
{
<p class="text-warning">Managed config file not found: @_status.ManagedFilePath</p>
@@ -38,42 +38,44 @@ else
{
<div class="alert alert-success">@_message</div>
}
<table class="table table-bordered">
<thead>
<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>
</tr>
</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>
<div class="table-responsive-cards">
<table class="table table-bordered">
<thead>
<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>
</tr>
}
</tbody>
</table>
<button class="btn btn-secondary mt-2" @onclick="Add">Add host</button>
</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>
}
</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>
@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>
}
</section>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="mb-4">
<h2 class="h5 mb-2">Leases</h2>
<section class="page-section">
<h2 class="page-section-title">Leases</h2>
@if (!_status.LeasesPathConfigured)
{
<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 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
{
<table class="table table-bordered">
<thead>
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr>
</thead>
<tbody>
@foreach (var e in _leases)
{
<tr>
<td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>@e.Mac</td>
<td>@e.Address</td>
<td>@e.Name</td>
<td>@e.ClientId</td>
</tr>
}
</tbody>
</table>
<div class="table-responsive-cards">
<table class="table table-bordered">
<thead>
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr>
</thead>
<tbody>
@foreach (var e in _leases)
{
<tr>
<td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
<td>@e.Mac</td>
<td>@e.Address</td>
<td>@e.Name</td>
<td>@e.ClientId</td>
</tr>
}
</tbody>
</table>
</div>
<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>
}
@@ -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
@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>
@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
@implements IDisposable
<PageTitle>Dnsmasq</PageTitle>
<h1 class="mb-4">Dnsmasq</h1>
<p class="lead text-muted mb-4">Configuration and service status at a glance.</p>
<PageTitle>Overview | Local DNS</PageTitle>
@if (_loading)
{
<p><em>Loading…</em></p>
<p class="text-muted"><em>Loading…</em></p>
}
else if (_error != null)
{
@@ -22,9 +18,9 @@ else if (_error != null)
}
else if (_status != null)
{
<section class="mb-4">
<h2 class="h5 mb-2">Service</h2>
<div class="alert alert-@(_status.DnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-0">
<section class="page-section">
<h2 class="page-section-title">Service</h2>
<div class="alert alert-@(_status.DnsmasqStatus == "active" ? "success" : "secondary") py-2 mb-2">
<strong>Dnsmasq:</strong> @GetStatusLabel(_status)
@if (_status.StatusCommandExitCode.HasValue)
{
@@ -37,7 +33,7 @@ else if (_status != null)
</div>
@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)
{
@@ -47,7 +43,7 @@ else if (_status != null)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
}
@(_reloading ? "Reloading…" : "Reload dnsmasq")
@(_reloading ? "Reloading…" : "Reload config")
</button>
@if (!string.IsNullOrEmpty(_reloadMessage))
{
@@ -55,49 +51,65 @@ else if (_status != 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>
}
@* 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)
{
<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))
{
<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
{
<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 *@
@if (_status.LogsConfigured)
@* Recent logs — live view from LogsCommand (show command so it's clear what produces the output) *@
@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>
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
<p class="mb-1 mt-3"><strong>Recent logs</strong></p>
@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)
{
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" @onclick="RefreshStatus" disabled="@_refreshing">Refresh status &amp; logs</button>
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
<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>
}
</section>
<section class="mb-4">
<h2 class="h5 mb-2">Effective config</h2>
<section class="page-section">
<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>
<ul class="list-unstyled small">
<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-leasefile:</strong> @(ec.DhcpLeaseFilePath ?? "(default)")</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.PidFilePath != null) { <li><strong>pid-file:</strong> @ec.PidFilePath</li> }
@if (ec.User != null) { <li><strong>user:</strong> @ec.User</li> }
@@ -141,8 +153,8 @@ else if (_status != null)
}
</section>
<section class="mb-4">
<h2 class="h5 mb-2">Config</h2>
<section class="page-section">
<h2 class="page-section-title">Config</h2>
<ul class="list-unstyled small">
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
@@ -170,8 +182,8 @@ else if (_status != null)
}
</section>
<section class="mb-4">
<h2 class="h5 mb-2">Paths</h2>
<section class="page-section">
<h2 class="page-section-title">Paths</h2>
<ul class="list-unstyled small">
<li>
<strong>Leases file:</strong> @(_status.LeasesPath ?? "—")
@@ -186,14 +198,6 @@ else if (_status != null)
</li>
</ul>
</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 {
@@ -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 IHostsClient HostsClient
<PageTitle>Hosts</PageTitle>
<PageTitle>Hosts | Local DNS</PageTitle>
<h1>Hosts file</h1>
<h1 class="page-title">Hosts file</h1>
@if (_status == null)
{
@@ -46,21 +46,23 @@ else
{
<div class="alert alert-success">@_message</div>
}
<table class="table table-bordered">
<thead>
<tr><th>#</th><th>Address</th><th>Names</th></tr>
</thead>
<tbody>
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
}
</tbody>
</table>
<div class="table-responsive-cards">
<table class="table table-bordered">
<thead>
<tr><th>#</th><th>Address</th><th>Names</th></tr>
</thead>
<tbody>
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
}
</tbody>
</table>
</div>
<p class="text-muted small">Path: @_status.SystemHostsPath</p>
<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),
StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand),
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,
StatusCommandExitCode: dnsmasqStatus != "active" && statusResult.ExitCode.HasValue ? statusResult.ExitCode.Value : null,
StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null,
@@ -89,4 +92,33 @@ public class StatusController : ControllerBase
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;
/// <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(
string? SystemHostsPath,
bool SystemHostsPathExists,
@@ -18,6 +46,9 @@ public record DnsmasqServiceStatus(
bool StatusCommandConfigured,
bool StatusShowConfigured,
bool LogsConfigured,
string? LogsPath,
string? StatusShowCommand,
string? LogsCommand,
string DnsmasqStatus,
int? StatusCommandExitCode,
string? StatusCommandStdout,
@@ -40,4 +40,19 @@ public record EffectiveDnsmasqConfig(
int? MaxCacheTtl,
int? MinCacheTtl,
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 {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
/* Global styles – mobile-first, consistent spacing */
: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 {
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 {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
background-color: #0d6efd;
border-color: #0d6efd;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
.btn-primary:hover {
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 {
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 {
@@ -25,15 +95,16 @@ h1:focus {
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
outline: 2px solid #198754;
}
.invalid {
outline: 1px solid #e50000;
outline: 2px solid #dc3545;
}
.validation-message {
color: #e50000;
color: #dc3545;
font-size: 0.875rem;
}
.blazor-error-boundary {
@@ -42,19 +113,56 @@ h1:focus {
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.blazor-error-boundary::after {
content: "An error has occurred.";
}
.darker-border-checkbox.form-check-input {
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);
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;
}
}
/* 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).
# DHCP-only; uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases.
port=0
# 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.
port=53
interface=eth0
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-leasefile=/data/leases
# Additional config and hosts
conf-dir=/data/dnsmasq.d
addn-hosts=/data/hosts
# Log to file so the harness can show real logs (LogsCommand tails this)
log-facility=/data/dnsmasq.log
log-queries
log-queries