mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
WIP: layout, status, hosts, dhcp, error pages and tests
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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 & 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+18
-3
@@ -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
|
||||
Reference in New Issue
Block a user