mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-21 13:22:35 +10:00
Dnsmasq/Hosts UI tweaks, test harness: down before redeploy, exclude leases from mount
- Dnsmasq page: Config (Main config, Managed file, Config set) under Service configuration; remove Paths/Leases (in Effective config DHCP); EffectiveConfigSection collapsible groups; StatusSection flex-wrap - Hosts: HostsFileSection always shows table; 'No host entries' in tbody when empty - Models: reorganize into sub-namespaces (Config, EffectiveConfig, Hosts, Dhcp, Status); move EffectiveConfig/Status/Logs components under Dnsmasq - Test harness: prepare-test-mount.sh runs 'docker compose down' before mount+up so all containers (including DHCP clients) restart; exclude testdata/leases from sync (unit tests only); testdata/README
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li>
|
||||
<strong>@Label:</strong> @Content
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li title="@(Source?.GetReadOnlyTooltip())">
|
||||
<strong>@Label:</strong> @DisplayValue
|
||||
@@ -0,0 +1,178 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Status
|
||||
@using DnsmasqWebUI.Models.EffectiveConfig
|
||||
|
||||
@if (Status != null)
|
||||
{
|
||||
<div class="ec-sections small">
|
||||
@* Hosts *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("hosts")' aria-expanded="@IsOpen("hosts")">
|
||||
<span class="ec-section-title fw-bold">Hosts</span>
|
||||
<i class="bi @(IsOpen("hosts") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("hosts") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="no-hosts" DisplayValue="@(Status.NoHosts ? "Yes (hosts files disabled)" : "No")" Source="@(Status.EffectiveConfigSources?.NoHosts)" />
|
||||
<li>
|
||||
<strong>addn-hosts:</strong>
|
||||
@if (Status.AddnHostsPaths?.Count > 0)
|
||||
{
|
||||
var srcList = Status.EffectiveConfigSources?.AddnHostsPaths;
|
||||
if (srcList != null && srcList.Count == Status.AddnHostsPaths.Count)
|
||||
{
|
||||
@foreach (var entry in srcList)
|
||||
{
|
||||
<span class="d-inline-block me-2" title="@(entry.Source?.GetReadOnlyTooltip())">@entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "")</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var deduped = (Status.AddnHostsPaths ?? Array.Empty<string>()).Distinct().ToList();
|
||||
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
|
||||
}
|
||||
}
|
||||
else { <span class="text-muted">(none)</span> }
|
||||
</li>
|
||||
@if (Status.ManagedHostsFilePath != null) { <li><strong>Managed hosts file (editable):</strong> @Status.ManagedHostsFilePath @(Status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li> }
|
||||
@if (Status.SystemHostsPath != null) { <li><strong>System hosts (read-only):</strong> @Status.SystemHostsPath @(Status.SystemHostsPathExists ? "✓" : "(missing)")</li> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Status.EffectiveConfig != null)
|
||||
{
|
||||
var ec = Status.EffectiveConfig;
|
||||
var src = Status.EffectiveConfigSources;
|
||||
|
||||
@* Resolver / DNS *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("resolver")' aria-expanded="@IsOpen("resolver")">
|
||||
<span class="ec-section-title fw-bold">Resolver / DNS</span>
|
||||
<i class="bi @(IsOpen("resolver") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("resolver") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="expand-hosts" DisplayValue="@(ec.ExpandHosts ? "Yes" : "No")" Source="@(src?.ExpandHosts)" />
|
||||
<EffectiveConfigOptionItem Label="bogus-priv" DisplayValue="@(ec.BogusPriv ? "Yes" : "No")" Source="@(src?.BogusPriv)" />
|
||||
<EffectiveConfigOptionItem Label="strict-order" DisplayValue="@(ec.StrictOrder ? "Yes" : "No")" Source="@(src?.StrictOrder)" />
|
||||
<EffectiveConfigOptionItem Label="no-resolv" DisplayValue="@(ec.NoResolv ? "Yes" : "No")" Source="@(src?.NoResolv)" />
|
||||
<EffectiveConfigOptionItem Label="domain-needed" DisplayValue="@(ec.DomainNeeded ? "Yes" : "No")" Source="@(src?.DomainNeeded)" />
|
||||
<EffectiveConfigOptionItem Label="port" DisplayValue="@(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" />
|
||||
@if (ec.ServerLocalValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="server / local" Content="@((MarkupString)RenderMultiValueList(ec.ServerLocalValues, src?.ServerLocalValues))" /> }
|
||||
@if (ec.AddressValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="address" Content="@((MarkupString)RenderMultiValueList(ec.AddressValues, src?.AddressValues))" /> }
|
||||
@if (ec.ResolvFiles?.Count > 0) { <EffectiveConfigMultiValueRow Label="resolv-file" Content="@((MarkupString)RenderMultiValueList(ec.ResolvFiles, src?.ResolvFiles))" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* DHCP *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("dhcp")' aria-expanded="@IsOpen("dhcp")">
|
||||
<span class="ec-section-title fw-bold">DHCP</span>
|
||||
<i class="bi @(IsOpen("dhcp") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("dhcp") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="dhcp-authoritative" DisplayValue="@(ec.DhcpAuthoritative ? "Yes" : "No")" Source="@(src?.DhcpAuthoritative)" />
|
||||
<EffectiveConfigOptionItem Label="leasefile-ro" DisplayValue="@(ec.LeasefileRo ? "Yes" : "No")" Source="@(src?.LeasefileRo)" />
|
||||
<EffectiveConfigOptionItem Label="dhcp-leasefile" DisplayValue="@(ec.DhcpLeaseFilePath ?? "(default)")" Source="@(src?.DhcpLeaseFilePath)" />
|
||||
@if (ec.DhcpLeaseMax.HasValue) { <EffectiveConfigOptionItem Label="dhcp-lease-max" DisplayValue="@ec.DhcpLeaseMax.Value.ToString()" Source="@(src?.DhcpLeaseMax)" /> }
|
||||
@if (ec.DhcpTtl.HasValue) { <EffectiveConfigOptionItem Label="dhcp-ttl" DisplayValue="@ec.DhcpTtl.Value.ToString()" Source="@(src?.DhcpTtl)" /> }
|
||||
@if (ec.DhcpRanges?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-range" Content="@((MarkupString)RenderMultiValueList(ec.DhcpRanges, src?.DhcpRanges))" /> }
|
||||
@if (ec.DhcpHostLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-host" Content="@((MarkupString)RenderMultiValueList(ec.DhcpHostLines, src?.DhcpHostLines))" /> }
|
||||
@if (ec.DhcpOptionLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-option" Content="@((MarkupString)RenderMultiValueList(ec.DhcpOptionLines, src?.DhcpOptionLines))" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Cache *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("cache")' aria-expanded="@IsOpen("cache")">
|
||||
<span class="ec-section-title fw-bold">Cache</span>
|
||||
<i class="bi @(IsOpen("cache") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("cache") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
@if (ec.CacheSize.HasValue) { <EffectiveConfigOptionItem Label="cache-size" DisplayValue="@ec.CacheSize.Value.ToString()" Source="@(src?.CacheSize)" /> }
|
||||
@if (ec.LocalTtl.HasValue) { <EffectiveConfigOptionItem Label="local-ttl" DisplayValue="@ec.LocalTtl.Value.ToString()" Source="@(src?.LocalTtl)" /> }
|
||||
<EffectiveConfigOptionItem Label="no-negcache" DisplayValue="@(ec.NoNegcache ? "Yes" : "No")" Source="@(src?.NoNegcache)" />
|
||||
@if (ec.NegTtl.HasValue) { <EffectiveConfigOptionItem Label="neg-ttl" DisplayValue="@ec.NegTtl.Value.ToString()" Source="@(src?.NegTtl)" /> }
|
||||
@if (ec.MaxTtl.HasValue) { <EffectiveConfigOptionItem Label="max-ttl" DisplayValue="@ec.MaxTtl.Value.ToString()" Source="@(src?.MaxTtl)" /> }
|
||||
@if (ec.MaxCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="max-cache-ttl" DisplayValue="@ec.MaxCacheTtl.Value.ToString()" Source="@(src?.MaxCacheTtl)" /> }
|
||||
@if (ec.MinCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="min-cache-ttl" DisplayValue="@ec.MinCacheTtl.Value.ToString()" Source="@(src?.MinCacheTtl)" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Process & networking *@
|
||||
<div class="ec-section">
|
||||
<button type="button" class="ec-section-header" @onclick='() => TogglePanel("process")' aria-expanded="@IsOpen("process")">
|
||||
<span class="ec-section-title fw-bold">Process & networking</span>
|
||||
<i class="bi @(IsOpen("process") ? "bi-chevron-down" : "bi-chevron-right") ec-section-chevron" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div class="collapse @(IsOpen("process") ? "show" : "")">
|
||||
<div class="ec-section-body">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<EffectiveConfigOptionItem Label="no-poll" DisplayValue="@(ec.NoPoll ? "Yes" : "No")" Source="@(src?.NoPoll)" />
|
||||
<EffectiveConfigOptionItem Label="bind-interfaces" DisplayValue="@(ec.BindInterfaces ? "Yes" : "No")" Source="@(src?.BindInterfaces)" />
|
||||
@if (ec.Interfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="interface" Content="@((MarkupString)RenderMultiValueList(ec.Interfaces, src?.Interfaces))" /> }
|
||||
@if (ec.ListenAddresses?.Count > 0) { <EffectiveConfigMultiValueRow Label="listen-address" Content="@((MarkupString)RenderMultiValueList(ec.ListenAddresses, src?.ListenAddresses))" /> }
|
||||
@if (ec.ExceptInterfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="except-interface" Content="@((MarkupString)RenderMultiValueList(ec.ExceptInterfaces, src?.ExceptInterfaces))" /> }
|
||||
@if (ec.PidFilePath != null) { <EffectiveConfigOptionItem Label="pid-file" DisplayValue="@ec.PidFilePath" Source="@(src?.PidFilePath)" /> }
|
||||
@if (ec.User != null) { <EffectiveConfigOptionItem Label="user" DisplayValue="@ec.User" Source="@(src?.User)" /> }
|
||||
@if (ec.Group != null) { <EffectiveConfigOptionItem Label="group" DisplayValue="@ec.Group" Source="@(src?.Group)" /> }
|
||||
@if (ec.LogFacility != null) { <EffectiveConfigOptionItem Label="log-facility" DisplayValue="@ec.LogFacility" Source="@(src?.LogFacility)" /> }
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
|
||||
private HashSet<string> _openPanels = new();
|
||||
|
||||
private bool IsOpen(string id) => _openPanels.Contains(id);
|
||||
|
||||
private void TogglePanel(string id)
|
||||
{
|
||||
if (_openPanels.Contains(id))
|
||||
_openPanels.Remove(id);
|
||||
else
|
||||
_openPanels.Add(id);
|
||||
}
|
||||
|
||||
private static MarkupString RenderMultiValueList(IReadOnlyList<string>? values, IReadOnlyList<ValueWithSource>? sources)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
return (MarkupString)"(none)";
|
||||
if (sources != null && sources.Count == values.Count)
|
||||
{
|
||||
var parts = sources.Select(e =>
|
||||
{
|
||||
var s = e.Source;
|
||||
var valueEnc = System.Net.WebUtility.HtmlEncode(e.Value);
|
||||
if (s?.IsReadOnly == true)
|
||||
{
|
||||
var tipEnc = System.Net.WebUtility.HtmlEncode(s.GetReadOnlyTooltip() ?? "");
|
||||
return $"{valueEnc} <span class=\"badge bg-secondary ms-1\" title=\"{tipEnc}\">readonly</span>";
|
||||
}
|
||||
return valueEnc;
|
||||
});
|
||||
return (MarkupString)string.Join(", ", parts);
|
||||
}
|
||||
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* Effective config sections – mobile-first, touch-friendly */
|
||||
.ec-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.ec-section {
|
||||
border-bottom: 1px solid var(--bs-border-color, #dee2e6);
|
||||
}
|
||||
|
||||
.ec-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Touch-friendly header: min 44px tap target (WCAG 2.5.5) */
|
||||
.ec-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: 0.4rem 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
line-height: 1.3;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.ec-section-header:hover {
|
||||
color: var(--bs-primary, #0d6efd);
|
||||
}
|
||||
|
||||
.ec-section-header:focus-visible {
|
||||
outline: 2px solid var(--bs-primary, #0d6efd);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.ec-section-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ec-section-chevron {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-section-body {
|
||||
padding: 0.25rem 0 0.5rem 0;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Long paths/values wrap on small screens; badges stay inline */
|
||||
.ec-section-body ul {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ec-section-body li {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.ec-section-body {
|
||||
padding-left: 0.35rem;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
using DnsmasqWebUI.Client.Http.Abstractions;
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
+9
-3
@@ -1,9 +1,15 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
|
||||
@if (_status?.StatusShowConfigured == true)
|
||||
@if (_status != null && _status.StatusShowConfigured)
|
||||
{
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
|
||||
<strong>Service status</strong>
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
|
||||
<span class="d-flex align-items-center gap-1">
|
||||
<strong>Service status</strong>
|
||||
@{
|
||||
var isActive = _status.DnsmasqStatus == "active";
|
||||
}
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger")" style="width: 0.5rem; height: 0.5rem;" title="@(isActive ? "Running" : "Not running")" aria-hidden="true"></span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
using DnsmasqWebUI.Client.Http.Abstractions;
|
||||
using DnsmasqWebUI.Models;
|
||||
using DnsmasqWebUI.Models.Status;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
@@ -1,3 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Hosts
|
||||
<div class="hosts-file-section">
|
||||
<h3 class="h6 @(IsFirst ? "mb-1" : "mt-3 mb-1") @(IsEditable ? "" : "text-muted")">
|
||||
@Path
|
||||
@@ -17,27 +18,41 @@
|
||||
<tr><th>#</th><th>Address</th><th>Names</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
|
||||
@if (_visibleEntries.Count == 0)
|
||||
{
|
||||
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
|
||||
<td>@e.LineNumber</td>
|
||||
<td>@e.Address</td>
|
||||
<td>@string.Join(", ", e.Names)</td>
|
||||
</tr>
|
||||
<tr><td colspan="3" class="text-muted small">No host entries.</td></tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var e in _visibleEntries)
|
||||
{
|
||||
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
|
||||
<td>@e.LineNumber</td>
|
||||
<td>@e.Address</td>
|
||||
<td>@string.Join(", ", e.Names)</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-md-none table-cards-mobile">
|
||||
@foreach (var e in (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough))
|
||||
@if (_visibleEntries.Count == 0)
|
||||
{
|
||||
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
|
||||
<div class="card-body py-2">
|
||||
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
|
||||
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
|
||||
<div class="table-card-row"><span class="table-card-label">Names</span> @string.Join(", ", e.Names)</div>
|
||||
<p class="text-muted small mb-0">No host entries.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var e in _visibleEntries)
|
||||
{
|
||||
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
|
||||
<div class="card-body py-2">
|
||||
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
|
||||
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
|
||||
<div class="table-card-row"><span class="table-card-label">Names</span> @string.Join(", ", e.Names)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,4 +63,11 @@
|
||||
[Parameter] public IReadOnlyList<HostEntry>? Entries { get; set; }
|
||||
[Parameter] public bool IsEditable { get; set; }
|
||||
[Parameter] public bool IsFirst { get; set; }
|
||||
|
||||
private IReadOnlyList<HostEntry> _visibleEntries = Array.Empty<HostEntry>();
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_visibleEntries = (Entries ?? Array.Empty<HostEntry>()).Where(x => !x.IsPassthrough).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Hosts
|
||||
@@ -21,18 +21,33 @@ else if (_error != null)
|
||||
else if (_status != null)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<span class="ms-1 text-muted small">(exit @_status.StatusCommandExitCode.Value)</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_status.StatusCommandStderr))
|
||||
{
|
||||
<span class="ms-1 text-muted small">— @_status.StatusCommandStderr</span>
|
||||
}
|
||||
</div>
|
||||
<h2 class="page-section-title">Service configuration</h2>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li><strong>Main config:</strong> @(_status.MainConfigPath ?? "—") @(_status.MainConfigPathExists ? "✓" : "(missing)")</li>
|
||||
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
|
||||
</ul>
|
||||
@if (_configSet != null && _configSet.Files?.Count > 0)
|
||||
{
|
||||
<p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
|
||||
<ul class="list-unstyled small ms-2 mb-0">
|
||||
@foreach (var f in _configSet.Files)
|
||||
{
|
||||
<li>
|
||||
@if (f.IsManaged)
|
||||
{
|
||||
<span class="badge bg-primary me-1">managed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
|
||||
}
|
||||
@f.FileName
|
||||
<span class="text-muted">(@f.Path)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
<EffectiveConfigSection Status="_status" />
|
||||
@if (_status.DnsmasqStatus != "active" && (_status.StatusCommandStdout != null || _status.StatusCommandStderr != null))
|
||||
{
|
||||
<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>
|
||||
@@ -63,135 +78,6 @@ else if (_status != null)
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
</section>
|
||||
|
||||
<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). Values from a non-managed file are readonly (edit that file to change).</p>
|
||||
<ul class="list-unstyled small">
|
||||
<EffectiveConfigOptionItem Label="no-hosts" DisplayValue="@(_status.NoHosts ? "Yes (hosts files disabled)" : "No")" Source="@(_status.EffectiveConfigSources?.NoHosts)" />
|
||||
<li>
|
||||
<strong>addn-hosts:</strong>
|
||||
@if (_status.AddnHostsPaths?.Count > 0)
|
||||
{
|
||||
var srcList = _status.EffectiveConfigSources?.AddnHostsPaths;
|
||||
if (srcList != null && srcList.Count == _status.AddnHostsPaths.Count)
|
||||
{
|
||||
@foreach (var entry in srcList)
|
||||
{
|
||||
<span class="d-inline-block me-2" title="@(entry.Source?.GetReadOnlyTooltip())">@entry.Path @(entry.Source?.IsReadOnly == true ? " (readonly)" : "")</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var deduped = (_status.AddnHostsPaths ?? Array.Empty<string>()).Distinct().ToList();
|
||||
@(deduped.Count == 1 ? deduped[0] : string.Join(", ", deduped))
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
}
|
||||
</li>
|
||||
@if (_status.ManagedHostsFilePath != null)
|
||||
{
|
||||
<li><strong>Managed hosts file (editable):</strong> @_status.ManagedHostsFilePath @(_status.ManagedHostsPathExists ? "✓" : "(not created yet)")</li>
|
||||
}
|
||||
@if (_status.SystemHostsPath != null)
|
||||
{
|
||||
<li><strong>System hosts (read-only):</strong> @_status.SystemHostsPath @(_status.SystemHostsPathExists ? "✓" : "(missing)")</li>
|
||||
}
|
||||
</ul>
|
||||
@if (_status.EffectiveConfig != null)
|
||||
{
|
||||
var ec = _status.EffectiveConfig;
|
||||
var src = _status.EffectiveConfigSources;
|
||||
<p class="mb-1 mt-2 text-muted small"><strong>Options</strong> (last wins for single-value; readonly when from a non-managed file):</p>
|
||||
<ul class="list-unstyled small ms-2">
|
||||
<EffectiveConfigOptionItem Label="expand-hosts" DisplayValue="@(ec.ExpandHosts ? "Yes" : "No")" Source="@(src?.ExpandHosts)" />
|
||||
<EffectiveConfigOptionItem Label="bogus-priv" DisplayValue="@(ec.BogusPriv ? "Yes" : "No")" Source="@(src?.BogusPriv)" />
|
||||
<EffectiveConfigOptionItem Label="strict-order" DisplayValue="@(ec.StrictOrder ? "Yes" : "No")" Source="@(src?.StrictOrder)" />
|
||||
<EffectiveConfigOptionItem Label="no-resolv" DisplayValue="@(ec.NoResolv ? "Yes" : "No")" Source="@(src?.NoResolv)" />
|
||||
<EffectiveConfigOptionItem Label="domain-needed" DisplayValue="@(ec.DomainNeeded ? "Yes" : "No")" Source="@(src?.DomainNeeded)" />
|
||||
<EffectiveConfigOptionItem Label="dhcp-authoritative" DisplayValue="@(ec.DhcpAuthoritative ? "Yes" : "No")" Source="@(src?.DhcpAuthoritative)" />
|
||||
<EffectiveConfigOptionItem Label="no-poll" DisplayValue="@(ec.NoPoll ? "Yes" : "No")" Source="@(src?.NoPoll)" />
|
||||
<EffectiveConfigOptionItem Label="bind-interfaces" DisplayValue="@(ec.BindInterfaces ? "Yes" : "No")" Source="@(src?.BindInterfaces)" />
|
||||
<EffectiveConfigOptionItem Label="no-negcache" DisplayValue="@(ec.NoNegcache ? "Yes" : "No")" Source="@(src?.NoNegcache)" />
|
||||
<EffectiveConfigOptionItem Label="leasefile-ro" DisplayValue="@(ec.LeasefileRo ? "Yes" : "No")" Source="@(src?.LeasefileRo)" />
|
||||
<EffectiveConfigOptionItem Label="dhcp-leasefile" DisplayValue="@(ec.DhcpLeaseFilePath ?? "(default)")" Source="@(src?.DhcpLeaseFilePath)" />
|
||||
@if (ec.CacheSize.HasValue) { <EffectiveConfigOptionItem Label="cache-size" DisplayValue="@ec.CacheSize.Value.ToString()" Source="@(src?.CacheSize)" /> }
|
||||
<EffectiveConfigOptionItem Label="port" DisplayValue="@(ec.Port switch { null => "53 (default)", 0 => "0 (no DNS listener)", _ => ec.Port.Value.ToString() })" Source="@(src?.Port)" />
|
||||
@if (ec.LocalTtl.HasValue) { <EffectiveConfigOptionItem Label="local-ttl" DisplayValue="@ec.LocalTtl.Value.ToString()" Source="@(src?.LocalTtl)" /> }
|
||||
@if (ec.PidFilePath != null) { <EffectiveConfigOptionItem Label="pid-file" DisplayValue="@ec.PidFilePath" Source="@(src?.PidFilePath)" /> }
|
||||
@if (ec.User != null) { <EffectiveConfigOptionItem Label="user" DisplayValue="@ec.User" Source="@(src?.User)" /> }
|
||||
@if (ec.Group != null) { <EffectiveConfigOptionItem Label="group" DisplayValue="@ec.Group" Source="@(src?.Group)" /> }
|
||||
@if (ec.LogFacility != null) { <EffectiveConfigOptionItem Label="log-facility" DisplayValue="@ec.LogFacility" Source="@(src?.LogFacility)" /> }
|
||||
@if (ec.DhcpLeaseMax.HasValue) { <EffectiveConfigOptionItem Label="dhcp-lease-max" DisplayValue="@ec.DhcpLeaseMax.Value.ToString()" Source="@(src?.DhcpLeaseMax)" /> }
|
||||
@if (ec.NegTtl.HasValue) { <EffectiveConfigOptionItem Label="neg-ttl" DisplayValue="@ec.NegTtl.Value.ToString()" Source="@(src?.NegTtl)" /> }
|
||||
@if (ec.MaxTtl.HasValue) { <EffectiveConfigOptionItem Label="max-ttl" DisplayValue="@ec.MaxTtl.Value.ToString()" Source="@(src?.MaxTtl)" /> }
|
||||
@if (ec.MaxCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="max-cache-ttl" DisplayValue="@ec.MaxCacheTtl.Value.ToString()" Source="@(src?.MaxCacheTtl)" /> }
|
||||
@if (ec.MinCacheTtl.HasValue) { <EffectiveConfigOptionItem Label="min-cache-ttl" DisplayValue="@ec.MinCacheTtl.Value.ToString()" Source="@(src?.MinCacheTtl)" /> }
|
||||
@if (ec.DhcpTtl.HasValue) { <EffectiveConfigOptionItem Label="dhcp-ttl" DisplayValue="@ec.DhcpTtl.Value.ToString()" Source="@(src?.DhcpTtl)" /> }
|
||||
</ul>
|
||||
<p class="mb-1 mt-2 text-muted small"><strong>Multi-value options</strong> (server/local, address, interface, etc.; each with source when known):</p>
|
||||
<ul class="list-unstyled small ms-2">
|
||||
@if (ec.ServerLocalValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="server / local" Content="@((MarkupString)RenderMultiValueList(ec.ServerLocalValues, src?.ServerLocalValues))" /> }
|
||||
@if (ec.AddressValues?.Count > 0) { <EffectiveConfigMultiValueRow Label="address" Content="@((MarkupString)RenderMultiValueList(ec.AddressValues, src?.AddressValues))" /> }
|
||||
@if (ec.Interfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="interface" Content="@((MarkupString)RenderMultiValueList(ec.Interfaces, src?.Interfaces))" /> }
|
||||
@if (ec.ListenAddresses?.Count > 0) { <EffectiveConfigMultiValueRow Label="listen-address" Content="@((MarkupString)RenderMultiValueList(ec.ListenAddresses, src?.ListenAddresses))" /> }
|
||||
@if (ec.ExceptInterfaces?.Count > 0) { <EffectiveConfigMultiValueRow Label="except-interface" Content="@((MarkupString)RenderMultiValueList(ec.ExceptInterfaces, src?.ExceptInterfaces))" /> }
|
||||
@if (ec.DhcpRanges?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-range" Content="@((MarkupString)RenderMultiValueList(ec.DhcpRanges, src?.DhcpRanges))" /> }
|
||||
@if (ec.DhcpHostLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-host" Content="@((MarkupString)RenderMultiValueList(ec.DhcpHostLines, src?.DhcpHostLines))" /> }
|
||||
@if (ec.DhcpOptionLines?.Count > 0) { <EffectiveConfigMultiValueRow Label="dhcp-option" Content="@((MarkupString)RenderMultiValueList(ec.DhcpOptionLines, src?.DhcpOptionLines))" /> }
|
||||
@if (ec.ResolvFiles?.Count > 0) { <EffectiveConfigMultiValueRow Label="resolv-file" Content="@((MarkupString)RenderMultiValueList(ec.ResolvFiles, src?.ResolvFiles))" /> }
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</ul>
|
||||
@if (_configSet != null && _configSet.Files?.Count > 0)
|
||||
{
|
||||
<p class="mb-1 mt-2"><strong>Config set (load order):</strong></p>
|
||||
<ul class="list-unstyled small ms-2">
|
||||
@foreach (var f in _configSet.Files)
|
||||
{
|
||||
<li>
|
||||
@if (f.IsManaged)
|
||||
{
|
||||
<span class="badge bg-primary me-1">managed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
|
||||
}
|
||||
@f.FileName
|
||||
<span class="text-muted">(@f.Path)</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Paths</h2>
|
||||
<ul class="list-unstyled small">
|
||||
<li>
|
||||
<strong>Leases file:</strong> @(_status.LeasesPath ?? "—")
|
||||
@if (_status.LeasesPathConfigured)
|
||||
{
|
||||
<span>@(_status.LeasesPathExists ? "✓" : "(missing)")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">(not configured)</span>
|
||||
}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -254,18 +140,6 @@ else if (_status != null)
|
||||
|
||||
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
|
||||
|
||||
private static string GetStatusLabel(DnsmasqServiceStatus s)
|
||||
{
|
||||
return s.DnsmasqStatus switch
|
||||
{
|
||||
"active" => "Running",
|
||||
"inactive" => "Not running",
|
||||
"unknown" => "Unknown",
|
||||
"notConfigured" => "Status check not configured",
|
||||
_ => s.DnsmasqStatus ?? "—"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ReloadDnsmasq()
|
||||
{
|
||||
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
|
||||
@@ -331,25 +205,6 @@ else if (_status != null)
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>Renders a multi-value list with per-value source (readonly/editable + tooltip) when EffectiveConfigSources matches; otherwise comma-separated. Returns MarkupString so Blazor renders raw HTML.</summary>
|
||||
private static MarkupString RenderMultiValueList(IReadOnlyList<string>? values, IReadOnlyList<ValueWithSource>? sources)
|
||||
{
|
||||
if (values == null || values.Count == 0)
|
||||
return (MarkupString)"(none)";
|
||||
if (sources != null && sources.Count == values.Count)
|
||||
{
|
||||
var parts = sources.Select(e =>
|
||||
{
|
||||
var src = e.Source;
|
||||
var tip = src?.GetReadOnlyTooltip() ?? "";
|
||||
var label = src == null ? "?" : (src.IsReadOnly ? "from " + src.FileName + ", readonly" : "editable");
|
||||
return $"{System.Net.WebUtility.HtmlEncode(e.Value)} <span class=\"text-muted\" title=\"{System.Net.WebUtility.HtmlEncode(tip)}\">({System.Net.WebUtility.HtmlEncode(label)})</span>";
|
||||
});
|
||||
return (MarkupString)string.Join(", ", parts);
|
||||
}
|
||||
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", values));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
|
||||
@@ -38,10 +38,7 @@ else
|
||||
<div class="alert alert-success">@_message</div>
|
||||
}
|
||||
|
||||
<p class="text-muted small mb-2">Each hosts file below is rendered with the same component; only the managed file is editable. System hosts is excluded when <strong>no-hosts</strong> is set (dnsmasq does not read it then).</p>
|
||||
|
||||
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
|
||||
<p class="text-muted small">@_status.ManagedHostsFilePath @(!_status.ManagedHostsPathExists ? "(not created yet; will be created on first save)" : "")</p>
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
|
||||
|
||||
@if (_readOnlyHosts?.Count > 0)
|
||||
|
||||
@@ -11,10 +11,14 @@
|
||||
@using DnsmasqWebUI.Client.Services.Abstractions
|
||||
@using DnsmasqWebUI.Components
|
||||
@using DnsmasqWebUI.Components.Modals
|
||||
@using DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Components.Hosts
|
||||
@using DnsmasqWebUI.Components.Dnsmasq
|
||||
@using DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Components.Hosts
|
||||
@using DnsmasqWebUI.Models
|
||||
@using DnsmasqWebUI.Models.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Hosts
|
||||
@using DnsmasqWebUI.Models.Dhcp
|
||||
@using DnsmasqWebUI.Models.Status
|
||||
@using DnsmasqWebUI.Configuration
|
||||
@using DnsmasqWebUI.Client.Http.Abstractions
|
||||
@using DnsmasqWebUI.Services.Abstractions
|
||||
|
||||
Reference in New Issue
Block a user