Files
dnsmasq-webui/src/DnsmasqWebUI/Components/Pages/Dhcp.razor
T
alex e7c2560d07 Release v0.0.5: option help modal, update check, effective config sections
- Config option help: modal with dnsmasq docs, label click/mouseleave
- Update check: background + manual check, nav footer tooltip with last check time
- Effective config: sectioned views (DNS, DHCP, etc.), section toggle
- Version bump to 0.0.5
2026-02-13 00:11:02 +10:00

654 lines
29 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@page "/dhcp"
@page "/leases"
@rendermode InteractiveServer
@namespace DnsmasqWebUI.Components.Pages
@inject IStatusClient StatusClient
@inject IDhcpHostsClient DhcpHostsClient
@inject ILeasesClient LeasesClient
@inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@inject ISettingsModalService SettingsModalService
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable
<PageTitle>DHCP | @AppTitle</PageTitle>
<h1 class="page-title">DHCP</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
{
@* ---- Static hosts (dhcp-host= in managed file) ---- *@
<section class="page-section">
<h2 class="page-section-title">Static hosts</h2>
@if (!_status.ManagedFilePathExists)
{
<p class="text-warning">Managed config file not found: @_status.ManagedFilePath</p>
}
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>
}
@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>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>
</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>
}
</section>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="page-section">
<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>
}
else if (!_status.LeasesPathExists)
{
<p class="text-warning">Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.</p>
}
else if (_leases == null)
{
<p class="text-warning">Could not read leases file.</p>
}
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>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: 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">
<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, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
</div>
}
</section>
<section class="page-section">
<h2 class="page-section-title">Config affecting DHCP</h2>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" />
</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 _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 void OnInitialized()
{
SettingsModalService.SettingsChanged += OnSettingsChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
await Load();
StateHasChanged();
}
private async Task Load()
{
_error = null;
_message = null;
try
{
await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
_status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
await LoadLeases(showLoading: true);
if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null)
StartLeasesRefreshTimer();
}
catch (Exception ex)
{
_error = ex.Message;
}
}
private void OpenLeasesSettings() => SettingsModalService.Open(SettingsModalContext.LeasesPolling, "Leases refresh interval");
private async void OnSettingsChanged()
{
await InvokeAsync(() =>
{
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
if (_status != null && _status.LeasesPathConfigured)
StartLeasesRefreshTimer();
StateHasChanged();
});
}
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;
await LoadLeases(showLoading: false);
await InvokeAsync(StateHasChanged);
}
private async Task LoadLeases(bool showLoading = true, bool forceRefresh = false)
{
if (_status == null || !_status.LeasesPathConfigured) return;
if (showLoading) _leasesLoading = true;
try
{
var resp = await LeasesClient.GetLeasesAsync(forceRefresh: forceRefresh);
_leases = resp.Entries;
}
finally
{
if (showLoading) _leasesLoading = false;
}
}
private void Add()
{
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;
StateHasChanged();
}
private void ToggleValid(DhcpHostEntry e)
{
e.IsComment = !e.IsComment;
StateHasChanged();
}
private void ToggleIgnore(DhcpHostEntry e)
{
e.Ignore = !e.Ignore;
StateHasChanged();
}
private void SetMac(DhcpHostEntry e, string? value)
{
e.MacAddresses = value?.Split(',').Select(s => s.Trim()).Where(s => s.Length > 0).ToList() ?? new List<string>();
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;
_error = null;
_message = null;
try
{
var result = await DhcpHostsClient.SaveDhcpHostsAsync(_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.";
await Load();
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_saving = false;
}
}
private async Task Reload()
{
_saving = true;
_error = null;
_message = null;
try
{
var result = await ReloadClient.ReloadAsync();
if (result.Success)
_message = "Dnsmasq reloaded.";
else
_error = "Reload failed: " + (result.StdErr ?? "unknown");
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_saving = false;
}
}
public void Dispose()
{
SettingsModalService.SettingsChanged -= OnSettingsChanged;
_leasesRefreshTimer?.Dispose();
_leasesRefreshTimer = null;
}
}