mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
DHCP: read-only hosts from other configs, combobox inputs, polling, UI fixes
- Static hosts: read dhcp-host from all config files; show external as read-only with Source column; IsEditable + SourcePath on DhcpHostEntry; reject save if managed entry duplicates MAC from another file (400 + message) - Lease field: single input + datalist (type or select), remove Other dropdown - MAC field: single input + datalist from current leases, TryFillFromLeaseByMac - Add host: default Name=*, suggest next free IP from DHCP range - Leases: configurable refresh polling; Settings modal for interval (client-side) - DhcpHostsClient: surface 400 error body message on save failure - App title config (ApplicationOptions), nav/layout/error/hosts tweaks
This commit is contained in:
+51
-3
@@ -1,4 +1,5 @@
|
||||
# Run the app with real dnsmasq and a DHCP client so hosts/leases are real.
|
||||
# Run the app with real dnsmasq and several DHCP clients so hosts/leases are real.
|
||||
# Some clients also make periodic DNS requests so the DNS cache and logs show activity.
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--prepare-only | --no-build | --recreate | --source DIR | --mount DIR]
|
||||
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
|
||||
#
|
||||
@@ -31,11 +32,58 @@ services:
|
||||
networks:
|
||||
testnet:
|
||||
ipv4_address: 172.28.0.2
|
||||
# no depends_on: app is the only service the client needs
|
||||
|
||||
# DHCP only; keeps running with lease.
|
||||
dhcp-client:
|
||||
image: alpine:3.20
|
||||
command: ["sh", "-c", "sleep 5 && udhcpc -i eth0 -f -n"]
|
||||
command: ["sh", "-c", "sleep 5 && exec udhcpc -i eth0 -f -n"]
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
# DHCP + periodic DNS lookups (every 20s) so DNS cache/logs show activity.
|
||||
dhcp-client-dns-a:
|
||||
image: alpine:3.20
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
udhcpc -i eth0 -f -n &
|
||||
sleep 10
|
||||
while true; do
|
||||
nslookup google.com 2>/dev/null || true
|
||||
nslookup cloudflare.com 2>/dev/null || true
|
||||
sleep 20
|
||||
done
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
# DHCP + periodic DNS lookups (every 45s); different interval for variety.
|
||||
dhcp-client-dns-b:
|
||||
image: alpine:3.20
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
udhcpc -i eth0 -f -n &
|
||||
sleep 10
|
||||
while true; do
|
||||
nslookup example.com 2>/dev/null || true
|
||||
nslookup github.com 2>/dev/null || true
|
||||
sleep 45
|
||||
done
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
# Third DHCP-only client so the leases table has more entries.
|
||||
dhcp-client-2:
|
||||
image: alpine:3.20
|
||||
command: ["sh", "-c", "sleep 8 && exec udhcpc -i eth0 -f -n"]
|
||||
networks:
|
||||
testnet: {}
|
||||
depends_on:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
|
||||
# Uses ps/pgrep. Memory is RSS; the time shown is process uptime (elapsed), not CPU usage.
|
||||
# Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented.
|
||||
|
||||
pid=$(pgrep -x dnsmasq)
|
||||
if [ -z "$pid" ]; then
|
||||
@@ -49,10 +49,11 @@ function fmt_since(s, n, a, mon_num, day_pad) {
|
||||
rss_mb = (rss+0) / 1024
|
||||
if (rss_mb < 0.1) rss_mb = 0.0
|
||||
|
||||
# Active line: break after semicolon; continuation (e.g. "4 min ago") on next line, indented to align after "Active: "
|
||||
if (lstart != "") {
|
||||
since = fmt_since(lstart)
|
||||
ago = etime_ago(etime)
|
||||
active_line = " Active: active (running) since " since "; " ago
|
||||
active_line = " Active: active (running) since " since ";\n " ago
|
||||
} else {
|
||||
active_line = " Active: active (running) since container start"
|
||||
}
|
||||
@@ -62,7 +63,6 @@ function fmt_since(s, n, a, mon_num, day_pad) {
|
||||
printf "%s\n", active_line
|
||||
printf " Main PID: %s (dnsmasq)\n", pid
|
||||
printf " Memory: %.1fM\n", rss_mb
|
||||
printf " Uptime: %s\n", etime
|
||||
printf " CGroup: (container)\n"
|
||||
printf " └─%s %s\n", pid, args
|
||||
}'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DnsmasqWebUI.Client.Http.Abstractions;
|
||||
using DnsmasqWebUI.Models;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace DnsmasqWebUI.Client.Http;
|
||||
|
||||
@@ -19,8 +20,25 @@ public sealed class DhcpHostsClient : IDhcpHostsClient
|
||||
public async Task<SaveWithReloadResult> SaveDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PutAsJsonAsync("api/dhcp/hosts", entries, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
var msg = response.ReasonPhrase ?? response.StatusCode.ToString();
|
||||
if (!string.IsNullOrEmpty(body) && body.TrimStart().StartsWith("{"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var err = System.Text.Json.JsonSerializer.Deserialize<JsonError>(body);
|
||||
if (!string.IsNullOrEmpty(err?.Error))
|
||||
msg = err.Error;
|
||||
}
|
||||
catch { /* use msg as-is */ }
|
||||
}
|
||||
throw new HttpRequestException(msg);
|
||||
}
|
||||
return await response.Content.ReadFromJsonAsync<SaveWithReloadResult>(ct)
|
||||
?? throw new InvalidOperationException("Unexpected null from api/dhcp/hosts.");
|
||||
}
|
||||
|
||||
private sealed class JsonError { [JsonPropertyName("error")] public string? Error { get; set; } }
|
||||
}
|
||||
|
||||
@@ -10,4 +10,7 @@ public class ClientSettings
|
||||
|
||||
/// <summary>Polling interval for the recent logs block (seconds).</summary>
|
||||
public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Polling interval for the DHCP leases list (seconds).</summary>
|
||||
public int LeasesPollingIntervalSeconds { get; set; } = 5;
|
||||
}
|
||||
|
||||
@@ -12,5 +12,8 @@ public enum SettingsModalContext
|
||||
ServicePolling,
|
||||
|
||||
/// <summary>Show only recent logs polling interval.</summary>
|
||||
LogsPolling
|
||||
LogsPolling,
|
||||
|
||||
/// <summary>Show only DHCP leases refresh interval.</summary>
|
||||
LeasesPolling
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
@@ -11,7 +12,7 @@
|
||||
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<title>Local DNS</title>
|
||||
<title>@_appTitle</title>
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
@@ -21,3 +22,6 @@
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@code {
|
||||
private string _appTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid d-flex align-items-center">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="Local DNS home">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="@AppTitle home">
|
||||
<i class="bi bi-globe brand-icon" aria-hidden="true"></i>
|
||||
<span>Local DNS</span>
|
||||
<span>@AppTitle</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,3 +32,6 @@
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,15 @@
|
||||
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection("Leases"))
|
||||
{
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">DHCP leases</h6>
|
||||
<label for="leases-interval" class="form-label">Refresh interval (seconds)</label>
|
||||
<input id="leases-interval" type="number" class="form-control" min="5" step="1"
|
||||
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
|
||||
@@ -42,7 +42,8 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
_editingSettings = new ClientSettings
|
||||
{
|
||||
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
|
||||
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds
|
||||
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
|
||||
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -73,6 +74,8 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
return key == "ServiceStatus";
|
||||
if (SettingsContext == SettingsModalContext.LogsPolling)
|
||||
return key == "Logs";
|
||||
if (SettingsContext == SettingsModalContext.LeasesPolling)
|
||||
return key == "Leases";
|
||||
if (SettingsContext == SettingsModalContext.All)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_searchTerm)) return true;
|
||||
@@ -81,6 +84,7 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
{
|
||||
"ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
"Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
"Leases" => "dhcp leases refresh polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -93,6 +97,8 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
|
||||
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300);
|
||||
_editingSettings.LeasesPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
|
||||
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
|
||||
if (_jsModule != null)
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
@inject IDhcpHostsClient DhcpHostsClient
|
||||
@inject ILeasesClient LeasesClient
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>DHCP | Local DNS</PageTitle>
|
||||
<PageTitle>DHCP | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">DHCP</h1>
|
||||
|
||||
@@ -29,6 +32,15 @@ else
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-2">Managed file: @_status.ManagedFilePath</p>
|
||||
<div class="row g-2 align-items-center mb-2">
|
||||
<div class="col-auto">
|
||||
<label for="filter-keyword" class="form-label mb-0 small text-muted">Search static hosts</label>
|
||||
</div>
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<input type="text" id="filter-keyword" class="form-control form-control-sm" placeholder="MAC, name, address, comment…" @bind="_filterKeyword" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
@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>
|
||||
@@ -42,43 +54,188 @@ else
|
||||
<div class="alert alert-success">@_message</div>
|
||||
}
|
||||
<div class="table-responsive-cards">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Valid</th><th>MAC</th><th>Name</th><th>Address</th><th>Lease</th><th>Ignore</th><th>Comment</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _entries.Where(x => !x.IsDeleted))
|
||||
{
|
||||
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
|
||||
<td>@e.LineNumber</td>
|
||||
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" /></td>
|
||||
<td><input class="form-control form-control-sm" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => SetMac(e, ev.Value?.ToString())" style="min-width:140px" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Name" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Lease" style="min-width:70px" /></td>
|
||||
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
|
||||
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
|
||||
<div class="d-none d-md-block">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>Valid</th><th>MAC</th><th>Name</th><th>Address</th><th>Lease</th><th>Ignore</th><th>Comment</th><th>Source</th><th></th>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _visibleStaticEntries)
|
||||
{
|
||||
var editable = IsEditable(e);
|
||||
<tr class="@(e.IsComment ? "table-secondary" : "") @(editable ? "" : "table-light")" @key="e.Id">
|
||||
<td>@e.LineNumber</td>
|
||||
@if (editable)
|
||||
{
|
||||
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" /></td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm" list="static-host-lease-datalist" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" style="min-width:140px" placeholder="MAC or pick from leases" />
|
||||
@if (_leases != null && _leases.Count > 0)
|
||||
{
|
||||
<datalist id="static-host-lease-datalist">
|
||||
@foreach (var l in _visibleLeases)
|
||||
{
|
||||
<option value="@l.Mac">@l.Mac @(string.IsNullOrEmpty(l.Name) ? "" : $" – {l.Name}") @(string.IsNullOrEmpty(l.Address) ? "" : $" – {l.Address}")</option>
|
||||
}
|
||||
</datalist>
|
||||
}
|
||||
</td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Name" placeholder="Name" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" placeholder="Address" /></td>
|
||||
<td>
|
||||
<input class="form-control form-control-sm" list="lease-datalist" @bind="e.Lease" @bind:event="oninput" style="min-width:7rem" placeholder="e.g. infinite or seconds" />
|
||||
<datalist id="lease-datalist">
|
||||
@foreach (var p in LeaseDatalistOptions)
|
||||
{
|
||||
<option value="@p.Value">@p.Label</option>
|
||||
}
|
||||
</datalist>
|
||||
</td>
|
||||
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" title="Add ignore flag so dnsmasq will not assign this client a lease" /></td>
|
||||
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
|
||||
<td class="text-muted small">—</td>
|
||||
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button></td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td>@(e.IsComment ? "No" : "Yes")</td>
|
||||
<td>@string.Join(", ", e.MacAddresses)</td>
|
||||
<td>@e.Name</td>
|
||||
<td>@e.Address</td>
|
||||
<td>@e.Lease</td>
|
||||
<td>@(e.Ignore ? "Yes" : "No")</td>
|
||||
<td>@e.Comment</td>
|
||||
<td><span class="text-muted small" title="@e.SourcePath">@(e.IsEditable ? "—" : (string.IsNullOrEmpty(e.SourcePath) ? "" : System.IO.Path.GetFileName(e.SourcePath)))</span></td>
|
||||
<td></td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-md-none table-cards-mobile">
|
||||
@foreach (var e in _visibleStaticEntries)
|
||||
{
|
||||
var editable = IsEditable(e);
|
||||
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "") @(editable ? "" : "border-start border-3 border-info")" @key="e.Id">
|
||||
<div class="card-body py-2">
|
||||
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
|
||||
@if (!string.IsNullOrEmpty(e.SourcePath))
|
||||
{
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">Source</span>
|
||||
<span class="text-muted small" title="@e.SourcePath">@System.IO.Path.GetFileName(e.SourcePath)</span>
|
||||
</div>
|
||||
}
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">Valid</span>
|
||||
<div class="table-card-control">
|
||||
@if (editable)
|
||||
{
|
||||
<input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@(e.IsComment ? "No" : "Yes")</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">MAC</span>
|
||||
@if (editable)
|
||||
{
|
||||
<div class="flex-grow-1" style="min-width:0">
|
||||
<input class="form-control form-control-sm" list="static-host-lease-datalist-mobile" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" placeholder="MAC or pick from leases" style="min-width:0; width:100%" />
|
||||
@if (_leases != null && _leases.Count > 0)
|
||||
{
|
||||
<datalist id="static-host-lease-datalist-mobile">
|
||||
@foreach (var l in _visibleLeases)
|
||||
{
|
||||
<option value="@l.Mac">@l.Mac @(string.IsNullOrEmpty(l.Name) ? "" : $" – {l.Name}") @(string.IsNullOrEmpty(l.Address) ? "" : $" – {l.Address}")</option>
|
||||
}
|
||||
</datalist>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@string.Join(", ", e.MacAddresses)</span>
|
||||
}
|
||||
</div>
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">Name</span>
|
||||
@if (editable) { <input class="form-control form-control-sm" @bind="e.Name" /> } else { <span>@e.Name</span> }
|
||||
</div>
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">Address</span>
|
||||
@if (editable) { <input class="form-control form-control-sm" @bind="e.Address" /> } else { <span>@e.Address</span> }
|
||||
</div>
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">Lease</span>
|
||||
@if (editable)
|
||||
{
|
||||
<input class="form-control form-control-sm" list="lease-datalist-mobile" @bind="e.Lease" @bind:event="oninput" style="min-width:0; width:100%" placeholder="e.g. infinite or seconds" />
|
||||
<datalist id="lease-datalist-mobile">
|
||||
@foreach (var p in LeaseDatalistOptions)
|
||||
{
|
||||
<option value="@p.Value">@p.Label</option>
|
||||
}
|
||||
</datalist>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@e.Lease</span>
|
||||
}
|
||||
</div>
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">Ignore</span>
|
||||
<div class="table-card-control">
|
||||
@if (editable) { <input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /> } else { <span>@(e.Ignore ? "Yes" : "No")</span> }
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-card-row">
|
||||
<span class="table-card-label">Comment</span>
|
||||
@if (editable) { <input class="form-control form-control-sm" @bind="e.Comment" /> } else { <span>@e.Comment</span> }
|
||||
</div>
|
||||
@if (editable)
|
||||
{
|
||||
<div class="table-card-row mt-2">
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="Add">Add host</button>
|
||||
<button class="btn btn-primary" @onclick="Save" disabled="@_saving">Save</button>
|
||||
@if (_status.ReloadCommandConfigured)
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving">Reload config</button>
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-primary mt-2" @onclick="Add">Add host</button>
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
|
||||
@if (_status.ReloadCommandConfigured)
|
||||
{
|
||||
<button class="btn btn-primary mt-2" @onclick="Reload" disabled="@_saving">Reload config</button>
|
||||
}
|
||||
<p class="text-muted small mt-2">Managed file: @_status.ManagedFilePath</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
@* ---- Leases (dhcp-leasefile, read-only) ---- *@
|
||||
<section class="page-section">
|
||||
<h2 class="page-section-title">Leases</h2>
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<h2 class="page-section-title mb-0">Leases</h2>
|
||||
@if (_status.LeasesPathConfigured)
|
||||
{
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Leases refresh interval settings" @onclick="OpenLeasesSettings" title="Configure leases refresh interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath))
|
||||
{
|
||||
<p class="text-muted small mb-2">Path: @_status.LeasesPath (read-only).</p>
|
||||
}
|
||||
@if (!_status.LeasesPathConfigured)
|
||||
{
|
||||
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
|
||||
@@ -94,45 +251,157 @@ else
|
||||
else if (_leases.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
|
||||
<p class="text-muted small">Path: @_status.LeasesPath</p>
|
||||
<button class="btn btn-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 align-items-center mb-2">
|
||||
<div class="col-auto">
|
||||
<label for="lease-filter-keyword" class="form-label mb-0 small text-muted">Search leases</label>
|
||||
</div>
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<input type="text" id="lease-filter-keyword" class="form-control form-control-sm" placeholder="MAC, address, name, client ID…" @bind="_leaseFilterKeyword" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive-cards">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _leases)
|
||||
{
|
||||
<tr>
|
||||
<td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
|
||||
<td>@e.Mac</td>
|
||||
<td>@e.Address</td>
|
||||
<td>@e.Name</td>
|
||||
<td>@e.ClientId</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-none d-md-block">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Expiry</th><th>MAC</th><th>Address</th><th>Name</th><th>Client ID</th><th>Control</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@{ var leaseIndex = 0; }
|
||||
@foreach (var e in _visibleLeases)
|
||||
{
|
||||
var idx = ++leaseIndex;
|
||||
<tr>
|
||||
<td>@idx</td>
|
||||
<td>@e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</td>
|
||||
<td>@e.Mac</td>
|
||||
<td>@e.Address</td>
|
||||
<td>@e.Name</td>
|
||||
<td>@e.ClientId</td>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-md-none table-cards-mobile">
|
||||
@{ leaseIndex = 0; }
|
||||
@foreach (var e in _visibleLeases)
|
||||
{
|
||||
var idx = ++leaseIndex;
|
||||
<div class="card mb-2">
|
||||
<div class="card-body py-2">
|
||||
<div class="table-card-row"><span class="table-card-label">#</span> @idx</div>
|
||||
<div class="table-card-row"><span class="table-card-label">Expiry</span> @e.Timestamp.ToString("yyyy-MM-dd HH:mm:ss")</div>
|
||||
<div class="table-card-row"><span class="table-card-label">MAC</span> @e.Mac</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">Name</span> @e.Name</div>
|
||||
<div class="table-card-row"><span class="table-card-label">Client ID</span> @e.ClientId</div>
|
||||
<div class="table-card-row mt-2 d-flex flex-wrap gap-1">
|
||||
<button type="button" class="btn btn-primary btn-sm" title="Add a new static host entry using this lease's MAC, name, and address" @onclick="() => AddStaticFromLease(e)">Add static</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" title="Add a static host with ignore flag (dnsmasq will not assign this client a lease)" @onclick="() => AddStaticFromLeaseWithIgnore(e)">Ignore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
</div>
|
||||
<p class="text-muted small">Path: @_status.LeasesPath (read-only). Cached and refreshed when the file changes.</p>
|
||||
<button class="btn btn-primary btn-sm mt-1" @onclick="LoadLeases" disabled="@_leasesLoading">Refresh</button>
|
||||
}
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
private DnsmasqServiceStatus? _status;
|
||||
private List<DhcpHostEntry> _entries = new();
|
||||
private IReadOnlyList<LeaseEntry>? _leases;
|
||||
private string _filterKeyword = "";
|
||||
private string _leaseFilterKeyword = "";
|
||||
private bool _leasesLoading;
|
||||
private string? _error;
|
||||
private Timer? _leasesRefreshTimer;
|
||||
private int _leasesRefreshIntervalSeconds = 5;
|
||||
private string? _message;
|
||||
private bool _settingsModalOpen;
|
||||
private string _settingsModalTitle = "Client settings";
|
||||
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
|
||||
private bool _saving;
|
||||
|
||||
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
|
||||
_entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword));
|
||||
|
||||
private static bool IsEditable(DhcpHostEntry e) => e.IsEditable;
|
||||
|
||||
private static bool StaticEntryMatchesKeyword(DhcpHostEntry e, string keyword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword)) return true;
|
||||
var k = keyword.Trim();
|
||||
var mac = string.Join(" ", e.MacAddresses);
|
||||
return Contains(mac, k) || Contains(e.Name, k) || Contains(e.Address, k) || Contains(e.Lease, k) || Contains(e.Comment, k);
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string keyword) =>
|
||||
!string.IsNullOrEmpty(value) && value.Contains(keyword, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private IEnumerable<LeaseEntry> _visibleLeases =>
|
||||
_leases?.Where(e => LeaseEntryMatchesKeyword(e, _leaseFilterKeyword)) ?? Array.Empty<LeaseEntry>();
|
||||
|
||||
private static bool LeaseEntryMatchesKeyword(LeaseEntry e, string keyword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword)) return true;
|
||||
var k = keyword.Trim();
|
||||
return Contains(e.Mac, k) || Contains(e.Address, k) || Contains(e.Name, k) || Contains(e.ClientId, k);
|
||||
}
|
||||
|
||||
private static string LeaseKey(LeaseEntry e) => $"{e.Mac}|{e.Address}";
|
||||
|
||||
private void OnMacInput(DhcpHostEntry e, string? value)
|
||||
{
|
||||
SetMac(e, value);
|
||||
TryFillFromLeaseByMac(e, value);
|
||||
}
|
||||
|
||||
private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || _leases == null) return;
|
||||
var mac = value.Trim();
|
||||
if (mac.Contains(',')) return;
|
||||
var lease = _visibleLeases.FirstOrDefault(l => string.Equals(l.Mac, mac, StringComparison.OrdinalIgnoreCase));
|
||||
if (lease != null)
|
||||
{
|
||||
e.Name = lease.Name ?? "";
|
||||
e.Address = lease.Address ?? "";
|
||||
e.Lease = "infinite";
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static readonly (string Value, string Label)[] LeaseDatalistOptions =
|
||||
{
|
||||
("", "(none)"),
|
||||
("infinite", "Infinite"),
|
||||
("3600", "1 hour"),
|
||||
("21600", "6 hours"),
|
||||
("43200", "12 hours"),
|
||||
("86400", "24 hours"),
|
||||
("604800", "7 days"),
|
||||
("2592000", "30 days"),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
@@ -144,10 +413,14 @@ else
|
||||
_message = null;
|
||||
try
|
||||
{
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
|
||||
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
|
||||
await LoadLeases();
|
||||
await LoadLeases(showLoading: true);
|
||||
if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null)
|
||||
StartLeasesRefreshTimer();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -155,10 +428,43 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadLeases()
|
||||
private void OpenLeasesSettings()
|
||||
{
|
||||
_settingsModalContext = SettingsModalContext.LeasesPolling;
|
||||
_settingsModalTitle = "Leases refresh interval";
|
||||
_settingsModalOpen = true;
|
||||
}
|
||||
|
||||
private async Task CloseSettingsModal()
|
||||
{
|
||||
_settingsModalOpen = false;
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
if (_status != null && _status.LeasesPathConfigured)
|
||||
StartLeasesRefreshTimer();
|
||||
}
|
||||
|
||||
private void StartLeasesRefreshTimer()
|
||||
{
|
||||
_leasesRefreshTimer?.Dispose();
|
||||
_leasesRefreshTimer = new Timer(
|
||||
_ => _ = InvokeAsync(OnLeasesRefreshTick),
|
||||
null,
|
||||
TimeSpan.FromSeconds(_leasesRefreshIntervalSeconds),
|
||||
TimeSpan.FromSeconds(_leasesRefreshIntervalSeconds));
|
||||
}
|
||||
|
||||
private async Task OnLeasesRefreshTick()
|
||||
{
|
||||
if (_status == null || !_status.LeasesPathConfigured) return;
|
||||
_leasesLoading = true;
|
||||
await LoadLeases(showLoading: false);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task LoadLeases(bool showLoading = true)
|
||||
{
|
||||
if (_status == null || !_status.LeasesPathConfigured) return;
|
||||
if (showLoading) _leasesLoading = true;
|
||||
try
|
||||
{
|
||||
var resp = await LeasesClient.GetLeasesAsync();
|
||||
@@ -166,16 +472,70 @@ else
|
||||
}
|
||||
finally
|
||||
{
|
||||
_leasesLoading = false;
|
||||
if (showLoading) _leasesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void Add()
|
||||
{
|
||||
_entries.Add(new DhcpHostEntry { Id = "new:" + Guid.NewGuid().ToString("N"), LineNumber = 0, MacAddresses = new List<string>(), Extra = new List<string>() });
|
||||
var nextAddress = GetNextAvailableAddress();
|
||||
_entries.Add(new DhcpHostEntry
|
||||
{
|
||||
Id = "new:" + Guid.NewGuid().ToString("N"),
|
||||
LineNumber = 0,
|
||||
MacAddresses = new List<string>(),
|
||||
Extra = new List<string>(),
|
||||
Name = "*",
|
||||
Address = nextAddress,
|
||||
IsEditable = true
|
||||
});
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private string? GetNextAvailableAddress()
|
||||
{
|
||||
if (_status == null || string.IsNullOrEmpty(_status.DhcpRangeStart) || string.IsNullOrEmpty(_status.DhcpRangeEnd))
|
||||
return null;
|
||||
if (!System.Net.IPAddress.TryParse(_status.DhcpRangeStart, out var startIp) || startIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
return null;
|
||||
if (!System.Net.IPAddress.TryParse(_status.DhcpRangeEnd, out var endIp) || endIp.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
return null;
|
||||
var used = new HashSet<uint>();
|
||||
foreach (var e in _entries.Where(x => !x.IsDeleted && !string.IsNullOrEmpty(x.Address)))
|
||||
{
|
||||
if (System.Net.IPAddress.TryParse(e.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
used.Add(ToUint(ip));
|
||||
}
|
||||
if (_leases != null)
|
||||
{
|
||||
foreach (var l in _leases)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(l.Address) && System.Net.IPAddress.TryParse(l.Address, out var ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
used.Add(ToUint(ip));
|
||||
}
|
||||
}
|
||||
var start = ToUint(startIp);
|
||||
var end = ToUint(endIp);
|
||||
if (start > end) return null;
|
||||
for (var u = start; u <= end; u++)
|
||||
{
|
||||
if (!used.Contains(u))
|
||||
return FromUint(u);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static uint ToUint(System.Net.IPAddress ip)
|
||||
{
|
||||
var b = ip.GetAddressBytes();
|
||||
return ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3];
|
||||
}
|
||||
|
||||
private static string FromUint(uint u)
|
||||
{
|
||||
return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}";
|
||||
}
|
||||
|
||||
private void Delete(DhcpHostEntry e)
|
||||
{
|
||||
e.IsDeleted = true;
|
||||
@@ -200,6 +560,35 @@ else
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void AddStaticFromLease(LeaseEntry lease)
|
||||
{
|
||||
AddStaticFromLeaseCore(lease, ignore: false);
|
||||
}
|
||||
|
||||
private void AddStaticFromLeaseWithIgnore(LeaseEntry lease)
|
||||
{
|
||||
AddStaticFromLeaseCore(lease, ignore: true);
|
||||
}
|
||||
|
||||
private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore)
|
||||
{
|
||||
_entries.Add(new DhcpHostEntry
|
||||
{
|
||||
Id = "new:" + Guid.NewGuid().ToString("N"),
|
||||
LineNumber = 0,
|
||||
IsComment = false,
|
||||
IsDeleted = false,
|
||||
Ignore = ignore,
|
||||
MacAddresses = string.IsNullOrEmpty(lease.Mac) ? new List<string>() : new List<string> { lease.Mac },
|
||||
Name = lease.Name ?? "",
|
||||
Address = lease.Address ?? "",
|
||||
Lease = "infinite",
|
||||
Extra = new List<string>(),
|
||||
Comment = null
|
||||
});
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_saving = true;
|
||||
@@ -248,4 +637,10 @@ else
|
||||
_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_leasesRefreshTimer?.Dispose();
|
||||
_leasesRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
@page "/Error"
|
||||
@namespace DnsmasqWebUI.Components.Pages
|
||||
@using System.Diagnostics
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
|
||||
<PageTitle>Error | Local DNS</PageTitle>
|
||||
<PageTitle>Error | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title text-danger">Error</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
@@ -29,6 +30,7 @@
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
private string? RequestId { get; set; }
|
||||
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
@inject IConfigSetClient ConfigSetClient
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Overview | Local DNS</PageTitle>
|
||||
<PageTitle>Overview | @AppTitle</PageTitle>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
@@ -156,6 +157,7 @@ else if (_status != null)
|
||||
}
|
||||
|
||||
@code {
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
private int _serviceStatusIntervalSeconds = 15;
|
||||
private int _recentLogsIntervalSeconds = 15;
|
||||
private bool _settingsModalOpen;
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
@namespace DnsmasqWebUI.Components.Pages
|
||||
@inject IStatusClient StatusClient
|
||||
@inject IHostsClient HostsClient
|
||||
|
||||
<PageTitle>Hosts | Local DNS</PageTitle>
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
<PageTitle>Hosts | @AppTitle</PageTitle>
|
||||
|
||||
<h1 class="page-title">Hosts file</h1>
|
||||
|
||||
@@ -50,27 +50,42 @@ else
|
||||
<div class="alert alert-success">@_message</div>
|
||||
}
|
||||
<div class="table-responsive-cards">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Address</th><th>Names</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in _entries.Where(x => !x.IsPassthrough))
|
||||
{
|
||||
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="e.Id">
|
||||
<td>@e.LineNumber</td>
|
||||
<td>@e.Address</td>
|
||||
<td>@string.Join(", ", e.Names)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div 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;
|
||||
|
||||
@@ -13,5 +13,7 @@
|
||||
@using DnsmasqWebUI.Components.Modals
|
||||
@using DnsmasqWebUI.Components.Home
|
||||
@using DnsmasqWebUI.Models
|
||||
@using DnsmasqWebUI.Configuration
|
||||
@using DnsmasqWebUI.Client.Http.Abstractions
|
||||
@using DnsmasqWebUI.Services.Abstractions
|
||||
@using Microsoft.Extensions.Options
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace DnsmasqWebUI.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Application-level settings (title, etc.). Bound from "Application" config section
|
||||
/// (appsettings.json or Application__* environment variables).
|
||||
/// </summary>
|
||||
public class ApplicationOptions
|
||||
{
|
||||
/// <summary>Configuration section name (e.g. "Application" for appsettings and Application__* env vars).</summary>
|
||||
public const string SectionName = "Application";
|
||||
|
||||
/// <summary>Default application title when not configured.</summary>
|
||||
public const string DefaultTitle = "Local DNS";
|
||||
|
||||
/// <summary>Application title shown in the navbar brand, page titles, and browser tab.</summary>
|
||||
public string ApplicationTitle { get; set; } = DefaultTitle;
|
||||
|
||||
/// <summary>Title to use in UI (never null/empty).</summary>
|
||||
public string EffectiveTitle => string.IsNullOrWhiteSpace(ApplicationTitle) ? DefaultTitle : ApplicationTitle;
|
||||
}
|
||||
@@ -42,6 +42,10 @@ public class DhcpController : ControllerBase
|
||||
var reload = await _reloadService.ReloadAsync(ct);
|
||||
return Ok(new SaveWithReloadResult(true, reload));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
|
||||
@@ -32,6 +32,7 @@ public class StatusController : ControllerBase
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
var effectiveLeasesPath = _configSetService.GetLeasesPath();
|
||||
var effectiveConfig = _configSetService.GetEffectiveConfig();
|
||||
var (dhcpRangeStart, dhcpRangeEnd) = _configSetService.GetDhcpRange();
|
||||
var systemHostsPath = _options.SystemHostsPath?.Trim();
|
||||
|
||||
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct);
|
||||
@@ -52,7 +53,7 @@ public class StatusController : ControllerBase
|
||||
var showResult = await showTask;
|
||||
var logsResult = await logsTask;
|
||||
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
|
||||
? showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : "")
|
||||
? FormatStatusShowOutput(showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : ""))
|
||||
: null;
|
||||
var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand)
|
||||
? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "")
|
||||
@@ -83,7 +84,9 @@ public class StatusController : ControllerBase
|
||||
StatusCommandStdout: dnsmasqStatus != "active" ? statusCommandStdout : null,
|
||||
StatusCommandStderr: dnsmasqStatus != "active" ? statusCommandStderr : null,
|
||||
StatusShowOutput: statusShowOutput,
|
||||
LogsOutput: logsOutput
|
||||
LogsOutput: logsOutput,
|
||||
DhcpRangeStart: dhcpRangeStart,
|
||||
DhcpRangeEnd: dhcpRangeEnd
|
||||
);
|
||||
return Ok(status);
|
||||
}
|
||||
@@ -93,6 +96,33 @@ public class StatusController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reformats StatusShow output: Active line gets a line break after the semicolon;
|
||||
/// continuation (e.g. "4 min ago") is indented to align with the value after "Active: ".
|
||||
/// </summary>
|
||||
private static string FormatStatusShowOutput(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return raw;
|
||||
var lines = raw.Split('\n');
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var activeIdx = line.IndexOf("Active:", StringComparison.Ordinal);
|
||||
if (activeIdx < 0) continue;
|
||||
var semiIdx = line.IndexOf(';', activeIdx);
|
||||
if (semiIdx < 0) continue;
|
||||
var afterSemi = semiIdx + 1;
|
||||
while (afterSemi < line.Length && line[afterSemi] == ' ') afterSemi++;
|
||||
var continuation = afterSemi < line.Length ? line[afterSemi..].TrimEnd() : "";
|
||||
var indentLength = activeIdx + "Active: ".Length;
|
||||
var indent = new string(' ', indentLength);
|
||||
lines[i] = continuation.Length > 0
|
||||
? line[..(semiIdx + 1)] + "\n" + indent + continuation
|
||||
: line[..(semiIdx + 1)];
|
||||
}
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
|
||||
@@ -17,4 +17,10 @@ public class DhcpHostEntry
|
||||
public string? Lease { get; set; }
|
||||
public List<string> Extra { get; set; } = new();
|
||||
public string? Comment { get; set; }
|
||||
|
||||
/// <summary>True when this entry is from the managed file (editable). False when from main config or another file (read-only). Set by server on GET; new entries created in the UI should set this to true.</summary>
|
||||
public bool IsEditable { get; set; }
|
||||
|
||||
/// <summary>Path to the config file this entry comes from. Set by server on GET; null for new entries not yet saved.</summary>
|
||||
public string? SourcePath { get; set; }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ namespace DnsmasqWebUI.Models;
|
||||
/// <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>
|
||||
/// <param name="DhcpRangeStart">Start IP of the first dhcp-range= (e.g. 172.28.0.10). Null when not set or unparseable.</param>
|
||||
/// <param name="DhcpRangeEnd">End IP of the first dhcp-range= (e.g. 172.28.0.50). Null when not set or unparseable.</param>
|
||||
public record DnsmasqServiceStatus(
|
||||
string? SystemHostsPath,
|
||||
bool SystemHostsPathExists,
|
||||
@@ -54,5 +56,7 @@ public record DnsmasqServiceStatus(
|
||||
string? StatusCommandStdout,
|
||||
string? StatusCommandStderr,
|
||||
string? StatusShowOutput,
|
||||
string? LogsOutput
|
||||
string? LogsOutput,
|
||||
string? DhcpRangeStart,
|
||||
string? DhcpRangeEnd
|
||||
);
|
||||
|
||||
@@ -5,6 +5,10 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ---- Application options (title, etc.) ----
|
||||
builder.Services.AddOptions<ApplicationOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ApplicationOptions.SectionName));
|
||||
|
||||
// ---- Dnsmasq options (required paths validated at startup) ----
|
||||
builder.Services.AddOptions<DnsmasqOptions>()
|
||||
.Bind(builder.Configuration.GetSection(DnsmasqOptions.SectionName))
|
||||
|
||||
@@ -15,4 +15,7 @@ public interface IDnsmasqConfigSetService : IApplicationSingleton
|
||||
|
||||
/// <summary>Additional hosts paths discovered from the config set (addn-hosts=; cumulative, all in order). Empty if none.</summary>
|
||||
IReadOnlyList<string> GetAddnHostsPaths();
|
||||
|
||||
/// <summary>Start and end IP of the last dhcp-range= (e.g. 172.28.0.10,172.28.0.50). Parsed from the raw value; (null, null) when not set or unparseable.</summary>
|
||||
(string? Start, string? End) GetDhcpRange();
|
||||
}
|
||||
|
||||
@@ -29,22 +29,29 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
|
||||
public async Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var path = await GetManagedFilePathAsync(ct);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
if (string.IsNullOrEmpty(set.ManagedFilePath))
|
||||
{
|
||||
_logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
|
||||
return Array.Empty<DhcpHostEntry>();
|
||||
}
|
||||
if (!File.Exists(path))
|
||||
var allEntries = new List<DhcpHostEntry>();
|
||||
foreach (var file in set.Files)
|
||||
{
|
||||
_logger.LogWarning("Managed config file not found: {Path}", path);
|
||||
return Array.Empty<DhcpHostEntry>();
|
||||
if (!File.Exists(file.Path))
|
||||
continue;
|
||||
var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct);
|
||||
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
|
||||
var entries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
|
||||
foreach (var e in entries)
|
||||
{
|
||||
e.SourcePath = file.Path;
|
||||
e.IsEditable = file.IsManaged;
|
||||
}
|
||||
allEntries.AddRange(entries);
|
||||
}
|
||||
var lines = await File.ReadAllLinesAsync(path, Encoding.UTF8, ct);
|
||||
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
|
||||
var entries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
|
||||
AssignStableIds(entries);
|
||||
return entries;
|
||||
AssignStableIds(allEntries);
|
||||
return allEntries;
|
||||
}
|
||||
|
||||
/// <summary>Assign stable Ids so we can match by Id on write (reorder-safe). Content-based; ":LineNumber" appended for uniqueness when needed.</summary>
|
||||
@@ -84,10 +91,23 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
|
||||
public async Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default)
|
||||
{
|
||||
var path = await GetManagedFilePathAsync(ct);
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
var path = set.ManagedFilePath;
|
||||
if (string.IsNullOrEmpty(path))
|
||||
throw new InvalidOperationException("No managed file path (main config has no conf-dir). Cannot write dhcp hosts.");
|
||||
|
||||
var managedEntries = entries.Where(e => e.IsEditable).ToList();
|
||||
var externalMacs = await GetMacsFromNonManagedFilesAsync(set, ct);
|
||||
foreach (var e in managedEntries.Where(e => !e.IsDeleted))
|
||||
{
|
||||
foreach (var mac in e.MacAddresses.Where(m => !string.IsNullOrWhiteSpace(m)))
|
||||
{
|
||||
var normalized = mac.Trim();
|
||||
if (externalMacs.TryGetValue(normalized, out var sourcePath))
|
||||
throw new ArgumentException($"MAC {mac} is already defined in {sourcePath}. Remove it from that file or use a different MAC.");
|
||||
}
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
@@ -103,7 +123,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
var fileEntries = configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost).ToList();
|
||||
AssignStableIds(fileEntries);
|
||||
|
||||
var byId = entries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var byId = managedEntries.Where(e => !string.IsNullOrEmpty(e.Id)).GroupBy(e => e.Id, StringComparer.Ordinal).ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var matchedIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
for (var i = 0; i < configLines.Count; i++)
|
||||
@@ -114,7 +134,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
configLines[i] = new DhcpHostLine { LineNumber = dhcpLine.LineNumber, DhcpHost = replacement };
|
||||
}
|
||||
|
||||
var appended = entries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList();
|
||||
var appended = managedEntries.Where(e => (string.IsNullOrEmpty(e.Id) || !matchedIds.Contains(e.Id)) && !e.IsDeleted).ToList();
|
||||
var output = configLines.Select(DnsmasqConfFileLineParser.ToLine).ToList();
|
||||
foreach (var entry in appended)
|
||||
output.Add(DnsmasqConfDhcpHostLineParser.ToLine(entry));
|
||||
@@ -125,6 +145,29 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
_logger.LogInformation("Wrote managed config file: {Path}", path);
|
||||
}
|
||||
|
||||
/// <summary>Returns MAC -> source file path for all dhcp-host MACs in non-managed config files (for duplicate validation).</summary>
|
||||
private static async Task<Dictionary<string, string>> GetMacsFromNonManagedFilesAsync(DnsmasqConfigSet set, CancellationToken ct)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var managedPath = set.ManagedFilePath ?? "";
|
||||
foreach (var file in set.Files.Where(f => !f.IsManaged && !string.Equals(f.Path, managedPath, StringComparison.Ordinal)))
|
||||
{
|
||||
if (!File.Exists(file.Path)) continue;
|
||||
var lines = await File.ReadAllLinesAsync(file.Path, Encoding.UTF8, ct);
|
||||
var configLines = DnsmasqConfFileLineParser.ParseFile(lines);
|
||||
foreach (var dhcp in configLines.OfType<DhcpHostLine>().Select(c => c.DhcpHost))
|
||||
{
|
||||
foreach (var mac in dhcp.MacAddresses.Where(m => !string.IsNullOrWhiteSpace(m)))
|
||||
{
|
||||
var normalized = mac.Trim();
|
||||
if (normalized.Length > 0 && !result.ContainsKey(normalized))
|
||||
result[normalized] = file.Path;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default)
|
||||
{
|
||||
var path = await GetManagedFilePathAsync(ct);
|
||||
|
||||
@@ -120,6 +120,36 @@ public class DnsmasqConfigSetService : IDnsmasqConfigSetService
|
||||
return GetEffectiveConfig().AddnHostsPaths;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public (string? Start, string? End) GetDhcpRange()
|
||||
{
|
||||
var set = GetConfigSet();
|
||||
if (set.Files.Count == 0)
|
||||
return (null, null);
|
||||
var paths = set.Files.Select(f => f.Path).ToList();
|
||||
var (raw, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, "dhcp-range");
|
||||
return ParseDhcpRangeStartEnd(raw);
|
||||
}
|
||||
|
||||
/// <summary>Parses dhcp-range value to (startIp, endIp). Format is typically start,end,mask,lease or tag:...,start,end,...; finds first two IPv4-looking tokens.</summary>
|
||||
internal static (string? Start, string? End) ParseDhcpRangeStartEnd(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return (null, null);
|
||||
var parts = raw.Split(',');
|
||||
string? start = null;
|
||||
string? end = null;
|
||||
foreach (var p in parts)
|
||||
{
|
||||
var t = p.Trim();
|
||||
if (string.IsNullOrEmpty(t)) continue;
|
||||
if (!System.Net.IPAddress.TryParse(t, out var ip) || ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
continue;
|
||||
if (start == null) { start = t; continue; }
|
||||
if (end == null) { end = t; break; }
|
||||
}
|
||||
return (start, end);
|
||||
}
|
||||
|
||||
private DnsmasqConfigSet GetConfigSet()
|
||||
{
|
||||
var mainPath = _options.MainConfigPath;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"Application": {
|
||||
"ApplicationTitle": "Local DNS"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
|
||||
@@ -162,11 +162,49 @@ h1:focus {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Table: desktop shows table, mobile shows card list (via d-none d-md-block / d-md-none in markup) */
|
||||
.table-responsive-cards {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.table-responsive-cards .d-none.d-md-block {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.table-cards-mobile .card .table-card-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.75rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.table-cards-mobile .card .table-card-row + .table-card-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.table-card-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--bs-secondary);
|
||||
min-width: 4.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-cards-mobile .table-card-row .form-control,
|
||||
.table-cards-mobile .table-card-row .form-select,
|
||||
.table-cards-mobile .table-card-row .table-card-control {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Align checkboxes with other inputs in card mode: control starts at same column */
|
||||
.table-cards-mobile .table-card-row .table-card-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Settings gear icon size (Bootstrap Icons) */
|
||||
.bi-gear-sm {
|
||||
font-size: 1.125rem;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
export function initDialog(dialogElement, dotNetRef) {
|
||||
if (!dialogElement) return;
|
||||
dialogElement.addEventListener('close', function handler() {
|
||||
dotNetRef.invokeMethodAsync('OnDialogClosed', dialogElement.returnValue ?? '');
|
||||
// Ignore errors on page refresh/unload when the Blazor circuit is already gone
|
||||
dotNetRef.invokeMethodAsync('OnDialogClosed', dialogElement.returnValue ?? '').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user