Files
dnsmasq-webui/src/DnsmasqWebUI/Components/Pages/Hosts.razor
T
alex 888bc980cf Effective config with source tracking, Dnsmasq page, and test harness
- Effective config: track value sources (ConfigValueSource, EffectiveConfigSources)
  and show readonly badges/tooltips for non-managed files. Multi-value options
  use ValueWithSource/PathWithSource for JSON. StatusClient uses camelCase
  serialization for reliable deserialization.
- Parsing: align with dnsmasq (conf-file/conf-dir order, suffix filter, case-
  sensitive keys). DnsmasqConfIncludeParser and DnsmasqConfigSetService build
  effective config and sources. New DnsmasqConfigSetServiceTests.
- UI: rename Overview/Home to Dnsmasq (page, nav, doc comments). Add
  EffectiveConfigOptionItem and EffectiveConfigMultiValueRow components.
  Dnsmasq.razor uses them; Hosts.razor shows addn-hosts with source. Encode
  server-derived strings in markup to avoid XSS.
- Home section namespace: Components.Home -> Components.Dnsmasq (LogsSection,
  StatusSection, _Imports).
- Test data: expand testdata (README, resolv.dnsmasq, hosts.extra, dnsmasq.d
  configs). dhcp.conf static IP aligned with testnet.
- agents.md: add agent instructions for test harness and conventions.
2026-02-02 12:12:01 +10:00

165 lines
6.2 KiB
Plaintext

@page "/hosts"
@rendermode InteractiveServer
@namespace DnsmasqWebUI.Components.Pages
@inject IStatusClient StatusClient
@inject IHostsClient HostsClient
@inject IOptions<ApplicationOptions> AppOptions
<PageTitle>Hosts | @AppTitle</PageTitle>
<h1 class="page-title">Hosts file</h1>
@if (_status == null)
{
<p class="d-flex align-items-center gap-2">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</p>
}
else if (_status.NoHosts && !_pathInAddnHosts)
{
<p class="text-muted">Hosts editing is disabled: <strong>no-hosts</strong> is set in dnsmasq config (dnsmasq only uses addn-hosts files) and the configured path is not in the effective addn-hosts list, so dnsmasq would not read the file.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Effective addn-hosts paths: @RenderAddnHostsList()</p>
}
}
else if (string.IsNullOrEmpty(_status.SystemHostsPath))
{
<p class="text-muted">No system hosts file configured. Set <strong>Dnsmasq:SystemHostsPath</strong> (e.g. /etc/hosts) to edit a hosts file from this app.</p>
@if (_status.AddnHostsPaths?.Count > 0)
{
<p class="text-muted small">Dnsmasq loads these addn-hosts files: @RenderAddnHostsList()</p>
}
}
else if (!_status.SystemHostsPathExists)
{
<p class="text-warning">System hosts file not found: @_status.SystemHostsPath</p>
}
else
{
@if (_status.DnsmasqStatus == "inactive" || _status.DnsmasqStatus == "unknown")
{
<div class="alert alert-warning">Dnsmasq service appears to be <strong>@_status.DnsmasqStatus</strong>. Save will update the file but reload may fail until the service is fixed.</div>
}
@if (_error != null)
{
<div class="alert alert-danger">@_error</div>
}
@if (_message != null)
{
<div class="alert alert-success">@_message</div>
}
<div class="table-responsive-cards">
<div class="d-none d-md-block">
<table class="table table-bordered">
<thead>
<tr><th>#</th><th>Address</th><th>Names</th></tr>
</thead>
<tbody>
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="e.Id">
<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>
<p class="text-muted small">Path: @_status.SystemHostsPath</p>
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
}
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status;
private List<HostEntry> _entries = new();
private string? _error;
private string? _message;
private bool _saving;
/// <summary>True when SystemHostsPath is in the effective addn-hosts list (editing allowed when no-hosts is set).</summary>
private bool _pathInAddnHosts => _status != null
&& !string.IsNullOrEmpty(_status.SystemHostsPath)
&& _status.AddnHostsPaths?.Any(p => string.Equals(p, Path.GetFullPath(_status.SystemHostsPath!.Trim()), StringComparison.Ordinal)) == true;
/// <summary>Returns MarkupString so Blazor renders raw HTML (path + source label).</summary>
private MarkupString RenderAddnHostsList()
{
if (_status?.AddnHostsPaths == null || _status.AddnHostsPaths.Count == 0)
return (MarkupString)"(none)";
var srcList = _status.EffectiveConfigSources?.AddnHostsPaths;
if (srcList != null && srcList.Count == _status.AddnHostsPaths.Count)
{
return (MarkupString)string.Join(", ", srcList.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.Path)} <span class=\"text-muted\" title=\"{System.Net.WebUtility.HtmlEncode(tip)}\">({System.Net.WebUtility.HtmlEncode(label)})</span>";
}));
}
return (MarkupString)System.Net.WebUtility.HtmlEncode(string.Join(", ", _status.AddnHostsPaths));
}
protected override async Task OnInitializedAsync()
{
await Load();
}
private async Task Load()
{
_error = null;
_message = null;
try
{
_status = await StatusClient.GetStatusAsync();
var entries = await HostsClient.GetHostsAsync();
_entries = entries?.ToList() ?? new List<HostEntry>();
}
catch (Exception ex)
{
_error = ex.Message;
}
}
private async Task Save()
{
_saving = true;
_error = null;
_message = null;
try
{
var result = await HostsClient.SaveHostsAsync(_entries);
if (result.Reload.Success)
_message = "Saved and dnsmasq reloaded.";
else if (!string.IsNullOrEmpty(result.Reload.StdErr))
_error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr;
else
_message = "Saved.";
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_saving = false;
}
}
}