mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
- 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
654 lines
29 KiB
Plaintext
654 lines
29 KiB
Plaintext
@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;
|
||
}
|
||
}
|