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