mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-04 04:18:13 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b22e4fb7d | ||
|
|
a3cc3968af | ||
|
|
8ed66cdf48 | ||
|
|
a776f445b6 | ||
|
|
b0ce7afb58 | ||
|
|
2cfbaf000c | ||
|
|
f8754d8d6e | ||
|
|
71a8a61cb3 | ||
|
|
bf1f6a0d82 | ||
|
|
91beb9d181 | ||
|
|
4e122ba439 | ||
|
|
6a7c0f62e1 | ||
|
|
f22b039e74 | ||
|
|
906e222ec1 | ||
|
|
ecd9e5cc7b |
+5
-6
@@ -19,15 +19,14 @@ RUN dotnet build "DnsmasqWebUI.csproj" -c Release -o /app/build
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "DnsmasqWebUI.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS app
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
FROM app AS final
|
||||
# Slow, rarely-changing steps first (stay cached when only .NET code changes)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends dnsmasq procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY scripts/entrypoint.sh .
|
||||
COPY scripts/dnsmasq-status.sh .
|
||||
COPY scripts/entrypoint.sh scripts/dnsmasq-status.sh .
|
||||
RUN chmod +x entrypoint.sh dnsmasq-status.sh
|
||||
# .NET publish last - only this layer invalidates when code changes
|
||||
COPY --from=publish /app/publish .
|
||||
ENTRYPOINT ["./entrypoint.sh"]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# 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]
|
||||
# Prepare mount: ./scripts/prepare-test-mount.sh [--minimal-conf | --prepare-only | ...]
|
||||
# Or set TESTDATA_MOUNT to override the data volume (default: ./testdata-mount).
|
||||
# Set TEST_DNSMASQ_CONF to override main config path (default: /data/dnsmasq-test.conf; use /data/dnsmasq-test-minimal.conf for minimal).
|
||||
#
|
||||
# No systemd in container (dnsmasq runs as a process from entrypoint, like most dnsmasq images).
|
||||
# StatusShowCommand/LogsCommand use ps so the Service status / Recent logs sections show something
|
||||
@@ -18,9 +19,11 @@ services:
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
DNSMASQ_CONF: /data/dnsmasq-test.conf
|
||||
Dnsmasq__MainConfigPath: /data/dnsmasq-test.conf
|
||||
DNSMASQ_CONF: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__MainConfigPath: ${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}
|
||||
Dnsmasq__ManagedFileName: zz-dnsmasq-webui.conf
|
||||
# No systemd in container: restart by killing and re-running dnsmasq (same as entrypoint.sh). Config path must match DNSMASQ_CONF/MainConfigPath.
|
||||
Dnsmasq__RestartCommand: "pkill -x dnsmasq || true; sleep 1; dnsmasq --test --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf} && dnsmasq --conf-file=${TEST_DNSMASQ_CONF:-/data/dnsmasq-test.conf}"
|
||||
Dnsmasq__ReloadCommand: "pkill -HUP -x dnsmasq"
|
||||
Dnsmasq__StatusCommand: "pgrep -x dnsmasq"
|
||||
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
#!/bin/sh
|
||||
# Simulates "systemctl status dnsmasq" output when run in a container without systemd.
|
||||
# Uses ps/pgrep. Memory is RSS. Uptime line omitted; Active line has break after semicolon with continuation indented.
|
||||
# When dnsmasq is not running, runs dnsmasq --test to show config/startup errors (use DNSMASQ_CONF for config path).
|
||||
|
||||
pid=$(pgrep -x dnsmasq)
|
||||
if [ -z "$pid" ]; then
|
||||
echo "● dnsmasq.service - dnsmasq - A lightweight DHCP and caching DNS server"
|
||||
echo " Loaded: (container, no systemd)"
|
||||
echo " Active: inactive (dead)"
|
||||
conf="${DNSMASQ_CONF:-/etc/dnsmasq.conf}"
|
||||
if [ -f "$conf" ]; then
|
||||
err=$(dnsmasq --test --conf-file="$conf" 2>&1)
|
||||
if [ -n "$err" ]; then
|
||||
echo ""
|
||||
echo " Config test (dnsmasq --test):"
|
||||
echo "$err" | sed 's/^/ /'
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -50,7 +50,7 @@ except ImportError:
|
||||
OUT_DIR = "/out"
|
||||
URL = os.environ.get("URL", "https://thekelleys.org.uk/dnsmasq/docs/dnsmasq-man.html")
|
||||
|
||||
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections. server + local = one UI row.
|
||||
# One per line; keep in sync with DnsmasqOptionTooltips / EffectiveConfigSections.
|
||||
OPTION_KEYS = [
|
||||
"no-hosts",
|
||||
"addn-hosts",
|
||||
|
||||
@@ -21,6 +21,7 @@ RECREATE=false
|
||||
CLEAR=false
|
||||
STOP=false
|
||||
TIDY=false
|
||||
MINIMAL_CONF=false
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS] [--]"
|
||||
@@ -43,6 +44,8 @@ usage() {
|
||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
||||
echo ""
|
||||
echo "Compose behaviour:"
|
||||
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
|
||||
echo " config has few readonly options; good for testing reload/restart failure flows."
|
||||
echo " --prepare-only Only prepare the mount; do not run docker compose."
|
||||
echo " Use to inspect or edit the mount before starting containers."
|
||||
echo " --build Pass --build to docker compose (rebuild images before starting)."
|
||||
@@ -85,6 +88,9 @@ usage() {
|
||||
echo ""
|
||||
echo " $0 --tidy"
|
||||
echo " Stop harness and clear testdata-mount for a clean next run."
|
||||
echo ""
|
||||
echo " $0 --minimal-conf"
|
||||
echo " Use dnsmasq-test-minimal.conf (single file, few options) and start."
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -125,6 +131,10 @@ while [ $# -gt 0 ]; do
|
||||
RECREATE=true
|
||||
shift
|
||||
;;
|
||||
--minimal-conf)
|
||||
MINIMAL_CONF=true
|
||||
shift
|
||||
;;
|
||||
--stop)
|
||||
STOP=true
|
||||
shift
|
||||
@@ -210,7 +220,11 @@ if [ "$PREPARE_ONLY" = true ]; then
|
||||
/*) MOUNT_EXPORT="$MOUNT_DIR" ;;
|
||||
*) MOUNT_EXPORT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
echo "To start the harness: TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
START_CMD="TESTDATA_MOUNT=$MOUNT_EXPORT docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
START_CMD="TESTDATA_MOUNT=$MOUNT_EXPORT TEST_DNSMASQ_CONF=/data/dnsmasq-test-minimal.conf docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||
fi
|
||||
echo "To start the harness: $START_CMD"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -220,6 +234,11 @@ case "$MOUNT_DIR" in
|
||||
*) export TESTDATA_MOUNT="./$MOUNT_DIR" ;;
|
||||
esac
|
||||
|
||||
if [ "$MINIMAL_CONF" = true ]; then
|
||||
export TEST_DNSMASQ_CONF="/data/dnsmasq-test-minimal.conf"
|
||||
echo "Using minimal config: $TEST_DNSMASQ_CONF"
|
||||
fi
|
||||
|
||||
COMPOSE_CMD="docker compose -f $COMPOSE_FILE up -d"
|
||||
if [ "$BUILD" = true ]; then
|
||||
COMPOSE_CMD="$COMPOSE_CMD --build"
|
||||
|
||||
@@ -26,7 +26,7 @@ public class DnsmasqConfIncludeParserHostsAndLeaseTests
|
||||
var mainPath = TestDataHelper.GetPath("dnsmasq-test.conf");
|
||||
Assert.True(File.Exists(mainPath), "Testdata dnsmasq-test.conf required");
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var addnHosts = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
|
||||
var addresses = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
|
||||
var listenAddrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ListenAddress);
|
||||
|
||||
@@ -45,7 +45,8 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
||||
{
|
||||
var mainPath = GetOfficialExamplePath();
|
||||
var paths = new[] { mainPath };
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.ServerLocalKeys);
|
||||
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
|
||||
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
|
||||
var dhcpOptionForce = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpOptionForce);
|
||||
var ipset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Ipset);
|
||||
var nftset = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Nftset);
|
||||
@@ -53,6 +54,7 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
|
||||
var dhcpNameMatch = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpNameMatch);
|
||||
var dhcpIgnoreNames = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.DhcpIgnoreNames);
|
||||
Assert.Empty(servers);
|
||||
Assert.Empty(locals);
|
||||
Assert.Empty(dhcpOptionForce);
|
||||
Assert.Empty(ipset);
|
||||
Assert.Empty(nftset);
|
||||
|
||||
@@ -23,11 +23,13 @@ public class DnsmasqConfIncludeParserServerAndResolvTests
|
||||
{
|
||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||
File.WriteAllText(f2, $"server={v3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
Assert.Equal(v3, result[2]);
|
||||
var resultServer = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Server);
|
||||
var resultLocal = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.Local);
|
||||
Assert.Equal(2, resultServer.Count);
|
||||
Assert.Equal(v1, resultServer[0]);
|
||||
Assert.Equal(v3, resultServer[1]);
|
||||
Assert.Single(resultLocal);
|
||||
Assert.Equal(v2, resultLocal[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -25,7 +25,8 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Empty(config.AddnHostsPaths);
|
||||
Assert.NotNull(sources);
|
||||
Assert.Empty(sources.AddnHostsPaths);
|
||||
Assert.Empty(sources.ServerLocalValues);
|
||||
Assert.Empty(sources.ServerValues);
|
||||
Assert.Empty(sources.LocalValues);
|
||||
Assert.Empty(sources.DhcpRanges);
|
||||
Assert.Null(sources.NoHosts);
|
||||
Assert.Null(sources.Port);
|
||||
@@ -59,7 +60,8 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Equal(cacheSize, config.CacheSize);
|
||||
Assert.Single(config.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
|
||||
Assert.Empty(config.ServerLocalValues);
|
||||
Assert.Empty(config.ServerValues);
|
||||
Assert.Empty(config.LocalValues);
|
||||
Assert.Empty(config.DhcpRanges);
|
||||
|
||||
Assert.NotNull(sources);
|
||||
|
||||
@@ -12,7 +12,8 @@ public class EffectiveDnsmasqConfigTests
|
||||
NoHosts: false,
|
||||
AddnHostsPaths: Array.Empty<string>(),
|
||||
HostsdirPath: null,
|
||||
ServerLocalValues: Array.Empty<string>(),
|
||||
ServerValues: Array.Empty<string>(),
|
||||
LocalValues: Array.Empty<string>(),
|
||||
RevServerValues: Array.Empty<string>(),
|
||||
AddressValues: Array.Empty<string>(),
|
||||
Interfaces: Array.Empty<string>(),
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<body>
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="js/ec-option-help.js"></script>
|
||||
<script src="js/copy-to-clipboard.js"></script>
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
|
||||
@inject ILoggingClient LoggingClient
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
|
||||
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)"><i class="bi bi-plus me-1" aria-hidden="true"></i>Add</button>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
|
||||
@foreach (var prefix in _prefixes)
|
||||
@@ -35,10 +35,10 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore defaults</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@if (_saving) { <span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span> } else { <i class="bi bi-floppy-fill me-1" aria-hidden="true"></i> }@(_saving ? "Saving…" : "Save")</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+7
-10
@@ -1,9 +1,10 @@
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
|
||||
|
||||
public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
@@ -53,7 +54,7 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
@@ -66,17 +67,17 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
if (!_dialogInitialized)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef);
|
||||
_dialogInitialized = true;
|
||||
}
|
||||
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -145,10 +146,6 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_dotNetRef?.Dispose();
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs
|
||||
@* App logs — live view via SignalR from ILogger *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
+11
-22
@@ -1,10 +1,11 @@
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.AppLogs;
|
||||
|
||||
/// <summary>
|
||||
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
|
||||
@@ -137,19 +138,13 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsyncSafe("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
@@ -183,13 +178,7 @@ public partial class AppLogsSection : IAsyncDisposable
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_logsJs != null)
|
||||
{
|
||||
try { await _logsJs.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync failed"); }
|
||||
_logsJs = null;
|
||||
}
|
||||
await _logsJs.DisposeAsyncSafe();
|
||||
_logsJs = null;
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
|
||||
@* Reusable value-only display for boolean/flag options. Edit mode: checkbox (unless readonly); View: "Enabled" / "Disabled". *@
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var isSet = Descriptor.GetValue() is bool b && b;
|
||||
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(Descriptor.GetValue() is bool isSet && isSet ? "Enabled" : "Disabled")
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public EventCallback<bool> CheckedChanged { get; set; }
|
||||
|
||||
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||
{
|
||||
await CheckedChanged.InvokeAsync(e.Value is true);
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
|
||||
@* Value-only display for the port option. Edit mode: number input (unless readonly); View: text with "(default DNS port)". *@
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var port = Descriptor.GetValue() is int p ? p : 53;
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(Descriptor.GetValue() is int port ? $"{port} (default DNS port)" : "(not set)")
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public EventCallback<int?> PortChanged { get; set; }
|
||||
|
||||
private async Task OnPortInput(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
|
||||
await PortChanged.InvokeAsync(port);
|
||||
else
|
||||
await PortChanged.InvokeAsync(null);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
|
||||
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. *@
|
||||
@if (_customFragment is { } fragment)
|
||||
{
|
||||
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<span id="@_anchorId" class="ec-field-label @(_optionHelpKey != null ? "ec-field-label-help" : null)" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Descriptor.OptionName:</strong></span>
|
||||
<span class="ec-field-value">
|
||||
@fragment(Descriptor)
|
||||
@if (Descriptor.GetSource()?.IsReadOnly == true)
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline">readonly</span>
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
var items = Descriptor.GetItems();
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = Descriptor.GetValue();
|
||||
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId" OnOptionHelpRequested="@OnOptionHelpRequested" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
|
||||
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
|
||||
private string? _labelTooltip;
|
||||
private string? _optionHelpKey;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
_optionHelpKey = DnsmasqOptionTooltips.GetOptionHelpKey(Descriptor.OptionName);
|
||||
}
|
||||
|
||||
private async Task RaiseOptionHelpRequested()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_optionHelpKey)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = _optionHelpKey, OptionLabel = Descriptor.OptionName, AnchorId = _anchorId });
|
||||
}
|
||||
|
||||
private async Task RaiseLabelMouseLeave() => await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = _anchorId, IsLabelMouseLeave = true });
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
|
||||
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
|
||||
@Value
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline">readonly</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li class="ec-field">
|
||||
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@LabelTooltip" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit)
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi">
|
||||
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi">
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
<ol class="ec-field-value-list">
|
||||
@foreach (var item in Items!)
|
||||
{
|
||||
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" /></li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
[Parameter] public string? OptionHelpKey { get; set; }
|
||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
|
||||
private string _editLines = "";
|
||||
|
||||
private async Task RaiseOptionHelpRequested()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
|
||||
}
|
||||
|
||||
private async Task RaiseLabelMouseLeave()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Items != null && Items.Count > 0)
|
||||
_editLines = string.Join("\n", Items.Select(i => i.Value));
|
||||
else
|
||||
_editLines = "";
|
||||
}
|
||||
|
||||
private async Task OnLinesInput(ChangeEventArgs e)
|
||||
{
|
||||
var text = e.Value?.ToString() ?? "";
|
||||
_editLines = text;
|
||||
var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
|
||||
await ItemsChanged.InvokeAsync(lines);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
|
||||
<span id="@OptionHelpAnchorId" class="ec-field-label @(OptionHelpKey != null ? "ec-field-label-help" : null)" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())" @onclick="RaiseOptionHelpRequested" @onmouseleave="RaiseLabelMouseLeave"><strong>@Label:</strong></span>
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true)
|
||||
{
|
||||
<span class="ec-field-value">
|
||||
<input type="text" class="form-control form-control-sm" value="@DisplayValue" @oninput="OnValueInput" />
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value">
|
||||
@DisplayValue
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
<span class="badge bg-secondary ec-badge-inline">readonly</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public string DisplayValue { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
[Parameter] public string? OptionHelpKey { get; set; }
|
||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
||||
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
|
||||
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
|
||||
private async Task RaiseOptionHelpRequested()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpKey) || string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { HelpKey = OptionHelpKey, OptionLabel = Label, AnchorId = OptionHelpAnchorId });
|
||||
}
|
||||
|
||||
private async Task RaiseLabelMouseLeave()
|
||||
{
|
||||
if (string.IsNullOrEmpty(OptionHelpAnchorId)) return;
|
||||
await OnOptionHelpRequested.InvokeAsync(new ConfigOptionHelpRequestEventArgs { AnchorId = OptionHelpAnchorId, IsLabelMouseLeave = true });
|
||||
}
|
||||
|
||||
private async Task OnValueInput(ChangeEventArgs e)
|
||||
{
|
||||
var value = e.Value?.ToString() ?? "";
|
||||
await DisplayValueChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (Status != null)
|
||||
{
|
||||
<div class="ec-container">
|
||||
<ConfigOptionHelpModal HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Visible="@_showOptionHelp" Title="@_optionHelpTitle"
|
||||
OnClose="CloseOptionHelp" OnMouseEnterModal="CancelCloseTimerAsync" OnMouseLeaveModal="ScheduleCloseAsync" />
|
||||
@if (ShowSearchBox)
|
||||
{
|
||||
<div class="ec-toolbar">
|
||||
<input type="search" id="ec-search-input" class="form-control form-control-sm" placeholder="Search..." autocomplete="off"
|
||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="StateHasChanged" />
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CollapseAll" title="Collapse all sections" aria-label="Collapse all sections">
|
||||
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="ec-sections small">
|
||||
@{
|
||||
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
||||
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views);
|
||||
var hasSearch = !string.IsNullOrWhiteSpace(_searchTerm);
|
||||
var term = _searchTerm?.Trim() ?? "";
|
||||
}
|
||||
@foreach (var view in views)
|
||||
{
|
||||
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
|
||||
continue;
|
||||
var sectionDescriptors = hasSearch
|
||||
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
|
||||
: contextDescriptors.ToList();
|
||||
if (sectionDescriptors.Count == 0)
|
||||
continue;
|
||||
var isOpen = hasSearch ? true : IsOpen(view.SectionId);
|
||||
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" OnOptionHelpRequested="@HandleOptionHelpRequested" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
||||
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
||||
/// <summary>View = read-only; Edit = editable (future). Cascaded to field displays.</summary>
|
||||
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
|
||||
private string _searchTerm = "";
|
||||
private HashSet<string> _openPanels = new();
|
||||
private string? _optionHelpKey;
|
||||
private string? _optionHelpAnchorId;
|
||||
private string _optionHelpTitle = "Option help";
|
||||
private bool _showOptionHelp;
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private bool IsOpen(string id) => _openPanels.Contains(id);
|
||||
|
||||
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
|
||||
{
|
||||
if (args.IsLabelMouseLeave)
|
||||
{
|
||||
if (args.AnchorId == _optionHelpAnchorId)
|
||||
ScheduleClose();
|
||||
return;
|
||||
}
|
||||
_optionHelpKey = args.HelpKey;
|
||||
_optionHelpTitle = args.OptionLabel;
|
||||
_optionHelpAnchorId = args.AnchorId;
|
||||
_showOptionHelp = true;
|
||||
CancelCloseTimer();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void CancelCloseTimer()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
private Task CancelCloseTimerAsync() { CancelCloseTimer(); return Task.CompletedTask; }
|
||||
|
||||
private void ScheduleClose()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = new CancellationTokenSource();
|
||||
var token = _closeCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(300, token);
|
||||
}
|
||||
catch (OperationCanceledException) { return; }
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_showOptionHelp = false;
|
||||
_closeCts = null;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private Task ScheduleCloseAsync() { ScheduleClose(); return Task.CompletedTask; }
|
||||
|
||||
private void CloseOptionHelp()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
_showOptionHelp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnSectionToggle(string id)
|
||||
{
|
||||
if (_openPanels.Contains(id))
|
||||
_openPanels.Remove(id);
|
||||
else
|
||||
_openPanels.Add(id);
|
||||
}
|
||||
|
||||
private void CollapseAll()
|
||||
{
|
||||
_openPanels.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
|
||||
{
|
||||
if (string.IsNullOrEmpty(term)) return true;
|
||||
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
|
||||
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts?.Dispose();
|
||||
_closeCts = null;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.Logs
|
||||
@* Renders a pre element for logs. Once Freeze is true, stops re-rendering so Blazor does not overwrite JS-owned content. *@
|
||||
<pre id="@Id" class="pre-block mb-0" style="max-height: 24rem;">@Placeholder</pre>
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.Logs
|
||||
@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@@ -27,14 +27,14 @@
|
||||
<LogsPreView Id="dnsmasq-logs-pre" Placeholder="@_initialPlaceholder" Freeze="_logsContentReceived" />
|
||||
}
|
||||
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
@if (_status.LogsPath != null)
|
||||
{
|
||||
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
|
||||
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm"><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download.">Download logs</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" disabled title="No log file path detected. Set log-facility=/path/to/file in your dnsmasq config to enable download."><i class="bi bi-download me-1" aria-hidden="true"></i>Download logs</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
+11
-22
@@ -1,10 +1,11 @@
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
|
||||
@@ -123,19 +124,13 @@ public partial class LogsSection : IAsyncDisposable
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsyncSafe("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsyncSafe("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
_logsContentReceived = true;
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
@@ -169,13 +164,7 @@ public partial class LogsSection : IAsyncDisposable
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_logsJs != null)
|
||||
{
|
||||
try { await _logsJs.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); }
|
||||
_logsJs = null;
|
||||
}
|
||||
await _logsJs.DisposeAsyncSafe();
|
||||
_logsJs = null;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.Status
|
||||
@* Service status — output of StatusShowCommand; only this component re-renders on its timer *@
|
||||
@if (_status != null && _status.StatusShowConfigured)
|
||||
{
|
||||
@@ -25,6 +25,6 @@
|
||||
<pre class="pre-block mb-0" style="max-height: 22rem;">@_status.StatusShowOutput</pre>
|
||||
}
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
</div>
|
||||
}
|
||||
+1
-1
@@ -2,7 +2,7 @@ using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq.Status;
|
||||
|
||||
/// <summary>
|
||||
/// Service status block: polls StatusShowCommand output at its own interval and re-renders only itself.
|
||||
@@ -0,0 +1,27 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="d-flex align-items-center gap-1 @CssClass">
|
||||
<code class="flex-grow-1 text-break @CodeCssClass">@Text</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0" @onclick="CopyAsync" title="@CopyTitle"><i class="bi bi-clipboard me-1" aria-hidden="true"></i>Copy</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Text { get; set; } = "";
|
||||
[Parameter] public string? CssClass { get; set; }
|
||||
[Parameter] public string? CodeCssClass { get; set; }
|
||||
[Parameter] public string CopyTitle { get; set; } = "Copy";
|
||||
|
||||
private async Task CopyAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Text)) return;
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Clipboard not available or failed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Components.EffectiveConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Base for effective-config custom value displays. Provides shared parameters,
|
||||
/// <see cref="NotifyValueChanged"/>, and <see cref="GetEffectiveDisplayValue"/> so pending changes are shown before save.
|
||||
/// </summary>
|
||||
public abstract class EffectiveConfigValueDisplayBase : ComponentBase
|
||||
{
|
||||
[Parameter]
|
||||
public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
|
||||
[CascadingParameter]
|
||||
public bool IsActiveEditor { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public EventCallback<FocusEventArgs> OnCommitRequested { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<object?> ValueChanged { get; set; }
|
||||
|
||||
[CascadingParameter]
|
||||
public IEffectiveConfigEditSession? Session { get; set; }
|
||||
|
||||
protected Task NotifyValueChanged(object? value) => ValueChanged.InvokeAsync(value);
|
||||
|
||||
/// <summary>Returns the value to display: pending change NewValue if this field has one, else descriptor value (not yet written to disk).</summary>
|
||||
protected object? GetEffectiveDisplayValue()
|
||||
{
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
return pending != null ? pending.NewValue : Descriptor.GetValue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
|
||||
<div class="ec-sections small">
|
||||
@{
|
||||
var views = EffectiveConfigViews.GetViewsForContext(Context);
|
||||
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
|
||||
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
|
||||
var term = SearchTerm?.Trim() ?? "";
|
||||
}
|
||||
@foreach (var view in views)
|
||||
{
|
||||
if (!descriptorsBySection.TryGetValue(view.SectionId, out var contextDescriptors))
|
||||
continue;
|
||||
var sectionDescriptors = hasSearch
|
||||
? contextDescriptors.Where(d => DescriptorMatchesSearch(d, term)).ToList()
|
||||
: contextDescriptors.ToList();
|
||||
if (sectionDescriptors.Count == 0)
|
||||
continue;
|
||||
var isOpen = hasSearch || IsSectionOpen(view.SectionId);
|
||||
<EffectiveConfigSectionPanel SectionId="@view.SectionId" Title="@view.Title" Descriptors="@sectionDescriptors" IsOpen="@isOpen" OnToggle="@OnSectionToggle"
|
||||
DisplayMode="@( IsEditMode ? EffectiveConfigDisplayMode.Edit : EffectiveConfigDisplayMode.View )"
|
||||
IsEditMode="@IsEditMode" ActiveFieldKey="@ActiveFieldKey" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
||||
[Parameter] public string SearchTerm { get; set; } = "";
|
||||
[Parameter] public HashSet<string> OpenSectionIds { get; set; } = new();
|
||||
[Parameter] public EventCallback<string> OnSectionToggle { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ActiveFieldKey { get; set; }
|
||||
|
||||
private bool IsSectionOpen(string id) => OpenSectionIds.Contains(id);
|
||||
|
||||
private static bool DescriptorMatchesSearch(EffectiveConfigFieldDescriptor d, string term)
|
||||
{
|
||||
if (string.IsNullOrEmpty(term)) return true;
|
||||
var searchable = (d.OptionName + " " + (DnsmasqOptionTooltips.Get(d.OptionName) ?? "")).Trim();
|
||||
return searchable.Contains(term, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@inject IEffectiveConfigEditSession Session
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
@if (Status != null)
|
||||
{
|
||||
<div class="ec-container" @onclick="DeactivateActiveFieldIfClicked">
|
||||
<OptionHelpController @ref="_optionHelpControllerRef" Visible="@_showOptionHelp" HelpKey="@_optionHelpKey" AnchorId="@_optionHelpAnchorId" Title="@_optionHelpTitle" OnCloseRequested="CloseOptionHelp" />
|
||||
<ReadonlyPopoverController @ref="_readonlyPopoverControllerRef" Visible="@_showReadonlyPopover" AnchorId="@_readonlyPopoverAnchorId" Source="@_readonlyPopoverSource" OptionName="@(_readonlyPopoverOptionName ?? "")" Value="@_readonlyPopoverValue" ManagedFilePath="@(Status?.ManagedFilePath)" OnCloseRequested="CloseReadonlyPopover" />
|
||||
@{
|
||||
var _sectionViews = EffectiveConfigViews.GetViewsForContext(Context);
|
||||
var _allSectionsExpanded = _sectionViews.Count > 0 && _sectionViews.All(v => _openPanels.Contains(v.SectionId));
|
||||
}
|
||||
<EffectiveConfigToolbar ShowSearchBox="@ShowSearchBox" SearchTerm="@_searchTerm" SearchTermChanged="@OnSearchTermChanged"
|
||||
IsEditMode="@Session.IsEditMode" PendingChangesCount="@Session.PendingChanges.Count"
|
||||
AllSectionsExpanded="@_allSectionsExpanded"
|
||||
OnExpandAll="ExpandAll" OnCollapseAll="CollapseAll" OnEnterEditMode="EnterEditMode" OnExitEditModeWithConfirm="ExitEditModeWithConfirmAsync" OnOpenSaveModal="OpenSaveModal" />
|
||||
<CascadingValue Value="@Session">
|
||||
<CascadingValue Value="@_uiContext">
|
||||
<EffectiveConfigPanels Status="@Status" Context="@Context" SearchTerm="@_searchTerm" OpenSectionIds="@_openPanels"
|
||||
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.IsEditMode" ActiveFieldKey="@Session.ActiveFieldKey" />
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
|
||||
OnClose="CloseSaveModal" OnSaveCompleted="OnSaveCompleted" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EffectiveConfigContext Context { get; set; } = EffectiveConfigContext.All;
|
||||
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
|
||||
private string _searchTerm = "";
|
||||
private HashSet<string> _openPanels = new();
|
||||
private bool _showSaveModal;
|
||||
|
||||
private string? _optionHelpKey;
|
||||
private string? _optionHelpAnchorId;
|
||||
private string _optionHelpTitle = "Option help";
|
||||
private bool _showOptionHelp;
|
||||
private OptionHelpController? _optionHelpControllerRef;
|
||||
|
||||
private bool _showReadonlyPopover;
|
||||
private ReadonlyPopoverController? _readonlyPopoverControllerRef;
|
||||
private string? _readonlyPopoverAnchorId;
|
||||
private ConfigValueSource? _readonlyPopoverSource;
|
||||
private string? _readonlyPopoverOptionName;
|
||||
private object? _readonlyPopoverValue;
|
||||
|
||||
private EffectiveConfigUiContext? _uiContext;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_uiContext = new EffectiveConfigUiContext
|
||||
{
|
||||
RequestOptionHelpAsync = HandleOptionHelpRequested,
|
||||
RequestReadonlyPopoverAsync = HandleReadonlyBadgeClicked,
|
||||
ScheduleReadonlyPopoverCloseAsync = () =>
|
||||
{
|
||||
_readonlyPopoverControllerRef?.ScheduleCloseFromBadgeLeave();
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
|
||||
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
|
||||
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; }
|
||||
};
|
||||
}
|
||||
|
||||
private async Task OnSearchTermChanged(string value)
|
||||
{
|
||||
_searchTerm = value ?? "";
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void CloseOptionHelp()
|
||||
{
|
||||
_showOptionHelp = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleOptionHelpRequested(ConfigOptionHelpRequestEventArgs args)
|
||||
{
|
||||
if (args.IsLabelMouseLeave)
|
||||
{
|
||||
if (args.AnchorId == _optionHelpAnchorId)
|
||||
_optionHelpControllerRef?.ScheduleCloseFromLabelLeave();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
_optionHelpControllerRef?.CancelCloseFromLabelEnter();
|
||||
_optionHelpKey = args.HelpKey;
|
||||
_optionHelpTitle = args.OptionLabel;
|
||||
_optionHelpAnchorId = args.AnchorId;
|
||||
_showOptionHelp = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void CloseReadonlyPopover()
|
||||
{
|
||||
_showReadonlyPopover = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleReadonlyBadgeClicked(ReadonlyBadgeClickedEventArgs args)
|
||||
{
|
||||
_readonlyPopoverControllerRef?.CancelCloseFromBadgeEnter();
|
||||
_readonlyPopoverAnchorId = args.AnchorId;
|
||||
_readonlyPopoverSource = args.Source;
|
||||
_readonlyPopoverOptionName = args.OptionName;
|
||||
_readonlyPopoverValue = args.Value;
|
||||
_showReadonlyPopover = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnSectionToggle(string id)
|
||||
{
|
||||
if (_openPanels.Contains(id))
|
||||
_openPanels.Remove(id);
|
||||
else
|
||||
_openPanels.Add(id);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ExpandAll()
|
||||
{
|
||||
foreach (var view in EffectiveConfigViews.GetViewsForContext(Context))
|
||||
_openPanels.Add(view.SectionId);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CollapseAll()
|
||||
{
|
||||
_openPanels.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void EnterEditMode()
|
||||
{
|
||||
Session.EnterEditMode();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ExitEditModeWithConfirmAsync()
|
||||
{
|
||||
if (Session.PendingChanges.Count > 0)
|
||||
{
|
||||
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
try
|
||||
{
|
||||
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
|
||||
if (!confirmed) return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
Session.ExitEditModeDiscard();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void DeactivateActiveFieldIfClicked()
|
||||
{
|
||||
if (Session.IsEditMode && Session.ActiveFieldKey != null)
|
||||
{
|
||||
Session.DeactivateField();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void ActivateField(string fieldKey)
|
||||
{
|
||||
Session.ActivateField(fieldKey);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
|
||||
{
|
||||
Session.TrackCommit(args);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OpenSaveModal()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
_showSaveModal = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CloseSaveModal()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CloseSaveModalAndEndEdit()
|
||||
{
|
||||
_showSaveModal = false;
|
||||
Session.ExitEditModeDiscard();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
+37
-12
@@ -13,16 +13,29 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ec-toolbar {
|
||||
/* Toolbar: single row, search left and actions right. Use ::deep so styles apply to EffectiveConfigToolbar (child) markup. */
|
||||
::deep .ec-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ec-toolbar input[type="search"] {
|
||||
flex: 1;
|
||||
::deep .ec-toolbar-search {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
::deep .ec-toolbar-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ec-sections {
|
||||
@@ -156,10 +169,28 @@
|
||||
}
|
||||
|
||||
::deep .ec-field-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Edit wrapper (checkbox/input) shrinks to content so the editable badge stays on the same line */
|
||||
::deep .ec-field-edit-wrapper {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Input/select in value area can shrink so the editable badge stays on the same line */
|
||||
::deep .ec-field-value > .form-control,
|
||||
::deep .ec-field-value > .form-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Multi-value: full width below label, indented ordered list. min-width: 0 so flex doesn't prevent wrapping. */
|
||||
@@ -201,20 +232,14 @@
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Badge: vertically centered, inline with value so when it wraps it stays part of the value flow */
|
||||
::deep .ec-field .badge,
|
||||
::deep .ec-field-value-item .badge {
|
||||
/* StatusBadge inside field rows: alignment; spacing comes from StatusBadge .status-badge-inline */
|
||||
::deep .ec-field .status-badge,
|
||||
::deep .ec-field-value-item .status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Inline badge (inside value): gap from text; when it wraps to own line the margin gives a subtle indent */
|
||||
::deep .ec-badge-inline {
|
||||
margin-left: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
::deep .ec-section-header {
|
||||
padding-left: 0.75rem;
|
||||
+7
-5
@@ -1,4 +1,4 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
<ul class="list-unstyled mb-0">
|
||||
@foreach (var f in Descriptors)
|
||||
{
|
||||
<EffectiveConfigFieldDisplay Descriptor="@f" OnOptionHelpRequested="@OnOptionHelpRequested" />
|
||||
var fieldKey = $"{SectionId}:{f.OptionName}";
|
||||
<EffectiveConfigFieldDisplay Descriptor="@f" FieldKey="@fieldKey"
|
||||
IsEditMode="@IsEditMode" IsActive="@(ActiveFieldKey == fieldKey)" />
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -27,10 +29,10 @@
|
||||
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
|
||||
[Parameter] public bool IsOpen { get; set; }
|
||||
[Parameter] public EventCallback<string> OnToggle { get; set; }
|
||||
/// <summary>Raised when user requests option help (click or mouse leave from label). Passed through from Section.</summary>
|
||||
[Parameter] public EventCallback<ConfigOptionHelpRequestEventArgs> OnOptionHelpRequested { get; set; }
|
||||
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
|
||||
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable.</summary>
|
||||
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ActiveFieldKey { get; set; }
|
||||
|
||||
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
|
||||
<div class="ec-toolbar @(IsEditMode ? "ec-edit-toolbar" : "")">
|
||||
@if (ShowSearchBox)
|
||||
{
|
||||
<input type="search" id="ec-search-input" class="form-control form-control-sm ec-toolbar-search" placeholder="Search..." autocomplete="off"
|
||||
value="@SearchTerm" @oninput="OnSearchInput" />
|
||||
}
|
||||
<div class="ec-toolbar-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnToggleExpandCollapse"
|
||||
title="@(AllSectionsExpanded ? "Collapse all sections" : "Expand all sections")"
|
||||
aria-label="@(AllSectionsExpanded ? "Collapse all sections" : "Expand all sections")">
|
||||
<i class="bi @(AllSectionsExpanded ? "bi-arrows-collapse" : "bi-arrows-expand")" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (IsEditMode)
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-primary" @onclick="OnOpenSaveModal" disabled="@(PendingChangesCount == 0)" title="Save changes to managed config" aria-label="Save">
|
||||
<i class="bi bi-floppy-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnExitEditModeWithConfirm" title="Exit edit mode" aria-label="Exit edit mode">
|
||||
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
|
||||
</button>
|
||||
@if (PendingChangesCount > 0)
|
||||
{
|
||||
<span class="text-muted small">@PendingChangesCount change(s)</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="OnEnterEditMode" title="Edit effective config" aria-label="Edit">
|
||||
<i class="bi bi-pencil" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public bool ShowSearchBox { get; set; } = true;
|
||||
[Parameter] public string SearchTerm { get; set; } = "";
|
||||
[Parameter] public EventCallback<string> SearchTermChanged { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public int PendingChangesCount { get; set; }
|
||||
[Parameter] public bool AllSectionsExpanded { get; set; }
|
||||
[Parameter] public EventCallback OnExpandAll { get; set; }
|
||||
[Parameter] public EventCallback OnCollapseAll { get; set; }
|
||||
|
||||
private async Task OnToggleExpandCollapse()
|
||||
{
|
||||
if (AllSectionsExpanded)
|
||||
await OnCollapseAll.InvokeAsync();
|
||||
else
|
||||
await OnExpandAll.InvokeAsync();
|
||||
}
|
||||
[Parameter] public EventCallback OnEnterEditMode { get; set; }
|
||||
[Parameter] public EventCallback OnExitEditModeWithConfirm { get; set; }
|
||||
[Parameter] public EventCallback OnOpenSaveModal { get; set; }
|
||||
|
||||
private async Task OnSearchInput(ChangeEventArgs e)
|
||||
{
|
||||
await SearchTermChanged.InvokeAsync(e.Value?.ToString() ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Reusable value-only display for boolean/flag options. When active editor: checkbox (unless readonly); View: "Enabled" / "Disabled". Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var isSet = GetEffectiveDisplayValue() is bool b && b;
|
||||
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(GetEffectiveDisplayValue() is bool isSet && isSet ? "Enabled" : "Disabled")
|
||||
}
|
||||
|
||||
@code {
|
||||
private async Task OnCheckedChange(ChangeEventArgs e)
|
||||
{
|
||||
await NotifyValueChanged(e.Value is true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Value-only display for int? options. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var current = GetEffectiveDisplayValue() is int i ? i : DefaultValue;
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 8rem;"
|
||||
min="@Min" max="@Max" value="@(current?.ToString() ?? "")" @oninput="OnInput" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() is int n ? n : (int?)null;
|
||||
if (v is null)
|
||||
{
|
||||
@("(not set)")
|
||||
}
|
||||
else
|
||||
{
|
||||
@v
|
||||
@if (!string.IsNullOrEmpty(ViewSuffix)) { <text> @ViewSuffix</text> }
|
||||
@if (!string.IsNullOrEmpty(Unit)) { <text> @Unit</text> }
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private EffectiveIntegerConfigFieldDescriptor? IntDescriptor => Descriptor as EffectiveIntegerConfigFieldDescriptor;
|
||||
private string? ViewSuffix => IntDescriptor?.ViewSuffix;
|
||||
private string? Unit => IntDescriptor?.Unit;
|
||||
private int Min => IntDescriptor?.Min ?? 0;
|
||||
private int Max => IntDescriptor?.Max ?? int.MaxValue;
|
||||
private int? DefaultValue => IntDescriptor?.DefaultValue;
|
||||
|
||||
private async Task OnInput(ChangeEventArgs e)
|
||||
{
|
||||
int? value = null;
|
||||
if (int.TryParse(e.Value?.ToString(), out var num) && num >= Min && num <= Max)
|
||||
value = num;
|
||||
await NotifyValueChanged(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* local-service: net | host. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
<select class="form-select form-select-sm" style="max-width: 10rem;" value="@(GetEffectiveDisplayValue() as string ?? "")" @onchange="OnChange" @onfocusout="OnCommitRequested">
|
||||
<option value="">(not set)</option>
|
||||
<option value="net">net</option>
|
||||
<option value="host">host</option>
|
||||
</select>
|
||||
}
|
||||
else
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
@(string.IsNullOrEmpty(v) ? "(not set)" : (v.Trim().ToLowerInvariant() is "net" or "host" ? v.Trim() : v))
|
||||
}
|
||||
|
||||
@code {
|
||||
private async Task OnChange(ChangeEventArgs e)
|
||||
{
|
||||
var v = e.Value?.ToString()?.Trim();
|
||||
var value = string.IsNullOrEmpty(v) ? null : (v == "net" || v == "host" ? v : null);
|
||||
await NotifyValueChanged(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* log-queries: optional value extra|proto|auth. View: Off / On / On (extra|proto|auth). When active editor: dropdown. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
<select class="form-select form-select-sm" style="max-width: 12rem;" value="@GetEditValue()" @onchange="OnChange" @onfocusout="OnCommitRequested">
|
||||
<option value="">Off</option>
|
||||
<option value="on">On</option>
|
||||
<option value="extra">On (extra)</option>
|
||||
<option value="proto">On (proto)</option>
|
||||
<option value="auth">On (auth)</option>
|
||||
</select>
|
||||
}
|
||||
else
|
||||
{
|
||||
@GetViewText()
|
||||
}
|
||||
|
||||
@code {
|
||||
private string GetEditValue()
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (string.IsNullOrEmpty(v)) return "";
|
||||
var lower = v.Trim().ToLowerInvariant();
|
||||
if (lower is "extra" or "proto" or "auth") return lower;
|
||||
return "on";
|
||||
}
|
||||
|
||||
private string GetViewText()
|
||||
{
|
||||
var v = GetEffectiveDisplayValue() as string;
|
||||
if (string.IsNullOrEmpty(v)) return "Off";
|
||||
var lower = v.Trim().ToLowerInvariant();
|
||||
return lower switch
|
||||
{
|
||||
"extra" => "On (extra)",
|
||||
"proto" => "On (proto)",
|
||||
"auth" => "On (auth)",
|
||||
_ => "On"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task OnChange(ChangeEventArgs e)
|
||||
{
|
||||
var sel = e.Value?.ToString()?.Trim().ToLowerInvariant();
|
||||
string? newValue = sel switch
|
||||
{
|
||||
"" or "off" => null,
|
||||
"on" => "",
|
||||
"extra" => "extra",
|
||||
"proto" => "proto",
|
||||
"auth" => "auth",
|
||||
_ => null
|
||||
};
|
||||
await NotifyValueChanged(newValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@inherits EffectiveConfigValueDisplayBase
|
||||
|
||||
@* Value-only display for the port option. Pending value shown until save. *@
|
||||
@if (IsActiveEditor && Descriptor.GetSource()?.IsReadOnly != true)
|
||||
{
|
||||
var port = GetEffectiveDisplayValue() is int p ? p : 53;
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" @onfocusout="OnCommitRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
@(GetEffectiveDisplayValue() is int port ? $"{port} (default DNS port)" : "(not set)")
|
||||
}
|
||||
|
||||
@code {
|
||||
private async Task OnPortInput(ChangeEventArgs e)
|
||||
{
|
||||
if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535)
|
||||
await NotifyValueChanged(port);
|
||||
else
|
||||
await NotifyValueChanged(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@using System.Net
|
||||
@using System.Net.Sockets
|
||||
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. Pending value shown until save. *@
|
||||
@if (_customFragment is { } fragment)
|
||||
{
|
||||
var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
||||
var showEditableBadge = !isReadonly;
|
||||
var isActiveEditor = IsEditMode && IsActive;
|
||||
var fragmentWithCallback = isActiveEditor ? _fragmentWithCallback : null;
|
||||
var effectiveValue = _effectiveValue;
|
||||
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||
<span class="ec-field-value">
|
||||
@if (isActiveEditor)
|
||||
{
|
||||
<div class="ec-field-edit-wrapper" @onfocusout="OnBlur" @onclick:stopPropagation="true">
|
||||
<CascadingValue Value="@true">
|
||||
<CascadingValue Value="@(EventCallback.Factory.Create<FocusEventArgs>(this, OnBlur))">
|
||||
@((fragmentWithCallback ?? fragment)(Descriptor))
|
||||
</CascadingValue>
|
||||
</CascadingValue>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<CascadingValue Value="@false">
|
||||
@fragment(Descriptor)
|
||||
</CascadingValue>
|
||||
}
|
||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@Descriptor.GetSource()" OptionName="@Descriptor.OptionName" Value="@effectiveValue" IsInteractive="@IsEditMode" ShowEditableBadge="@showEditableBadge" HasPendingChange="@_hasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@isActiveEditor" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
else if (Descriptor.IsMultiValue)
|
||||
{
|
||||
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
|
||||
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
IsActiveEditor="@(IsEditMode && IsActive)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||
Source="@_multiValueRowSource" ShowEditableBadge="@IsEditMode" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
var value = _effectiveValue;
|
||||
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
|
||||
var isReadonly = Descriptor.GetSource()?.IsReadOnly == true;
|
||||
var showReadonlyHint = IsEditMode && isReadonly;
|
||||
var isActiveEditor = IsEditMode && IsActive;
|
||||
@if (showReadonlyHint)
|
||||
{
|
||||
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
FieldKey="@FieldKey" ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange" />
|
||||
</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="ec-field @(isActiveEditor ? "ec-field-editing" : "")" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
|
||||
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||
ShowEditableBadge="true" FieldKey="@FieldKey" IsActive="@IsActive"
|
||||
ReadonlyBadgeAnchorId="@_readonlyBadgeAnchorId" RawValue="@value" HasPendingChange="@_hasPendingChange"
|
||||
DisplayValueChanged="@HandleDefaultValueChanged" OnBlur="@HandleDefaultBlur" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||
</li>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public bool IsActive { get; set; }
|
||||
[CascadingParameter] public IEffectiveConfigEditSession? Session { get; set; }
|
||||
|
||||
private readonly string _anchorId = "ec-help-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private readonly string _readonlyBadgeAnchorId = "ec-readonly-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
|
||||
private RenderFragment<EffectiveConfigFieldDescriptor>? _fragmentWithCallback;
|
||||
private string? _labelTooltip;
|
||||
private string? _optionHelpKey;
|
||||
private object? _effectiveValue;
|
||||
private bool _hasPendingChange;
|
||||
private object? _valueAtActivation;
|
||||
private object? _draftValue;
|
||||
private bool _wasActive;
|
||||
private ExitAction _exitAction = ExitAction.None;
|
||||
|
||||
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
|
||||
private IReadOnlyList<ValueWithSource>? _multiValueItems;
|
||||
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
|
||||
private bool _multiValueHasDraft;
|
||||
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
|
||||
private ConfigValueSource? _multiValueRowSource;
|
||||
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
|
||||
private bool _multiValueHasPendingOrDraft;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
_customFragment = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_fragmentWithCallback = RenderFragmentRegistry.BuildFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<object?>(this, HandleValueChanged));
|
||||
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
|
||||
_optionHelpKey = Descriptor.OptionName;
|
||||
var pending = Session?.PendingChanges.FirstOrDefault(c =>
|
||||
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
|
||||
var descriptorValue = Descriptor.IsMultiValue
|
||||
? (Descriptor.GetItems()?.Select(i => i.Value).ToList() ?? new List<string>())
|
||||
: Descriptor.GetValue();
|
||||
_hasPendingChange = pending != null;
|
||||
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
if (!_wasActive && IsActive)
|
||||
_valueAtActivation = descriptorValue;
|
||||
if (_wasActive && !IsActive && _exitAction == ExitAction.None)
|
||||
_ = InvokeAsync(CommitIfChangedAsync);
|
||||
if (_wasActive && !IsActive)
|
||||
_exitAction = ExitAction.None;
|
||||
_wasActive = IsActive;
|
||||
}
|
||||
|
||||
private void ComputeMultiValueState(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string>? pendingList, IReadOnlyList<string>? draftList)
|
||||
{
|
||||
var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List<string>();
|
||||
_multiValueHasDraft = draftList != null && !ValuesEqual(descriptorValues, draftList);
|
||||
if (_multiValueHasDraft && draftList != null)
|
||||
{
|
||||
_multiValueItems = BuildItemsWithSource(descriptorItems, draftList);
|
||||
}
|
||||
else if (_hasPendingChange && pendingList != null)
|
||||
{
|
||||
_multiValueItems = BuildItemsWithSource(descriptorItems, pendingList);
|
||||
}
|
||||
else
|
||||
{
|
||||
_multiValueItems = descriptorItems;
|
||||
}
|
||||
var allReadonly = _multiValueItems != null && _multiValueItems.Count > 0 && _multiValueItems.All(i => i.Source?.IsReadOnly == true);
|
||||
_multiValueRowSource = allReadonly && !IsEditMode ? _multiValueItems?.FirstOrDefault()?.Source : null;
|
||||
_multiValueHasPendingOrDraft = _hasPendingChange || _multiValueHasDraft;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(IReadOnlyList<ValueWithSource>? descriptorItems, IReadOnlyList<string> values)
|
||||
{
|
||||
var pool = descriptorItems?.ToList() ?? new List<ValueWithSource>();
|
||||
var list = new List<ValueWithSource>(values.Count);
|
||||
foreach (var s in values)
|
||||
{
|
||||
var idx = pool.FindIndex(x => string.Equals(x.Value, s, StringComparison.Ordinal));
|
||||
if (idx >= 0)
|
||||
{
|
||||
list.Add(pool[idx]);
|
||||
pool.RemoveAt(idx);
|
||||
}
|
||||
else
|
||||
list.Add(new ValueWithSource(s, null));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private void HandleValueChanged(object? value)
|
||||
{
|
||||
_draftValue = value;
|
||||
}
|
||||
|
||||
private async Task OnBlur(FocusEventArgs _)
|
||||
{
|
||||
if (!IsActive) return;
|
||||
await CommitIfChangedAsync();
|
||||
}
|
||||
|
||||
private async Task<bool> CommitIfChangedAsync()
|
||||
{
|
||||
var newValue = _draftValue ?? _effectiveValue;
|
||||
if (!ValuesEqual(_valueAtActivation, newValue))
|
||||
{
|
||||
var validationError = ValidateDraftValue(newValue);
|
||||
if (validationError != null)
|
||||
{
|
||||
NotificationService.ShowError(validationError);
|
||||
return false;
|
||||
}
|
||||
|
||||
var source = Descriptor.GetSource();
|
||||
object? valueToCommit = newValue;
|
||||
if (newValue is IReadOnlyList<string> list)
|
||||
{
|
||||
var deduped = list.Distinct(StringComparer.Ordinal).ToList();
|
||||
valueToCommit = deduped;
|
||||
}
|
||||
var args = new EffectiveConfigEditCommittedArgs(Descriptor.SectionId, Descriptor.OptionName, _valueAtActivation, valueToCommit, source?.FilePath);
|
||||
if (Ui != null)
|
||||
await Ui.CommitFieldAsync(args);
|
||||
}
|
||||
_draftValue = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
[CascadingParameter] private EffectiveConfigUiContext? Ui { get; set; }
|
||||
|
||||
private void HandleDefaultValueChanged(string value)
|
||||
{
|
||||
_draftValue = value;
|
||||
}
|
||||
|
||||
private async Task HandleDefaultBlur(string? value)
|
||||
{
|
||||
if (!IsActive) return;
|
||||
_draftValue = value;
|
||||
if (!await CommitIfChangedAsync() && Ui != null)
|
||||
await Ui.ActivateFieldAsync(FieldKey);
|
||||
}
|
||||
|
||||
private void HandleMultiValueChanged(IReadOnlyList<string> values)
|
||||
{
|
||||
_draftValue = values.ToList();
|
||||
if (Descriptor.IsMultiValue)
|
||||
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task HandleConfirmEdit()
|
||||
{
|
||||
if (!await CommitIfChangedAsync())
|
||||
return;
|
||||
_exitAction = ExitAction.Confirm;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private async Task HandleCancelEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
_exitAction = ExitAction.Cancel;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private async Task HandleRevertEdit()
|
||||
{
|
||||
_draftValue = null;
|
||||
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
|
||||
_exitAction = ExitAction.Revert;
|
||||
if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private string? ValidateDraftValue(object? value)
|
||||
{
|
||||
if (string.Equals(Descriptor.OptionName, DnsmasqConfKeys.ListenAddress, StringComparison.Ordinal))
|
||||
{
|
||||
var values = AsStringList(value) ?? (value is string s ? new[] { s } : null);
|
||||
if (values == null) return null;
|
||||
foreach (var raw in values)
|
||||
{
|
||||
var v = raw.Trim();
|
||||
if (!IPAddress.TryParse(v, out var ip) || (ip.AddressFamily != AddressFamily.InterNetwork && ip.AddressFamily != AddressFamily.InterNetworkV6))
|
||||
return $"Invalid value for {DnsmasqConfKeys.ListenAddress}: '{raw}'. Use a valid IPv4 or IPv6 address.";
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? AsStringList(object? value)
|
||||
{
|
||||
return value as IReadOnlyList<string>;
|
||||
}
|
||||
|
||||
private static bool ValuesEqual(object? oldValue, object? newValue)
|
||||
{
|
||||
var oldList = AsStringList(oldValue);
|
||||
var newList = AsStringList(newValue);
|
||||
if (oldList != null || newList != null)
|
||||
return (oldList ?? Array.Empty<string>()).SequenceEqual(newList ?? Array.Empty<string>(), StringComparer.Ordinal);
|
||||
return Equals(oldValue, newValue);
|
||||
}
|
||||
|
||||
private enum ExitAction { None, Confirm, Cancel, Revert }
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<span class="ec-field-value-item" title="@(ReadonlyClickActivatesField ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip())">
|
||||
@Value
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@OptionName" Value="@Value"
|
||||
IsInteractive="@(IsEditMode && !ReadonlyClickActivatesField && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
|
||||
ShowEditableBadge="@(Source?.IsReadOnly != true)" EditableAsButton="@IsEditMode"
|
||||
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Value { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
|
||||
[Parameter] public string? FieldKey { get; set; }
|
||||
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
|
||||
[Parameter] public string? ReadonlyTitleOverride { get; set; }
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<span class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||
<ol class="ec-field-value-list">
|
||||
@if (SourceItems?.Count > 0)
|
||||
{
|
||||
@for (var i = 0; i < SourceItems.Count; i++)
|
||||
{
|
||||
var item = SourceItems[i];
|
||||
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
|
||||
var itemReadonly = item.Source?.IsReadOnly == true;
|
||||
var rowIndex = i;
|
||||
var rowValue = item.Value;
|
||||
|
||||
<li class="ec-multi-list-row" @key="anchorId">
|
||||
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||
{
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="save" Clickable="true"
|
||||
Title="Apply change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="@(() => SaveEdit(rowIndex))" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard edit" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value-item">@rowValue</span>
|
||||
|
||||
<span class="status-badge-inline">
|
||||
<OptionStateBadge
|
||||
AnchorId="@anchorId"
|
||||
Source="@item.Source"
|
||||
OptionName="@OptionName"
|
||||
Value="@rowValue"
|
||||
IsInteractive="@IsEditMode"
|
||||
ShowEditableBadge="@(!itemReadonly)"
|
||||
EditableAsButton="@(!itemReadonly)"
|
||||
OnEditableClickRequested="@(!itemReadonly ? EventCallback.Factory.Create(this, () => { BeginEdit(rowIndex, rowValue); return Task.CompletedTask; }) : default)" />
|
||||
@if (!itemReadonly)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="remove" Clickable="true"
|
||||
Title="Remove this value" AriaLabel="Remove"
|
||||
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-empty-hint">
|
||||
<span class="text-muted">(none)</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (AllowAddManagedValue)
|
||||
{
|
||||
<li class="ec-multi-list-row ec-multi-list-add-item" @key="AddItemKey">
|
||||
@if (_addingNew)
|
||||
{
|
||||
<input class="form-control form-control-sm ec-multi-list-input"
|
||||
placeholder="New value"
|
||||
@bind="_draftValue" @bind:event="oninput" />
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="add" Clickable="true"
|
||||
Title="Add this value" AriaLabel="Add"
|
||||
PreventBlurOnMouseDown="true" OnClick="SaveNew" Class="status-badge-inline" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard" AriaLabel="Cancel"
|
||||
PreventBlurOnMouseDown="true" OnClick="CancelInline" Class="status-badge-inline" />
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.Editable" Text="add item" Clickable="true"
|
||||
Title="Add a new value" AriaLabel="Add item"
|
||||
OnClick="BeginAdd" Class="status-badge-inline ec-multi-list-add-btn" />
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
|
||||
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
|
||||
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
|
||||
|
||||
private const string AddItemKey = "add-item";
|
||||
private int? _editingIndex;
|
||||
private bool _addingNew;
|
||||
private string _draftValue = "";
|
||||
|
||||
private void BeginEdit(int index, string current)
|
||||
{
|
||||
_addingNew = false;
|
||||
_editingIndex = index;
|
||||
_draftValue = current;
|
||||
}
|
||||
|
||||
private void BeginAdd()
|
||||
{
|
||||
_editingIndex = null;
|
||||
_addingNew = true;
|
||||
_draftValue = "";
|
||||
}
|
||||
|
||||
private void CancelInline()
|
||||
{
|
||||
_editingIndex = null;
|
||||
_addingNew = false;
|
||||
_draftValue = "";
|
||||
}
|
||||
|
||||
private async Task SaveEdit(int index)
|
||||
{
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
next[index] = (_draftValue ?? "").Trim();
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
CancelInline();
|
||||
}
|
||||
|
||||
private async Task SaveNew()
|
||||
{
|
||||
var value = (_draftValue ?? "").Trim();
|
||||
if (value.Length == 0) return;
|
||||
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
|
||||
next.Add(value);
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
CancelInline();
|
||||
}
|
||||
|
||||
private async Task RemoveAt(int index)
|
||||
{
|
||||
if (SourceItems == null || index < 0 || index >= SourceItems.Count) return;
|
||||
var item = SourceItems[index];
|
||||
if (item?.Source?.IsReadOnly == true) return;
|
||||
|
||||
var next = (SourceItems ?? Array.Empty<ValueWithSource>())
|
||||
.Select(x => x.Value)
|
||||
.ToList();
|
||||
if (index < 0 || index >= next.Count) return;
|
||||
|
||||
next.RemoveAt(index);
|
||||
await OnValuesChanged.InvokeAsync(next);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/* Editor block: distinct from display mode (border + background) */
|
||||
.ec-multi-list-editor {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.ec-multi-list-editor .ec-field-value-list {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .ec-field-value-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .status-badge-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-row .form-control.ec-multi-list-input {
|
||||
max-width: 28rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ec-multi-list-empty-hint {
|
||||
list-style: none;
|
||||
margin-left: -1.25rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item {
|
||||
list-style: none;
|
||||
margin-left: -1.25rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px dashed var(--bs-border-color, #dee2e6);
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .form-control.ec-multi-list-input {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-item .status-badge-inline {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ec-multi-list-add-btn {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<li class="ec-field">
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
|
||||
@if (IsActiveEditor)
|
||||
{
|
||||
<EffectiveConfigMultiValueListEditor
|
||||
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
|
||||
OptionName="@Label"
|
||||
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
||||
IsEditMode="@IsEditMode"
|
||||
AllowAddManagedValue="@IsEditMode"
|
||||
OnValuesChanged="@ItemsChanged" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value ec-field-value-multi">
|
||||
@if (Items?.Count > 0)
|
||||
{
|
||||
<ol class="ec-field-value-list">
|
||||
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
|
||||
{
|
||||
var anchorId = ReadonlyBadgeAnchorIdBase != null ? ReadonlyBadgeAnchorIdBase + "-" + index : "";
|
||||
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" OptionName="@Label" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorId="@anchorId" FieldKey="@FieldKey" ReadonlyClickActivatesField="true" ReadonlyTitleOverride="New values can still be added. Click to edit." /></li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">(none)</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
||||
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
[Parameter] public string? OptionHelpKey { get; set; }
|
||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public bool IsEditMode { get; set; }
|
||||
[Parameter] public string? ReadonlyBadgeAnchorIdBase { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></OptionHelpLabel>
|
||||
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && IsActive && Source?.IsReadOnly != true)
|
||||
{
|
||||
<span class="ec-field-value" @onclick:stopPropagation="true">
|
||||
<input type="text" class="form-control form-control-sm" value="@(_editValue ?? DisplayValue)" @oninput="OnValueInput" @onfocusout="OnInputBlur" />
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="ec-field-value">
|
||||
@DisplayValue
|
||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorId ?? "")" Source="@Source" OptionName="@Label" Value="@(RawValue ?? DisplayValue)" IsInteractive="@(DisplayMode == EffectiveConfigDisplayMode.Edit && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="false" OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||
</span>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string Label { get; set; } = "";
|
||||
[Parameter] public string DisplayValue { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
|
||||
[Parameter] public string? LabelTooltip { get; set; }
|
||||
[Parameter] public string? OptionHelpKey { get; set; }
|
||||
[Parameter] public string? OptionHelpAnchorId { get; set; }
|
||||
/// <summary>When set in edit mode, the readonly badge is rendered as a button with this id so the popover can anchor to it.</summary>
|
||||
[Parameter] public string? ReadonlyBadgeAnchorId { get; set; }
|
||||
/// <summary>Raw value for the readonly popover override line (e.g. from descriptor); falls back to DisplayValue.</summary>
|
||||
[Parameter] public object? RawValue { get; set; }
|
||||
/// <summary>When true, show an 'editable' badge next to non-readonly fields; clicking it activates edit for this field.</summary>
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
/// <summary>When true, badge shows "pending" and warning style to indicate this field has an unsaved change.</summary>
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
/// <summary>Field key for activation via context when editable badge is clicked.</summary>
|
||||
[Parameter] public string FieldKey { get; set; } = "";
|
||||
/// <summary>When true, add ec-field-editing class and show input when DisplayMode is Edit.</summary>
|
||||
[Parameter] public bool IsActive { get; set; }
|
||||
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
|
||||
[Parameter] public EventCallback<string> DisplayValueChanged { get; set; }
|
||||
/// <summary>When Edit and not readonly: raised when the input loses focus; argument is the current value.</summary>
|
||||
[Parameter] public EventCallback<string?> OnBlur { get; set; }
|
||||
/// <summary>When user clicks OK; parent should commit and deactivate.</summary>
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
/// <summary>When user clicks Cancel; parent should clear draft and deactivate.</summary>
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
/// <summary>When user clicks Revert; parent should revert pending, clear draft, and deactivate.</summary>
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
|
||||
|
||||
private string? _editValue;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (DisplayMode != EffectiveConfigDisplayMode.Edit)
|
||||
_editValue = null;
|
||||
}
|
||||
|
||||
private async Task OnValueInput(ChangeEventArgs e)
|
||||
{
|
||||
var value = e.Value?.ToString() ?? "";
|
||||
_editValue = value;
|
||||
await DisplayValueChanged.InvokeAsync(value);
|
||||
}
|
||||
|
||||
private async Task OnInputBlur(FocusEventArgs _)
|
||||
{
|
||||
if (OnBlur.HasDelegate)
|
||||
await OnBlur.InvokeAsync(_editValue ?? DisplayValue);
|
||||
}
|
||||
}
|
||||
+32
-19
@@ -1,5 +1,5 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Extensions
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Extensions.DependencyInjection
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.JSInterop
|
||||
@using System.Text.Json.Serialization
|
||||
@@ -50,19 +50,33 @@
|
||||
private DotNetObjectReference<ConfigOptionHelpModal>? _dotNetRef;
|
||||
private bool _scrollListenerAdded;
|
||||
private bool _mouseOverModal;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private bool _moduleLoaded;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (Visible && !_scrollListenerAdded)
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { /* prerender */ }
|
||||
catch (JSDisconnectedException) { /* circuit disconnected */ }
|
||||
catch (JSException) { /* import failed */ }
|
||||
}
|
||||
|
||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = true;
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
await JSRuntime.InvokeVoidAsync("addConfigOptionHelpScrollCloseListener", _dotNetRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
|
||||
}
|
||||
else if (!Visible && _scrollListenerAdded)
|
||||
else if (!Visible && _scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener");
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
@@ -79,13 +93,15 @@
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scrollListenerAdded)
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
try { await JSRuntime.InvokeVoidAsync("removeConfigOptionHelpScrollCloseListener"); } catch { /* best effort */ }
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
@@ -134,19 +150,16 @@
|
||||
|
||||
private async Task PositionUnderAnchorAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(AnchorId)) return;
|
||||
try
|
||||
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
|
||||
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
|
||||
if (rect is { } r)
|
||||
{
|
||||
var rect = await JSRuntime.InvokeAsync<BoundingRect?>("getConfigOptionHelpAnchorRect", AnchorId);
|
||||
if (rect is { } r)
|
||||
{
|
||||
var gap = 4;
|
||||
var topPx = r.Bottom + gap;
|
||||
var leftPx = r.Left;
|
||||
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
|
||||
}
|
||||
var gap = 4;
|
||||
var topPx = r.Bottom + gap;
|
||||
var leftPx = r.Left;
|
||||
_positionStyle = $"position: fixed; top: {topPx}px; left: {leftPx}px; visibility: visible;";
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
_positionStyle = "visibility: visible;";
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IEffectiveConfigSaveService SaveService
|
||||
@inject INotificationService NotificationService
|
||||
|
||||
@if (ShowModal)
|
||||
{
|
||||
<EffectiveConfigSaveModal
|
||||
PendingChanges="@PendingChanges"
|
||||
Status="@Status"
|
||||
Session="@Session"
|
||||
SaveState="@_saveState"
|
||||
SaveResult="@_saveResult"
|
||||
RestoreResult="@_restoreResult"
|
||||
OnClose="@OnClose"
|
||||
OnSave="@HandleSave"
|
||||
OnRestore="@HandleRestore"
|
||||
OnReverted="@HandleReverted"
|
||||
OnCloseAndEndEdit="@OnCloseAndEndEdit" />
|
||||
}
|
||||
|
||||
@code {
|
||||
public enum SaveUiState
|
||||
{
|
||||
Review,
|
||||
Saving,
|
||||
WriteFailed,
|
||||
ValidateFailed,
|
||||
ReloadFailed,
|
||||
RestoreInProgress,
|
||||
RestoreFailed
|
||||
}
|
||||
|
||||
[Parameter] public bool ShowModal { get; set; }
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnSaveCompleted { get; set; }
|
||||
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
|
||||
private SaveUiState _saveState = SaveUiState.Review;
|
||||
private EffectiveConfigSaveResult? _saveResult;
|
||||
private EffectiveConfigRestoreResult? _restoreResult;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (!ShowModal)
|
||||
{
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleReverted()
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleSave()
|
||||
{
|
||||
if (Session.PendingChanges.Count == 0) return;
|
||||
|
||||
_saveState = SaveUiState.Saving;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await Session.ApplyAsync();
|
||||
|
||||
_saveResult = result;
|
||||
if (result.Saved && result.Restarted)
|
||||
{
|
||||
var message = result.UserMessage ?? "Config saved and dnsmasq restarted.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Saved && result.IsValidateFailed)
|
||||
_saveState = SaveUiState.ValidateFailed;
|
||||
else if (result.Saved && !result.Restarted)
|
||||
_saveState = SaveUiState.ReloadFailed;
|
||||
else if (!result.Saved)
|
||||
_saveState = SaveUiState.WriteFailed;
|
||||
else
|
||||
_saveState = SaveUiState.Review;
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task HandleRestore(string backupPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(backupPath)) return;
|
||||
|
||||
_saveState = SaveUiState.RestoreInProgress;
|
||||
_restoreResult = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
var result = await SaveService.RestoreAsync(backupPath);
|
||||
|
||||
_restoreResult = result;
|
||||
if (result.Restored && result.Restarted)
|
||||
{
|
||||
var message = result.UserMessage ?? "Backup restored and dnsmasq restarted.";
|
||||
NotificationService.ShowSuccess(message);
|
||||
_saveState = SaveUiState.Review;
|
||||
_saveResult = null;
|
||||
_restoreResult = null;
|
||||
await OnClose.InvokeAsync();
|
||||
await OnSaveCompleted.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
_saveState = SaveUiState.RestoreFailed;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
|
||||
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="ec-save-modal-title" aria-modal="true">
|
||||
<div class="modal-dialog modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="ec-save-modal-title">Save effective config changes</h5>
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close" disabled="@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)"></button>
|
||||
</div>
|
||||
<div class="modal-body ec-save-modal-body">
|
||||
@if (SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving || SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<div class="ec-save-modal-loading text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status" aria-hidden="true"></div>
|
||||
<p class="text-muted mt-2 mb-0">@(SaveState == EffectiveConfigSaveFlow.SaveUiState.Saving ? "Saving, validating and restarting…" : "Restoring backup and restarting…")</p>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-reload-failed">
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">
|
||||
<strong>Config was saved, but validation failed.</strong> Restart was not attempted. You can restore the backup or keep editing.
|
||||
</div>
|
||||
</div>
|
||||
@if (SaveResult.BackupPath != null)
|
||||
{
|
||||
<p class="ec-save-modal-backup-path mb-2">
|
||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
||||
</p>
|
||||
}
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Validation output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetValidationFailedOutput(SaveResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed && SaveResult != null)
|
||||
{
|
||||
<div class="alert alert-danger mb-0">
|
||||
<strong>Save failed.</strong> @SaveResult.UserMessage
|
||||
@if (!string.IsNullOrEmpty(SaveResult.RestartStdErr))
|
||||
{
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed && SaveResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-reload-failed">
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">
|
||||
<strong>Config was saved, but the restart command failed.</strong> You can restore the backup or keep editing.
|
||||
</div>
|
||||
</div>
|
||||
@if (SaveResult.BackupPath != null)
|
||||
{
|
||||
<p class="ec-save-modal-backup-path mb-2">
|
||||
<span class="text-muted">Backup:</span> <code class="small ec-save-modal-backup-code">@SaveResult.BackupPath</code>
|
||||
</p>
|
||||
}
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Command output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestartFailedOutput(SaveResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed && RestoreResult != null)
|
||||
{
|
||||
<div class="ec-save-modal-restore-failed">
|
||||
<div class="alert alert-danger mb-3 d-flex align-items-center gap-2 ec-save-modal-alert">
|
||||
<i class="bi bi-exclamation-triangle-fill ec-save-modal-alert-icon" aria-hidden="true"></i>
|
||||
<div class="ec-save-modal-alert-text">@RestoreResult.UserMessage</div>
|
||||
</div>
|
||||
<div class="ec-save-modal-stderr mb-3">
|
||||
<p class="text-muted small mb-1"><strong>Command output</strong></p>
|
||||
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mb-0 text-body">@GetRestoreFailedOutput(RestoreResult)</pre>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
|
||||
@if (ManagedFilePath != null)
|
||||
{
|
||||
<p class="ec-save-modal-target mb-3">
|
||||
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
|
||||
</p>
|
||||
}
|
||||
@if (PendingChanges.Count == 0)
|
||||
{
|
||||
<div class="ec-save-modal-empty alert alert-light border text-center text-muted py-4 mb-0">
|
||||
No pending changes. Close to continue editing.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="ec-save-modal-changes list-group list-group-flush">
|
||||
@foreach (var c in PendingChanges)
|
||||
{
|
||||
var change = c;
|
||||
var oldList = AsStringList(change.OldValue);
|
||||
var newList = AsStringList(change.NewValue);
|
||||
var isMultiValue = oldList != null || newList != null;
|
||||
<div class="list-group-item ec-save-modal-item d-flex align-items-start gap-3 py-3 px-0 border-0 border-bottom">
|
||||
<div class="ec-save-modal-item-body flex-grow-1 min-width-0">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="@change.OptionName" Class="ec-save-modal-option me-2" />
|
||||
@if (!isMultiValue)
|
||||
{
|
||||
<span class="ec-save-modal-diff text-body">
|
||||
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue)</span>
|
||||
<span class="text-muted mx-1" aria-hidden="true">→</span>
|
||||
<span>@FormatValue(change.NewValue)</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isMultiValue)
|
||||
{
|
||||
var added = (newList ?? Array.Empty<string>()).Except(oldList ?? Array.Empty<string>()).ToList();
|
||||
var removed = (oldList ?? Array.Empty<string>()).Except(newList ?? Array.Empty<string>()).ToList();
|
||||
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
|
||||
@foreach (var val in removed)
|
||||
{
|
||||
<div class="text-muted text-decoration-line-through">− @val</div>
|
||||
}
|
||||
@foreach (var val in added)
|
||||
{
|
||||
<div class="text-success">+ @val</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (change.CurrentSourceFilePath != null)
|
||||
{
|
||||
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="() => Revert(change)" title="Revert this change" aria-label="Revert @change.OptionName">
|
||||
<i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Revert
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer ec-save-modal-footer">
|
||||
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed) && SaveResult?.BackupPath != null)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="() => Restore(SaveResult.BackupPath)"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backup</button>
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.RestoreFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
<button type="button" class="btn btn-danger" @onclick="CloseAndEndEdit"><i class="bi bi-x-circle me-1" aria-hidden="true"></i>Stop editing</button>
|
||||
}
|
||||
else if (SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-warning" @onclick="Close"><i class="bi bi-pencil me-1" aria-hidden="true"></i>Continue editing</button>
|
||||
}
|
||||
else if (SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress)
|
||||
{
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@(PendingChanges.Count == 0)"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" style="display: block;" @onclick="OnBackdropClick"></div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
|
||||
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
|
||||
[Parameter] public EffectiveConfigSaveResult? SaveResult { get; set; }
|
||||
[Parameter] public EffectiveConfigRestoreResult? RestoreResult { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
[Parameter] public EventCallback<string> OnRestore { get; set; }
|
||||
[Parameter] public EventCallback OnReverted { get; set; }
|
||||
[Parameter] public EventCallback OnCloseAndEndEdit { get; set; }
|
||||
|
||||
private bool AllowClose => SaveState != EffectiveConfigSaveFlow.SaveUiState.Saving && SaveState != EffectiveConfigSaveFlow.SaveUiState.RestoreInProgress;
|
||||
|
||||
private string? ManagedFilePath => Status?.ManagedFilePath;
|
||||
|
||||
private static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
|
||||
{
|
||||
var err = (result.ValidationStdErr ?? "").Trim();
|
||||
var outp = (result.ValidationStdOut ?? "").Trim();
|
||||
if (err.Length > 0 && outp.Length > 0)
|
||||
return err + "\n\n" + outp;
|
||||
if (err.Length > 0) return err;
|
||||
if (outp.Length > 0) return outp;
|
||||
var exitInfo = result.ValidationExitCode >= 0 ? $"Exit code: {result.ValidationExitCode}." : "";
|
||||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||||
}
|
||||
|
||||
private static string GetRestartFailedOutput(EffectiveConfigSaveResult result)
|
||||
{
|
||||
var err = (result.RestartStdErr ?? "").Trim();
|
||||
var outp = (result.RestartStdOut ?? "").Trim();
|
||||
if (err.Length > 0 && outp.Length > 0)
|
||||
return err + "\n\n" + outp;
|
||||
if (err.Length > 0) return err;
|
||||
if (outp.Length > 0) return outp;
|
||||
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
|
||||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||||
}
|
||||
|
||||
private static string GetRestoreFailedOutput(EffectiveConfigRestoreResult result)
|
||||
{
|
||||
var err = (result.RestartStdErr ?? "").Trim();
|
||||
if (err.Length > 0) return err;
|
||||
var exitInfo = result.RestartExitCode >= 0 ? $"Exit code: {result.RestartExitCode}." : "";
|
||||
return string.IsNullOrEmpty(exitInfo) ? "(No output captured)" : exitInfo + " (No output captured)";
|
||||
}
|
||||
|
||||
/// <summary>If the value is a list of strings (multi-value option), return it; otherwise null.</summary>
|
||||
private static IReadOnlyList<string>? AsStringList(object? v)
|
||||
{
|
||||
if (v == null) return null;
|
||||
if (v is IReadOnlyList<string> ro) return ro;
|
||||
if (v is List<string> list) return list;
|
||||
if (v is string[] arr) return arr;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatValue(object? v)
|
||||
{
|
||||
if (v == null) return "(not set)";
|
||||
if (v is bool b) return b ? "on" : "off";
|
||||
var list = AsStringList(v);
|
||||
if (list != null) return list.Count == 0 ? "(none)" : string.Join(", ", list);
|
||||
return v.ToString() ?? "(not set)";
|
||||
}
|
||||
|
||||
private async Task Revert(PendingEffectiveConfigChange change)
|
||||
{
|
||||
Session.RevertChange(change.SectionId, change.OptionName);
|
||||
await OnReverted.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnBackdropClick()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await Close();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
await OnSave.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Restore(string backupPath)
|
||||
{
|
||||
await OnRestore.InvokeAsync(backupPath);
|
||||
}
|
||||
|
||||
private async Task CloseAndEndEdit()
|
||||
{
|
||||
if (!AllowClose) return;
|
||||
await OnCloseAndEndEdit.InvokeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Keep footer buttons on one line */
|
||||
.ec-save-modal-footer {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Ensure the dialog stacks above the backdrop when both are siblings inside .modal */
|
||||
.modal-dialog {
|
||||
position: relative;
|
||||
z-index: 1056;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.ec-save-modal-body {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-target {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-changes {
|
||||
margin: 0 -1rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-item:last-child {
|
||||
border-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ec-save-modal-option {
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-diff {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-override {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-empty {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-loading {
|
||||
min-height: 6rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-path {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-backup-code {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Command output: ensure text is always visible (avoid inherited color/visibility issues) */
|
||||
.ec-save-modal-pre {
|
||||
font-size: 0.8125rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
color: var(--bs-body-color, #212529);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* Alert icon in reload/restore failed states: larger, spacing, aligned with text */
|
||||
.ec-save-modal-alert {
|
||||
--ec-save-modal-alert-icon-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ec-save-modal-alert-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.25rem;
|
||||
margin-right: var(--ec-save-modal-alert-icon-gap);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ec-save-modal-alert-text {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<ConfigOptionHelpModal HelpKey="@HelpKey" AnchorId="@AnchorId" Visible="@Visible" Title="@Title"
|
||||
OnClose="RequestClose" OnMouseEnterModal="CancelCloseTimer" OnMouseLeaveModal="ScheduleClose" />
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? HelpKey { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public string Title { get; set; } = "Option help";
|
||||
[Parameter] public EventCallback OnCloseRequested { get; set; }
|
||||
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private void CancelCloseTimer()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
private void ScheduleClose()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = new CancellationTokenSource();
|
||||
var token = _closeCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await Task.Delay(300, token); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_closeCts = null;
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private async Task RequestClose()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Called by the section when the label loses mouse (so we schedule close; if user moves to modal, OnMouseEnterModal cancels).</summary>
|
||||
public void ScheduleCloseFromLabelLeave()
|
||||
{
|
||||
ScheduleClose();
|
||||
}
|
||||
|
||||
/// <summary>Called when opening or switching help (e.g. user clicked another label); cancels any pending 300ms close so the timer from a previous label leave does not dismiss the new help.</summary>
|
||||
public void CancelCloseFromLabelEnter()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<span id="@AnchorId" class="@EffectiveCssClass" title="@Title" @onclick="OnClick" @onmouseleave="OnMouseLeave">
|
||||
@ChildContent
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string AnchorId { get; set; } = "";
|
||||
[Parameter] public string? HelpKey { get; set; }
|
||||
[Parameter] public string OptionLabel { get; set; } = "";
|
||||
[Parameter] public string? CssClass { get; set; }
|
||||
|
||||
private string EffectiveCssClass =>
|
||||
CssClass ?? (!string.IsNullOrEmpty(HelpKey) ? "ec-field-label ec-field-label-help" : "ec-field-label");
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private Task OnClick()
|
||||
{
|
||||
if (Ui == null || string.IsNullOrEmpty(HelpKey)) return Task.CompletedTask;
|
||||
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
|
||||
{
|
||||
HelpKey = HelpKey,
|
||||
OptionLabel = OptionLabel,
|
||||
AnchorId = AnchorId
|
||||
});
|
||||
}
|
||||
|
||||
private Task OnMouseLeave()
|
||||
{
|
||||
if (Ui == null) return Task.CompletedTask;
|
||||
return Ui.RequestOptionHelpAsync(new ConfigOptionHelpRequestEventArgs
|
||||
{
|
||||
AnchorId = AnchorId,
|
||||
IsLabelMouseLeave = true
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
@if (Source?.IsReadOnly == true)
|
||||
{
|
||||
var readonlyTitle = !string.IsNullOrEmpty(ReadonlyTitleOverride) ? ReadonlyTitleOverride : Source?.GetReadOnlyTooltip();
|
||||
if (ReadonlyClickActivatesField && Ui != null && !string.IsNullOrEmpty(FieldKey))
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
|
||||
Id="@AnchorId" Title="@readonlyTitle"
|
||||
OnClick="OnEditableClick"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
else if (IsInteractive && Ui != null)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
|
||||
Id="@AnchorId" Title="@readonlyTitle"
|
||||
OnClick="OnReadonlyClick" OnMouseLeave="OnReadonlyMouseLeave"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly"
|
||||
Title="@readonlyTitle"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
}
|
||||
else if (ShowEditableBadge)
|
||||
{
|
||||
@if (EditableAsButton)
|
||||
{
|
||||
@if (IsActiveEditor)
|
||||
{
|
||||
<span class="status-badge-inline">
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionOk" Text="ok" Clickable="true"
|
||||
Title="Apply this field change" AriaLabel="Apply change"
|
||||
PreventBlurOnMouseDown="true" OnClick="OnConfirmClick" />
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionCancel" Text="cancel" Clickable="true"
|
||||
Title="Discard in-progress edits for this field" AriaLabel="Cancel edit"
|
||||
PreventBlurOnMouseDown="true" OnClick="OnCancelClick" />
|
||||
@if (HasPendingChange)
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.ActionRevert" Text="revert" Clickable="true"
|
||||
Title="Remove this field from pending changes" AriaLabel="Revert pending change"
|
||||
PreventBlurOnMouseDown="true" OnClick="OnRevertClick" />
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="@(HasPendingChange ? StatusBadgeKind.Pending : StatusBadgeKind.Editable)"
|
||||
Text="@(HasPendingChange ? "pending" : "editable")"
|
||||
Clickable="true"
|
||||
Title="@(HasPendingChange ? "Pending change" : "Edit this field")"
|
||||
AriaLabel="Edit"
|
||||
OnClick="OnEditableClick"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable"
|
||||
Class="status-badge-inline" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string AnchorId { get; set; } = "";
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public bool IsInteractive { get; set; }
|
||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
||||
[Parameter] public bool HasPendingChange { get; set; }
|
||||
[Parameter] public bool EditableAsButton { get; set; } = true;
|
||||
[Parameter] public bool IsActiveEditor { get; set; }
|
||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||
[Parameter] public string? FieldKey { get; set; }
|
||||
/// <summary>Optional: when set, editable-badge click invokes this instead of activating the field by key (e.g. list editor per-item edit).</summary>
|
||||
[Parameter] public EventCallback OnEditableClickRequested { get; set; }
|
||||
/// <summary>When true, readonly badge click activates the field for editing instead of showing the readonly popover (e.g. for multi-value where new items can still be added).</summary>
|
||||
[Parameter] public bool ReadonlyClickActivatesField { get; set; }
|
||||
/// <summary>Optional tooltip for readonly badge when <see cref="ReadonlyClickActivatesField"/> is true (e.g. "New values can still be added. Click to edit.").</summary>
|
||||
[Parameter] public string? ReadonlyTitleOverride { get; set; }
|
||||
[CascadingParameter] public EffectiveConfigUiContext? Ui { get; set; }
|
||||
|
||||
private async Task OnConfirmClick()
|
||||
{
|
||||
if (OnConfirmRequested.HasDelegate)
|
||||
await OnConfirmRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnCancelClick()
|
||||
{
|
||||
if (OnCancelRequested.HasDelegate)
|
||||
await OnCancelRequested.InvokeAsync();
|
||||
else if (Ui != null)
|
||||
await Ui.DeactivateFieldAsync();
|
||||
}
|
||||
|
||||
private async Task OnRevertClick()
|
||||
{
|
||||
if (OnRevertRequested.HasDelegate)
|
||||
await OnRevertRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnEditableClick()
|
||||
{
|
||||
if (OnEditableClickRequested.HasDelegate)
|
||||
{
|
||||
await OnEditableClickRequested.InvokeAsync();
|
||||
return;
|
||||
}
|
||||
if (Ui != null && !string.IsNullOrEmpty(FieldKey))
|
||||
await Ui.ActivateFieldAsync(FieldKey);
|
||||
}
|
||||
|
||||
private Task OnReadonlyClick()
|
||||
{
|
||||
if (Ui == null || Source == null) return Task.CompletedTask;
|
||||
return Ui.RequestReadonlyPopoverAsync(new ReadonlyBadgeClickedEventArgs(AnchorId, Source, OptionName, Value));
|
||||
}
|
||||
|
||||
private Task OnReadonlyMouseLeave()
|
||||
{
|
||||
if (Ui == null) return Task.CompletedTask;
|
||||
return Ui.ScheduleReadonlyPopoverCloseAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
|
||||
@if (Source is { IsReadOnly: true })
|
||||
{
|
||||
<div class="ec-readonly-hint small text-muted mt-1">
|
||||
<span class="d-block">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</span>
|
||||
@if (!string.IsNullOrEmpty(_removeCommand))
|
||||
{
|
||||
<div class="mt-1">
|
||||
<CopyableCodeLine Text="@_removeCommand" CssClass="small" CodeCssClass="small text-break" CopyTitle="Copy command" />
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_overrideLine))
|
||||
{
|
||||
<span class="d-block mt-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</span>
|
||||
<div class="mt-1">
|
||||
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public string? ManagedFilePath { get; set; }
|
||||
|
||||
private string _removeCommand = "";
|
||||
private string _overrideLine = "";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Source is { IsReadOnly: true })
|
||||
{
|
||||
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
|
||||
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_removeCommand = "";
|
||||
_overrideLine = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
@using DnsmasqWebUI.Extensions
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.JSInterop
|
||||
@using System.Text.Json.Serialization
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (Visible && Source != null)
|
||||
{
|
||||
<div class="ec-readonly-popover" id="@_popoverId" style="@_positionStyle" @onmouseenter="OnPopoverMouseEnter" @onmouseleave="OnPopoverMouseLeave">
|
||||
<div class="ec-readonly-popover-content">
|
||||
<div class="ec-readonly-popover-header">
|
||||
<span class="ec-readonly-popover-title">Read-only: @OptionName</span>
|
||||
</div>
|
||||
<div class="ec-readonly-popover-body small">
|
||||
<p class="text-muted mb-2">@ReadonlyConfigCommandGenerator.GetEditFileHint(Source)</p>
|
||||
@if (!string.IsNullOrEmpty(_removeCommand))
|
||||
{
|
||||
<div class="mb-2">
|
||||
<CopyableCodeLine Text="@_removeCommand" CodeCssClass="small" CopyTitle="Copy command" />
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(_overrideLine))
|
||||
{
|
||||
<p class="text-muted mb-1">@ReadonlyConfigCommandGenerator.GetOverrideHint(OptionName, Value, ManagedFilePath)</p>
|
||||
<CopyableCodeLine Text="@_overrideLine" CodeCssClass="small" CopyTitle="Copy line" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public string? ManagedFilePath { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
[Parameter] public EventCallback OnMouseEnterPopover { get; set; }
|
||||
[Parameter] public EventCallback OnMouseLeavePopover { get; set; }
|
||||
|
||||
private const string HiddenPositionStyle = "position: fixed; z-index: 1060; left: 0; top: 0; visibility: hidden;";
|
||||
private const string VisiblePositionStyleFormat = "position: fixed; top: {0}px; left: {1}px; visibility: visible;";
|
||||
|
||||
private string _positionStyle = "";
|
||||
private readonly string _popoverId = "ec-readonly-popover-" + Guid.NewGuid().ToString("N")[..8];
|
||||
private DotNetObjectReference<ReadonlyEditPopover>? _dotNetRef;
|
||||
private bool _scrollListenerAdded;
|
||||
private bool _mouseOverPopover;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private bool _moduleLoaded;
|
||||
private string _removeCommand = "";
|
||||
private string _overrideLine = "";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Source is { IsReadOnly: true })
|
||||
{
|
||||
_removeCommand = ReadonlyConfigCommandGenerator.GetRemoveLineCommand(Source, OptionName);
|
||||
_overrideLine = ReadonlyConfigCommandGenerator.GetOverrideLine(OptionName, Value, ManagedFilePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_removeCommand = "";
|
||||
_overrideLine = "";
|
||||
}
|
||||
if (Visible && string.IsNullOrEmpty(_positionStyle))
|
||||
_positionStyle = HiddenPositionStyle;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (JSException) { }
|
||||
}
|
||||
|
||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = true;
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
await _jsModule.InvokeVoidAsyncSafe("addScrollCloseListener", _dotNetRef);
|
||||
}
|
||||
else if (!Visible && _scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
|
||||
if (!Visible)
|
||||
_positionStyle = "";
|
||||
|
||||
if (Visible && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
await PositionUnderAnchorAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnScrollClose()
|
||||
{
|
||||
await PositionUnderAnchorAsync();
|
||||
StateHasChanged();
|
||||
if (!_mouseOverPopover)
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_scrollListenerAdded && _jsModule != null)
|
||||
{
|
||||
_scrollListenerAdded = false;
|
||||
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
}
|
||||
|
||||
private async Task PositionUnderAnchorAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(AnchorId) || _jsModule == null) return;
|
||||
var rect = await _jsModule.InvokeAsyncSafe<BoundingRect?>("getAnchorRect", AnchorId);
|
||||
if (rect is { } r)
|
||||
{
|
||||
var gap = 4;
|
||||
var topPx = r.Bottom + gap;
|
||||
var leftPx = r.Left;
|
||||
_positionStyle = string.Format(VisiblePositionStyleFormat, topPx, leftPx);
|
||||
}
|
||||
else
|
||||
{
|
||||
_positionStyle = HiddenPositionStyle;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnPopoverMouseEnter()
|
||||
{
|
||||
_mouseOverPopover = true;
|
||||
await OnMouseEnterPopover.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnPopoverMouseLeave()
|
||||
{
|
||||
_mouseOverPopover = false;
|
||||
await OnMouseLeavePopover.InvokeAsync();
|
||||
}
|
||||
|
||||
private sealed record BoundingRect(
|
||||
[property: JsonPropertyName("top")] double Top,
|
||||
[property: JsonPropertyName("left")] double Left,
|
||||
[property: JsonPropertyName("bottom")] double Bottom,
|
||||
[property: JsonPropertyName("width")] double Width,
|
||||
[property: JsonPropertyName("height")] double Height);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* Readonly edit popover: anchored under the badge; same look as option help popover */
|
||||
.ec-readonly-popover {
|
||||
position: fixed;
|
||||
z-index: 1060;
|
||||
max-width: min(420px, calc(100vw - 1rem));
|
||||
max-height: min(70vh, 400px);
|
||||
overflow: auto;
|
||||
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
background: var(--bs-body-bg, #fff);
|
||||
}
|
||||
|
||||
.ec-readonly-popover-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ec-readonly-popover-header {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ec-readonly-popover-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.ec-readonly-popover-body {
|
||||
padding: 0.75rem;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Models.Dnsmasq
|
||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||
|
||||
<ReadonlyEditPopover Visible="@Visible" AnchorId="@AnchorId" Source="@Source" OptionName="@OptionName" Value="@Value" ManagedFilePath="@ManagedFilePath"
|
||||
OnClose="RequestClose" OnMouseEnterPopover="CancelCloseTimer" OnMouseLeavePopover="ScheduleClose" />
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? AnchorId { get; set; }
|
||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
||||
[Parameter] public string OptionName { get; set; } = "";
|
||||
[Parameter] public object? Value { get; set; }
|
||||
[Parameter] public string? ManagedFilePath { get; set; }
|
||||
[Parameter] public EventCallback OnCloseRequested { get; set; }
|
||||
|
||||
private CancellationTokenSource? _closeCts;
|
||||
|
||||
private void CancelCloseTimer()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = null;
|
||||
}
|
||||
|
||||
private void ScheduleClose()
|
||||
{
|
||||
_closeCts?.Cancel();
|
||||
_closeCts = new CancellationTokenSource();
|
||||
var token = _closeCts.Token;
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await Task.Delay(300, token); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (token.IsCancellationRequested) return;
|
||||
_closeCts = null;
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
});
|
||||
}, token);
|
||||
}
|
||||
|
||||
private async Task RequestClose()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
await OnCloseRequested.InvokeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when mouse leaves readonly badge; close unless pointer enters popover quickly.
|
||||
/// </summary>
|
||||
public void ScheduleCloseFromBadgeLeave()
|
||||
{
|
||||
ScheduleClose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when readonly badge is interacted with to keep popover open.
|
||||
/// </summary>
|
||||
public void CancelCloseFromBadgeEnter()
|
||||
{
|
||||
CancelCloseTimer();
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@
|
||||
@Path
|
||||
@if (IsEditable)
|
||||
{
|
||||
<span class="badge bg-primary ms-1">editable</span>
|
||||
<StatusBadge Kind="StatusBadgeKind.Editable" Text="editable" Class="ms-1" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary ms-1"> read-only</span>
|
||||
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="read-only" Class="ms-1" />
|
||||
}
|
||||
</h3>
|
||||
<div class="table-responsive-cards">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@using DnsmasqWebUI.Components.Shared
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@implements IDisposable
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
</div>
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
<NotificationHost />
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@using System.Reflection
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject IUpdateCheckService UpdateCheckService
|
||||
@inject INotificationService NotificationService
|
||||
@implements IDisposable
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
@@ -54,14 +56,7 @@
|
||||
else
|
||||
{
|
||||
<button type="button" class="nav-footer-link nav-footer-check-btn" @onclick="ForceCheckAsync" disabled="@UpdateCheckService.CheckInProgress" title="@GetCheckButtonTooltip()" aria-label="@GetCheckButtonTooltip()">
|
||||
@if (UpdateCheckService.CheckInProgress)
|
||||
{
|
||||
<i class="bi bi-arrow-repeat nav-footer-spin" aria-hidden="true"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-repeat" aria-hidden="true"></i>
|
||||
}
|
||||
<i class="bi bi-arrow-repeat nav-footer-check-icon @(UpdateCheckService.CheckInProgress ? "nav-footer-spin" : null)" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
]</span>
|
||||
@@ -106,13 +101,23 @@
|
||||
return "Checking…";
|
||||
var last = UpdateCheckService.LastCheckTime;
|
||||
if (last.HasValue)
|
||||
return $"No new update available. Checked at {last.Value.ToLocalTime():t}. Check again.";
|
||||
{
|
||||
var local = last.Value.ToLocalTime();
|
||||
var format = local.Date == DateTime.Now.Date ? "t" : "g"; // time only today, short date+time otherwise
|
||||
return $"No new update available. Checked at {local.ToString(format)}. Check again.";
|
||||
}
|
||||
return "Check for updates";
|
||||
}
|
||||
|
||||
private async Task ForceCheckAsync()
|
||||
{
|
||||
await UpdateCheckService.CheckNowAsync();
|
||||
if (UpdateCheckService.LastCheckFailed)
|
||||
NotificationService.ShowError("Update check failed: " + (UpdateCheckService.LastCheckErrorMessage ?? "Unknown error."));
|
||||
else if (UpdateCheckService.NewerVersionAvailable)
|
||||
NotificationService.ShowSuccess($"New version {UpdateCheckService.NewerVersionTag} is available.");
|
||||
else
|
||||
NotificationService.ShowInfo("You're up to date.");
|
||||
}
|
||||
|
||||
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
|
||||
|
||||
@@ -190,6 +190,10 @@
|
||||
color: rgba(255, 255, 255, 0.9) !important;
|
||||
}
|
||||
|
||||
.nav-footer-check-icon {
|
||||
display: inline-block; /* required for transform animation */
|
||||
}
|
||||
|
||||
.nav-footer-spin {
|
||||
animation: nav-footer-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,17 @@
|
||||
@if (SettingsContext == SettingsModalContext.All)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Search settings</label>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<label class="form-label mb-0">Search settings</label>
|
||||
<div class="d-flex gap-1 ms-auto" role="group" aria-label="Expand or collapse all sections">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Expand all sections" aria-label="Expand all sections" @onclick="ExpandAllGroups">
|
||||
<i class="bi bi-arrows-expand" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary settings-expand-collapse-btn" title="Collapse all sections" aria-label="Collapse all sections" @onclick="CollapseAllGroups">
|
||||
<i class="bi bi-arrows-collapse" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="search" class="form-control" placeholder="Search..." autocomplete="off"
|
||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
|
||||
</div>
|
||||
@@ -136,8 +146,8 @@
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save">Save</button>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close"><i class="bi bi-x me-1" aria-hidden="true"></i>Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Extensions.Interop;
|
||||
using DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
@@ -55,7 +56,7 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
@@ -68,10 +69,10 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
{
|
||||
if (!_dialogInitialized)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("initDialog", _dialogRef, _dotNetRef);
|
||||
_dialogInitialized = true;
|
||||
}
|
||||
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
|
||||
await _jsModule.InvokeVoidAsyncSafe("showModal", _dialogRef);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +98,19 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ExpandAllGroups()
|
||||
{
|
||||
foreach (var g in GetVisibleGroups())
|
||||
_expandedGroupIds.Add(g.Id);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void CollapseAllGroups()
|
||||
{
|
||||
_expandedGroupIds.Clear();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private bool ShouldShowSection(string key)
|
||||
{
|
||||
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
|
||||
@@ -125,13 +139,15 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
}
|
||||
|
||||
await ClientSettingsService.SaveSettingsAsync(ClientSettingsFields.ToDto());
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsyncSafe("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -147,14 +163,8 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync failed"); }
|
||||
_jsModule = null;
|
||||
}
|
||||
await _jsModule.DisposeAsyncSafe();
|
||||
_jsModule = null;
|
||||
_dotNetRef?.Dispose();
|
||||
_dotNetRef = null;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.settings-expand-collapse-btn {
|
||||
padding: 0.25rem 0.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.settings-expand-collapse-btn .bi {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.settings-modal-dialog .modal-footer {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.125);
|
||||
|
||||
@@ -97,7 +97,7 @@ else
|
||||
<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>
|
||||
<td><button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button></td>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -203,7 +203,7 @@ else
|
||||
@if (editable)
|
||||
{
|
||||
<div class="table-card-row mt-2">
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)">Delete</button>
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -212,11 +212,11 @@ else
|
||||
</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>
|
||||
<button class="btn btn-primary" @onclick="Add"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add host</button>
|
||||
<button class="btn btn-primary" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
@if (_status.ReloadCommandConfigured)
|
||||
{
|
||||
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving">Reload config</button>
|
||||
<button class="btn btn-primary" @onclick="Reload" disabled="@_saving"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload config</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -253,7 +253,7 @@ else
|
||||
{
|
||||
<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>
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -286,8 +286,8 @@ else
|
||||
<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>
|
||||
<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)"><i class="bi bi-plus me-1" aria-hidden="true"></i>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)"><i class="bi bi-slash-circle me-1" aria-hidden="true"></i>Ignore</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -309,8 +309,8 @@ else
|
||||
<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>
|
||||
<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)"><i class="bi bi-plus me-1" aria-hidden="true"></i>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)"><i class="bi bi-slash-circle me-1" aria-hidden="true"></i>Ignore</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,14 +318,14 @@ else
|
||||
</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>
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>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" />
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -416,6 +416,16 @@ else
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RefreshStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
_error = null;
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject INotificationService NotificationService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
|
||||
<PageTitle>Dnsmasq | @AppTitle</PageTitle>
|
||||
|
||||
@@ -38,11 +40,11 @@ else if (_status != null)
|
||||
<li>
|
||||
@if (f.IsManaged)
|
||||
{
|
||||
<span class="badge bg-primary me-1">managed</span>
|
||||
<StatusBadge Kind="StatusBadgeKind.Managed" Text="managed" Class="me-1" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary me-1">@(f.Source.ToString().ToLowerInvariant())</span>
|
||||
<StatusBadge Kind="StatusBadgeKind.SourceMain" Text="@(f.Source.ToString().ToLowerInvariant())" Class="me-1" />
|
||||
}
|
||||
@f.Path
|
||||
@if (exists == true) { <text> ✓</text> }
|
||||
@@ -56,7 +58,7 @@ else if (_status != null)
|
||||
<li><strong>Managed file:</strong> @(_status.ManagedFilePath ?? "—") @(_status.ManagedFilePathExists ? "✓" : "(not created yet)")</li>
|
||||
}
|
||||
</ul>
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.All" ShowSearchBox="true" />
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.All" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
@if (_status.ReloadCommandConfigured)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mt-3">
|
||||
@@ -65,16 +67,12 @@ else if (_status != null)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>
|
||||
}
|
||||
@(_reloading ? "Reloading…" : "Reload config")
|
||||
</button>
|
||||
@if (!string.IsNullOrEmpty(_reloadMessage))
|
||||
{
|
||||
<span class="@(_reloadSuccess ? "text-success" : "text-danger") small">@_reloadMessage</span>
|
||||
}
|
||||
@if (!_reloadSuccess && _reloadDetail != null)
|
||||
{
|
||||
<pre class="pre-block mt-2 mb-0" style="max-height: 10rem;">@_reloadDetail</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -92,9 +90,6 @@ else if (_status != null)
|
||||
private string? _error;
|
||||
private bool _loading = true;
|
||||
private bool _reloading;
|
||||
private string? _reloadMessage;
|
||||
private string? _reloadDetail;
|
||||
private bool _reloadSuccess;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
@@ -150,26 +145,38 @@ else if (_status != null)
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task RefreshStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync(_cts.Token);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Component disposed (e.g. navigated away) while callback was pending; ignore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadDnsmasq()
|
||||
{
|
||||
if (_status == null || !_status.ReloadCommandConfigured || _reloading) return;
|
||||
_reloading = true;
|
||||
_reloadMessage = null;
|
||||
_reloadDetail = null;
|
||||
try
|
||||
{
|
||||
var token = _cts.Token;
|
||||
var result = await ReloadClient.ReloadAsync(token);
|
||||
if (result.Success)
|
||||
{
|
||||
_reloadMessage = "Reloaded.";
|
||||
_reloadSuccess = true;
|
||||
NotificationService.ShowSuccess("Reloaded.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_reloadMessage = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + ".";
|
||||
_reloadDetail = BuildReloadDetail(result);
|
||||
_reloadSuccess = false;
|
||||
var msg = "Reload failed" + (result.ExitCode != 0 ? " (exit " + result.ExitCode + ")" : "") + ".";
|
||||
if (!string.IsNullOrEmpty(result.StdErr))
|
||||
msg += " " + result.StdErr;
|
||||
NotificationService.ShowError(msg);
|
||||
}
|
||||
try
|
||||
{
|
||||
@@ -183,7 +190,7 @@ else if (_status != null)
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_reloadMessage == null) { _reloadMessage = ex.Message; _reloadSuccess = false; }
|
||||
NotificationService.ShowError("Reload failed: " + ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -191,14 +198,6 @@ else if (_status != null)
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildReloadDetail(ReloadResult r)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(r.StdOut)) parts.Add("stdout:\n" + r.StdOut);
|
||||
if (!string.IsNullOrEmpty(r.StdErr)) parts.Add("stderr:\n" + r.StdErr);
|
||||
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
||||
|
||||
@@ -19,7 +19,7 @@ else
|
||||
{
|
||||
<section class="page-section mb-4">
|
||||
<h2 class="page-section-title">Config affecting hosts</h2>
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" />
|
||||
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Hosts" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
|
||||
</section>
|
||||
@if (string.IsNullOrEmpty(_status.ManagedHostsFilePath))
|
||||
{
|
||||
@@ -45,7 +45,7 @@ else
|
||||
}
|
||||
|
||||
<HostsFileSection Path="@(_status.ManagedHostsFilePath ?? "")" Entries="_entries" IsEditable="true" IsFirst="true" />
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving">Save</button>
|
||||
<button class="btn btn-primary mt-2" @onclick="Save" disabled="@_saving"><i class="bi bi-floppy-fill me-1" aria-hidden="true"></i>Save</button>
|
||||
|
||||
@if (_readOnlyHosts?.Count > 0)
|
||||
{
|
||||
@@ -90,6 +90,16 @@ else
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task RefreshStatusAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
_error = null;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@namespace DnsmasqWebUI.Components.Shared
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
@inject INotificationService NotificationService
|
||||
@implements IDisposable
|
||||
|
||||
@if (_toasts.Count > 0)
|
||||
{
|
||||
<div class="notification-host" aria-live="polite">
|
||||
@foreach (var item in _toasts)
|
||||
{
|
||||
<NotificationToast @key="item.Id"
|
||||
Id="@item.Id"
|
||||
Kind="@item.Kind"
|
||||
Message="@item.Message"
|
||||
AutoDismissMs="4500"
|
||||
OnDismissRequested="Dismiss" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<NotificationItem> _toasts = new();
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NotificationService.NotificationsChanged += OnNotificationsChanged;
|
||||
_toasts = NotificationService.GetSnapshot().ToList();
|
||||
}
|
||||
|
||||
private void OnNotificationsChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_toasts = NotificationService.GetSnapshot().ToList();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task Dismiss(int id)
|
||||
{
|
||||
NotificationService.Dismiss(id);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
NotificationService.NotificationsChanged -= OnNotificationsChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.notification-host {
|
||||
position: fixed;
|
||||
top: var(--app-spacing-md, 1rem);
|
||||
right: var(--app-spacing-md, 1rem);
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-host > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
@namespace DnsmasqWebUI.Components.Shared
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||
|
||||
<div class="notification-toast notification-toast-@Kind.ToString().ToLowerInvariant()" role="alert">
|
||||
<span class="notification-toast-accent" aria-hidden="true"></span>
|
||||
<span class="notification-toast-icon" aria-hidden="true">@Icon</span>
|
||||
<span class="notification-toast-message">@Message</span>
|
||||
<button type="button" class="notification-toast-dismiss" aria-label="Dismiss" @onclick="Dismiss">×</button>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public NotificationKind Kind { get; set; }
|
||||
[Parameter] public string Message { get; set; } = "";
|
||||
[Parameter] public int Id { get; set; }
|
||||
[Parameter] public int AutoDismissMs { get; set; } = 4500;
|
||||
[Parameter] public EventCallback<int> OnDismissRequested { get; set; }
|
||||
|
||||
private string Icon => Kind switch
|
||||
{
|
||||
NotificationKind.Success => "✓",
|
||||
NotificationKind.Error => "✕",
|
||||
NotificationKind.Warning => "⚠",
|
||||
NotificationKind.Info => "ℹ",
|
||||
_ => "•"
|
||||
};
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
{
|
||||
if (firstRender && AutoDismissMs > 0)
|
||||
_ = ScheduleAutoDismiss();
|
||||
}
|
||||
|
||||
private async Task ScheduleAutoDismiss()
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(AutoDismissMs);
|
||||
await InvokeAsync(() => OnDismissRequested.InvokeAsync(Id));
|
||||
}
|
||||
catch (TaskCanceledException) { }
|
||||
}
|
||||
|
||||
private async Task Dismiss() => await OnDismissRequested.InvokeAsync(Id);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* Toast: compact popover with left accent, icon, message, dismiss. Modern, distinct. */
|
||||
.notification-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
min-width: 16rem;
|
||||
max-width: 28rem;
|
||||
padding: 0.75rem 1rem;
|
||||
padding-left: 0.5rem;
|
||||
background: var(--notification-bg);
|
||||
color: var(--notification-fg);
|
||||
border-radius: var(--app-radius-lg, 0.5rem);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.notification-toast-accent {
|
||||
width: 4px;
|
||||
align-self: stretch;
|
||||
border-radius: 2px;
|
||||
background: var(--notification-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-toast-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.notification-toast-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-toast-dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
opacity: 0.75;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--app-radius, 0.375rem);
|
||||
}
|
||||
|
||||
.notification-toast-dismiss:hover {
|
||||
opacity: 1;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.notification-toast-success {
|
||||
--notification-bg: #f0fdf4;
|
||||
--notification-fg: #166534;
|
||||
--notification-accent: #22c55e;
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.notification-toast-error {
|
||||
--notification-bg: #fef2f2;
|
||||
--notification-fg: #b91c1c;
|
||||
--notification-accent: #ef4444;
|
||||
}
|
||||
|
||||
/* Warning */
|
||||
.notification-toast-warning {
|
||||
--notification-bg: #fffbeb;
|
||||
--notification-fg: #b45309;
|
||||
--notification-accent: #f59e0b;
|
||||
}
|
||||
|
||||
/* Info */
|
||||
.notification-toast-info {
|
||||
--notification-bg: #eff6ff;
|
||||
--notification-fg: #1d4ed8;
|
||||
--notification-accent: #3b82f6;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
@namespace DnsmasqWebUI.Components.Shared
|
||||
|
||||
@if (Clickable)
|
||||
{
|
||||
<button type="button"
|
||||
class="@CssClass"
|
||||
title="@Title"
|
||||
aria-label="@(AriaLabel ?? Text)"
|
||||
disabled="@Disabled"
|
||||
id="@(string.IsNullOrEmpty(Id) ? null : Id)"
|
||||
@onclick="OnClick"
|
||||
@onclick:stopPropagation="@StopPropagation"
|
||||
@onmousedown:preventDefault="@PreventBlurOnMouseDown"
|
||||
@onmouseleave="OnMouseLeave">
|
||||
@ChildContent
|
||||
@if (ChildContent == null && !string.IsNullOrWhiteSpace(IconClass))
|
||||
{
|
||||
<i class="@IconClass" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">@Text</span>
|
||||
}
|
||||
else if (ChildContent == null)
|
||||
{
|
||||
@Text
|
||||
}
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="@CssClass" title="@Title" id="@(string.IsNullOrEmpty(Id) ? null : Id)">
|
||||
@ChildContent
|
||||
@if (ChildContent == null && !string.IsNullOrWhiteSpace(IconClass))
|
||||
{
|
||||
<i class="@IconClass" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">@Text</span>
|
||||
}
|
||||
else if (ChildContent == null)
|
||||
{
|
||||
@Text
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public StatusBadgeKind Kind { get; set; }
|
||||
[Parameter] public string Text { get; set; } = "";
|
||||
[Parameter] public bool Clickable { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public string? AriaLabel { get; set; }
|
||||
[Parameter] public string? Id { get; set; }
|
||||
[Parameter] public string? IconClass { get; set; }
|
||||
[Parameter] public bool StopPropagation { get; set; } = true;
|
||||
[Parameter] public bool PreventBlurOnMouseDown { get; set; }
|
||||
[Parameter] public EventCallback OnClick { get; set; }
|
||||
[Parameter] public EventCallback OnMouseLeave { get; set; }
|
||||
[Parameter] public string? Class { get; set; }
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
private string CssClass
|
||||
{
|
||||
get
|
||||
{
|
||||
var kindClass = Kind switch
|
||||
{
|
||||
StatusBadgeKind.Managed => "bg-primary",
|
||||
StatusBadgeKind.SourceMain => "bg-secondary",
|
||||
StatusBadgeKind.SourceInclude => "bg-secondary",
|
||||
StatusBadgeKind.Editable => "bg-primary",
|
||||
StatusBadgeKind.ReadOnly => "bg-secondary",
|
||||
StatusBadgeKind.Pending => "bg-warning text-dark",
|
||||
StatusBadgeKind.ActionOk => "bg-success",
|
||||
StatusBadgeKind.ActionCancel => "bg-secondary",
|
||||
StatusBadgeKind.ActionRevert => "bg-danger",
|
||||
StatusBadgeKind.NeutralPill => "bg-light text-dark border rounded-pill",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
return $"badge status-badge {kindClass} {Class}".Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Shared badge: compact label size, alignment, no button border when clickable */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.status-badge + .status-badge {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.status-badge-inline {
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
/* Clickable badges: pointer, hover lift + glow, active press, focus for a11y */
|
||||
button.status-badge {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease-out, filter 0.2s ease-out, box-shadow 0.2s ease-out;
|
||||
}
|
||||
|
||||
button.status-badge:hover:not(:disabled) {
|
||||
transform: scale(1.06) translateY(-2px);
|
||||
filter: brightness(0.94);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
button.status-badge:active:not(:disabled) {
|
||||
transform: scale(0.94) translateY(0);
|
||||
filter: brightness(0.88);
|
||||
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
button.status-badge:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button.status-badge:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DnsmasqWebUI.Components.Shared;
|
||||
|
||||
/// <summary>Semantic badge intent for <see cref="StatusBadge"/>; maps to consistent CSS and behavior.</summary>
|
||||
public enum StatusBadgeKind
|
||||
{
|
||||
Managed,
|
||||
SourceMain,
|
||||
SourceInclude,
|
||||
Editable,
|
||||
ReadOnly,
|
||||
Pending,
|
||||
ActionOk,
|
||||
ActionCancel,
|
||||
ActionRevert,
|
||||
NeutralPill
|
||||
}
|
||||
@@ -7,13 +7,28 @@
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using DnsmasqWebUI
|
||||
@using DnsmasqWebUI.Extensions.DependencyInjection
|
||||
@using DnsmasqWebUI.Extensions.Hosting
|
||||
@using DnsmasqWebUI.Extensions.Interop
|
||||
@using DnsmasqWebUI.Models.Client
|
||||
@using DnsmasqWebUI.Models.Client.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Leases.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Logs.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions
|
||||
@using DnsmasqWebUI.Infrastructure.Services.Updates.Abstractions
|
||||
@using DnsmasqWebUI.Components
|
||||
@using DnsmasqWebUI.Components.Shared
|
||||
@using DnsmasqWebUI.Components.Modals
|
||||
@using DnsmasqWebUI.Components.Dnsmasq
|
||||
@using DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
|
||||
@using DnsmasqWebUI.Components.Dnsmasq.Status
|
||||
@using DnsmasqWebUI.Components.Dnsmasq.Logs
|
||||
@using DnsmasqWebUI.Components.Dnsmasq.AppLogs
|
||||
@using DnsmasqWebUI.Components.EffectiveConfig
|
||||
@using DnsmasqWebUI.Components.Hosts
|
||||
@using DnsmasqWebUI.Infrastructure
|
||||
@using DnsmasqWebUI.Infrastructure.Helpers.Config
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Leases.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Common.Process.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
@@ -42,8 +43,10 @@ public class StatusController : ControllerBase
|
||||
var (dhcpRangeStart, dhcpRangeEnd) = _configSetService.GetDhcpRange();
|
||||
var systemHostsPath = _options.SystemHostsPath?.Trim();
|
||||
|
||||
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, TimeSpan.FromSeconds(5), ct);
|
||||
var dnsmasqStatus = statusResult.ExitCode == 0 ? "active" : (statusResult.ExitCode.HasValue ? "inactive" : "unknown");
|
||||
var statusResult = await _processRunner.RunAsync(_options.StatusCommand, _options.StatusTimeout, ct);
|
||||
var dnsmasqStatus = statusResult.TimedOut || statusResult.ExitCode is null || statusResult.ExceptionMessage is not null
|
||||
? "unknown"
|
||||
: statusResult.ExitCode == 0 ? "active" : "inactive";
|
||||
var statusCommandStdout = string.IsNullOrWhiteSpace(statusResult.Stdout) ? null : statusResult.Stdout.Trim();
|
||||
var statusCommandStderr = string.IsNullOrWhiteSpace(statusResult.Stderr) ? null : statusResult.Stderr.Trim();
|
||||
if (statusResult.ExceptionMessage != null)
|
||||
@@ -51,19 +54,19 @@ public class StatusController : ControllerBase
|
||||
|
||||
var showTask = string.IsNullOrWhiteSpace(_options.StatusShowCommand)
|
||||
? Task.FromResult(new ProcessRunResult(null, "", "", false))
|
||||
: _processRunner.RunAsync(_options.StatusShowCommand, TimeSpan.FromSeconds(5), ct);
|
||||
: _processRunner.RunAsync(_options.StatusShowCommand, _options.StatusShowTimeout, ct);
|
||||
var logsTask = string.IsNullOrWhiteSpace(_options.LogsCommand)
|
||||
? Task.FromResult(new ProcessRunResult(null, "", "", false))
|
||||
: _processRunner.RunAsync(_options.LogsCommand, TimeSpan.FromSeconds(10), ct);
|
||||
: _processRunner.RunAsync(_options.LogsCommand, _options.LogsTimeout, ct);
|
||||
await Task.WhenAll(showTask, logsTask);
|
||||
|
||||
var showResult = await showTask;
|
||||
var logsResult = await logsTask;
|
||||
var statusShowOutput = !string.IsNullOrWhiteSpace(_options.StatusShowCommand)
|
||||
? FormatStatusShowOutput(showResult.Stdout + (showResult.TimedOut ? "\n(Command timed out.)" : ""))
|
||||
? FormatStatusShowOutput(AppendCommandFailureMarker(showResult.Stdout, showResult.TimedOut, showResult.ExceptionMessage))
|
||||
: null;
|
||||
var logsOutput = !string.IsNullOrWhiteSpace(_options.LogsCommand)
|
||||
? logsResult.Stdout + (logsResult.TimedOut ? "\n(Command timed out.)" : "")
|
||||
? AppendCommandFailureMarker(logsResult.Stdout, logsResult.TimedOut, logsResult.ExceptionMessage)
|
||||
: null;
|
||||
|
||||
var status = new DnsmasqServiceStatus(
|
||||
@@ -83,7 +86,7 @@ public class StatusController : ControllerBase
|
||||
LeasesPathConfigured: !string.IsNullOrEmpty(effectiveLeasesPath),
|
||||
LeasesPathExists: !string.IsNullOrEmpty(effectiveLeasesPath) && System.IO.File.Exists(effectiveLeasesPath),
|
||||
ConfigFiles: set.Files.Count > 0 ? set.Files : null,
|
||||
ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.ReloadCommand),
|
||||
ReloadCommandConfigured: !string.IsNullOrWhiteSpace(_options.RestartCommand) || !string.IsNullOrWhiteSpace(_options.ReloadCommand),
|
||||
StatusCommandConfigured: !string.IsNullOrWhiteSpace(_options.StatusCommand),
|
||||
StatusShowConfigured: !string.IsNullOrWhiteSpace(_options.StatusShowCommand),
|
||||
LogsConfigured: !string.IsNullOrWhiteSpace(_options.LogsCommand),
|
||||
@@ -108,6 +111,15 @@ public class StatusController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private static string AppendCommandFailureMarker(string output, bool timedOut, string? exceptionMessage)
|
||||
{
|
||||
if (timedOut)
|
||||
return output + "\n(Command timed out.)";
|
||||
if (!string.IsNullOrEmpty(exceptionMessage))
|
||||
return output + "\n(Command failed: " + exceptionMessage + ")";
|
||||
return output;
|
||||
}
|
||||
|
||||
/// <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: ".
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>0.0.5</Version>
|
||||
<Version>0.0.6</Version>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
|
||||
<DisableBuildCompression>true</DisableBuildCompression>
|
||||
|
||||
+3
-3
@@ -1,13 +1,13 @@
|
||||
using System.Threading.Channels;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Logs;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Logs.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions;
|
||||
namespace DnsmasqWebUI.Extensions.DependencyInjection;
|
||||
|
||||
/// <summary>
|
||||
/// App logs setup: buffer, channel, and provider.
|
||||
+29
-2
@@ -1,11 +1,12 @@
|
||||
using System.Reflection;
|
||||
using DnsmasqWebUI.Infrastructure.Client;
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions;
|
||||
namespace DnsmasqWebUI.Extensions.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
@@ -59,12 +60,38 @@ public static class ServiceCollectionExtensions
|
||||
/// Scans the assembly for types implementing application marker interfaces
|
||||
/// (<see cref="IApplicationScopedService"/>, <see cref="IApplicationScopedConcrete"/>, <see cref="IApplicationSingleton"/>, <see cref="IApplicationHostedService"/>, etc.)
|
||||
/// and registers each with the configured lifetime. Hosted services are registered via <c>AddHostedService<T></c>.
|
||||
/// Also registers all <see cref="IApplicationOptionsValidator{TOptions}"/> implementations as <see cref="IValidateOptions{TOptions}"/>.
|
||||
/// Skips open generics; requires exactly one public implementation per interface (scoped/singleton).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
foreach (var (markerInterface, register) in ApplicationRegistrations)
|
||||
ScanAndRegister(services, markerInterface, register);
|
||||
AddOptionsValidatorsFromAssembly(services, Assembly.GetExecutingAssembly());
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the given assembly for types implementing <see cref="IApplicationOptionsValidator{TOptions}"/> and registers each
|
||||
/// as <see cref="IValidateOptions{TOptions}"/> singleton so the options framework picks them up.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddOptionsValidatorsFromAssembly(this IServiceCollection services, Assembly assembly)
|
||||
{
|
||||
var markerDef = typeof(IApplicationOptionsValidator<>);
|
||||
var validateOptionsDef = typeof(IValidateOptions<>);
|
||||
var validators = assembly.GetTypes()
|
||||
.Where(t => t.IsClass && t.IsPublic && !t.IsAbstract && !t.IsGenericTypeDefinition)
|
||||
.Select(t => (Impl: t, MarkerInterface: t.GetInterfaces().FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == markerDef)))
|
||||
.Where(x => x.MarkerInterface != null)
|
||||
.ToList();
|
||||
|
||||
foreach (var (impl, markerInterface) in validators)
|
||||
{
|
||||
// Register as IValidateOptions<TOptions> so options validation finds it
|
||||
var optionsType = markerInterface!.GetGenericArguments()[0];
|
||||
var validateOptionsType = validateOptionsDef.MakeGenericType(optionsType);
|
||||
services.AddSingleton(validateOptionsType, impl);
|
||||
}
|
||||
return services;
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions;
|
||||
namespace DnsmasqWebUI.Extensions.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// CORS configuration from app settings (Cors:Enabled, Cors:PolicyName, Cors:AllowAnyOrigin, etc.).
|
||||
+1
-1
@@ -2,7 +2,7 @@ using System.Net;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions;
|
||||
namespace DnsmasqWebUI.Extensions.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Middleware configuration from app settings (forwarded headers, HTTPS, CORS).
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Safe JS module interop helpers that swallow disconnect/cancel/dispose exceptions.
|
||||
/// Use for best-effort or teardown calls (dispose, cleanup, hub callbacks, error toasts).
|
||||
/// </summary>
|
||||
public static class JSObjectReferenceExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes a void method on the module without throwing on circuit disconnect, cancel, or dispose.
|
||||
/// Returns true if the call succeeded, false otherwise.
|
||||
/// </summary>
|
||||
public static async ValueTask<bool> InvokeVoidAsyncSafe(
|
||||
this IJSObjectReference module,
|
||||
string identifier,
|
||||
params object?[]? args)
|
||||
{
|
||||
try
|
||||
{
|
||||
await module.InvokeVoidAsync(identifier, args ?? []);
|
||||
return true;
|
||||
}
|
||||
catch (JSDisconnectedException) { return false; }
|
||||
catch (TaskCanceledException) { return false; }
|
||||
catch (ObjectDisposedException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a method on the module that returns a value, without throwing on circuit disconnect, cancel, or dispose.
|
||||
/// Returns default when the call fails.
|
||||
/// </summary>
|
||||
public static async ValueTask<T?> InvokeAsyncSafe<T>(
|
||||
this IJSObjectReference module,
|
||||
string identifier,
|
||||
params object?[]? args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await module.InvokeAsync<T>(identifier, args ?? []);
|
||||
}
|
||||
catch (JSDisconnectedException) { return default; }
|
||||
catch (TaskCanceledException) { return default; }
|
||||
catch (ObjectDisposedException) { return default; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the module reference without throwing. Use in DisposeAsync when the circuit may already be gone.
|
||||
/// </summary>
|
||||
public static async ValueTask DisposeAsyncSafe(this IJSObjectReference? module)
|
||||
{
|
||||
if (module == null) return;
|
||||
try
|
||||
{
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (TaskCanceledException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
catch (InvalidOperationException) { }
|
||||
catch (JSException) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions.Interop;
|
||||
|
||||
/// <summary>
|
||||
/// Safe JS interop helpers that swallow disconnect/cancel/dispose exceptions.
|
||||
/// Use for best-effort or teardown calls; use raw InvokeVoidAsync/InvokeAsync when failure must propagate.
|
||||
/// </summary>
|
||||
public static class JSRuntimeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Invokes a void JS function without throwing on circuit disconnect, cancel, or dispose.
|
||||
/// Returns true if the call succeeded, false otherwise.
|
||||
/// </summary>
|
||||
public static async ValueTask<bool> InvokeVoidAsyncSafe(
|
||||
this IJSRuntime js,
|
||||
string identifier,
|
||||
CancellationToken cancellationToken = default,
|
||||
params object?[]? args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var a = args ?? [];
|
||||
if (cancellationToken == default)
|
||||
await js.InvokeVoidAsync(identifier, a);
|
||||
else
|
||||
await js.InvokeVoidAsync(identifier, cancellationToken, a);
|
||||
return true;
|
||||
}
|
||||
catch (JSDisconnectedException) { return false; }
|
||||
catch (TaskCanceledException) { return false; }
|
||||
catch (ObjectDisposedException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a JS function that returns a value, without throwing on circuit disconnect, cancel, or dispose.
|
||||
/// Returns default when the call fails (e.g. circuit gone).
|
||||
/// </summary>
|
||||
public static async ValueTask<T?> InvokeAsyncSafe<T>(
|
||||
this IJSRuntime js,
|
||||
string identifier,
|
||||
CancellationToken cancellationToken = default,
|
||||
params object?[]? args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var a = args ?? [];
|
||||
if (cancellationToken == default)
|
||||
return await js.InvokeAsync<T>(identifier, a);
|
||||
return await js.InvokeAsync<T>(identifier, cancellationToken, a);
|
||||
}
|
||||
catch (JSDisconnectedException) { return default; }
|
||||
catch (TaskCanceledException) { return default; }
|
||||
catch (ObjectDisposedException) { return default; }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Client;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Net;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Registration.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Client;
|
||||
|
||||
|
||||
@@ -148,6 +148,4 @@ public static class DnsmasqConfKeys
|
||||
// --- Niche / platform (Linux conntrack mark for UBus/query filtering) ---
|
||||
public const string Conntrack = "conntrack";
|
||||
|
||||
/// <summary>Keys collected for effective config "server/local" multi-value (order preserved).</summary>
|
||||
public static readonly string[] ServerLocalKeys = { Server, Local };
|
||||
}
|
||||
|
||||
@@ -8,13 +8,6 @@ namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
/// </summary>
|
||||
public static class DnsmasqOptionTooltips
|
||||
{
|
||||
/// <summary>Display label used in EffectiveConfigFieldBuilder for server/local multi-value.</summary>
|
||||
public const string ServerLocalLabel = "server / local";
|
||||
|
||||
/// <summary>Option name to option-help file key. "server / local" → "server"; others unchanged.</summary>
|
||||
public static string GetOptionHelpKey(string optionName) =>
|
||||
string.Equals(optionName, ServerLocalLabel, StringComparison.Ordinal) ? "server" : optionName;
|
||||
|
||||
private static readonly FrozenDictionary<string, string> Tooltips = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
// --- Hosts ---
|
||||
@@ -32,7 +25,8 @@ public static class DnsmasqOptionTooltips
|
||||
[DnsmasqConfKeys.DomainNeeded] = "Never forward plain names (without a domain) to upstream.",
|
||||
[DnsmasqConfKeys.Port] = "Listen on this port for DNS queries (default 53).",
|
||||
[DnsmasqConfKeys.LogQueries] = "Log DNS queries. Optional value: extra, proto, or auth.",
|
||||
[ServerLocalLabel] = "Upstream DNS servers (server=) and local-only domains (local=). Order is preserved.",
|
||||
[DnsmasqConfKeys.Server] = "Upstream DNS servers. Order is preserved.",
|
||||
[DnsmasqConfKeys.Local] = "Local-only domains (queries not forwarded). Order is preserved.",
|
||||
[DnsmasqConfKeys.RevServer] = "Reverse DNS server (e.g. rev-server=1.2.3.0/24,192.168.0.1). Can repeat.",
|
||||
[DnsmasqConfKeys.Address] = "Map a domain or hostname to an IP (e.g. for ad blocking or local names). Can repeat.",
|
||||
[DnsmasqConfKeys.ResolvFile] = "File(s) to read upstream server addresses from (e.g. from DHCP). Can repeat.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
@@ -11,7 +12,7 @@ public static class EffectiveConfigFieldBuilder
|
||||
private static EffectiveDnsmasqConfig? Config(DnsmasqServiceStatus? s) => s?.EffectiveConfig;
|
||||
private static EffectiveConfigSources? Sources(DnsmasqServiceStatus? s) => s?.EffectiveConfigSources;
|
||||
|
||||
/// <summary>Builds getItems from config/source list selectors; used by Multi() for fields that have both.</summary>
|
||||
/// <summary>Builds getItems from config/source list selectors; used by AddDescriptor for multi-value fields that have both config and source lists.</summary>
|
||||
private static Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?> Items(
|
||||
Func<EffectiveDnsmasqConfig?, IReadOnlyList<string>?> getValues,
|
||||
Func<EffectiveConfigSources?, IReadOnlyList<ValueWithSource>?> getWithSource)
|
||||
@@ -37,7 +38,7 @@ public static class EffectiveConfigFieldBuilder
|
||||
public static string SectionCache => EffectiveConfigSections.SectionCache;
|
||||
public static string SectionProcess => EffectiveConfigSections.SectionProcess;
|
||||
|
||||
public static IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus? status)
|
||||
public static IReadOnlyList<EffectiveConfigFieldDescriptor> BuildFieldDescriptors(DnsmasqServiceStatus? status, IEffectiveConfigRenderFragmentRegistry? registry = null)
|
||||
{
|
||||
if (status == null || status.EffectiveConfig == null)
|
||||
return Array.Empty<EffectiveConfigFieldDescriptor>();
|
||||
@@ -45,142 +46,159 @@ public static class EffectiveConfigFieldBuilder
|
||||
var list = new List<EffectiveConfigFieldDescriptor>();
|
||||
|
||||
// Hosts
|
||||
list.Add(Single(DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null));
|
||||
list.Add(Multi(DnsmasqConfKeys.AddnHosts, status, s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths)));
|
||||
list.Add(Single(DnsmasqConfKeys.Hostsdir, status, s => Config(s)?.HostsdirPath, s => Sources(s)?.HostsdirPath, null));
|
||||
list.Add(Single(DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoHosts, status, s => Config(s)?.NoHosts, s => Sources(s)?.NoHosts, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AddnHosts, status, null, null, s => ToValueWithSourceList(s?.AddnHostsPaths, Sources(s)?.AddnHostsPaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Hostsdir, status, s => Config(s)?.HostsdirPath, s => Sources(s)?.HostsdirPath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ReadEthers, status, s => Config(s)?.ReadEthers, s => Sources(s)?.ReadEthers, null);
|
||||
|
||||
// Resolver / DNS
|
||||
list.Add(Multi(DnsmasqOptionTooltips.ServerLocalLabel, status, Items(ec => ec?.ServerLocalValues, src => src?.ServerLocalValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.RevServer, status, Items(ec => ec?.RevServerValues, src => src?.RevServerValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.Address, status, Items(ec => ec?.AddressValues, src => src?.AddressValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.ResolvFile, status, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles)));
|
||||
list.Add(Single(DnsmasqConfKeys.NoResolv, status, s => Config(s)?.NoResolv, s => Sources(s)?.NoResolv, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DomainNeeded, status, s => Config(s)?.DomainNeeded, s => Sources(s)?.DomainNeeded, null));
|
||||
list.Add(Single(DnsmasqConfKeys.Port, status, s => Config(s)?.Port, s => Sources(s)?.Port, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LogQueries, status, s => Config(s)?.LogQueries, s => Sources(s)?.LogQueries, null));
|
||||
list.Add(Single(DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null));
|
||||
list.Add(Single(DnsmasqConfKeys.AllServers, status, s => Config(s)?.AllServers, s => Sources(s)?.AllServers, null));
|
||||
list.Add(Single(DnsmasqConfKeys.AuthTtl, status, s => Config(s)?.AuthTtl, s => Sources(s)?.AuthTtl, null));
|
||||
list.Add(Single(DnsmasqConfKeys.EdnsPacketMax, status, s => Config(s)?.EdnsPacketMax, s => Sources(s)?.EdnsPacketMax, null));
|
||||
list.Add(Single(DnsmasqConfKeys.QueryPort, status, s => Config(s)?.QueryPort, s => Sources(s)?.QueryPort, null));
|
||||
list.Add(Single(DnsmasqConfKeys.PortLimit, status, s => Config(s)?.PortLimit, s => Sources(s)?.PortLimit, null));
|
||||
list.Add(Single(DnsmasqConfKeys.MinPort, status, s => Config(s)?.MinPort, s => Sources(s)?.MinPort, null));
|
||||
list.Add(Single(DnsmasqConfKeys.MaxPort, status, s => Config(s)?.MaxPort, s => Sources(s)?.MaxPort, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DnsLoopDetect, status, s => Config(s)?.DnsLoopDetect, s => Sources(s)?.DnsLoopDetect, null));
|
||||
list.Add(Single(DnsmasqConfKeys.StopDnsRebind, status, s => Config(s)?.StopDnsRebind, s => Sources(s)?.StopDnsRebind, null));
|
||||
list.Add(Single(DnsmasqConfKeys.RebindLocalhostOk, status, s => Config(s)?.RebindLocalhostOk, s => Sources(s)?.RebindLocalhostOk, null));
|
||||
list.Add(Single(DnsmasqConfKeys.ClearOnReload, status, s => Config(s)?.ClearOnReload, s => Sources(s)?.ClearOnReload, null));
|
||||
list.Add(Single(DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null));
|
||||
list.Add(Single(DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null));
|
||||
list.Add(Multi(DnsmasqConfKeys.RebindDomainOk, status, Items(ec => ec?.RebindDomainOkValues, src => src?.RebindDomainOkValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.BogusNxdomain, status, Items(ec => ec?.BogusNxdomainValues, src => src?.BogusNxdomainValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.IgnoreAddress, status, Items(ec => ec?.IgnoreAddressValues, src => src?.IgnoreAddressValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.Alias, status, Items(ec => ec?.AliasValues, src => src?.AliasValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.FilterRr, status, Items(ec => ec?.FilterRrValues, src => src?.FilterRrValues)));
|
||||
list.Add(Single(DnsmasqConfKeys.Filterwin2k, status, s => Config(s)?.Filterwin2k, s => Sources(s)?.Filterwin2k, null));
|
||||
list.Add(Single(DnsmasqConfKeys.FilterA, status, s => Config(s)?.FilterA, s => Sources(s)?.FilterA, null));
|
||||
list.Add(Single(DnsmasqConfKeys.FilterAaaa, status, s => Config(s)?.FilterAaaa, s => Sources(s)?.FilterAaaa, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LocaliseQueries, status, s => Config(s)?.LocaliseQueries, s => Sources(s)?.LocaliseQueries, null));
|
||||
list.Add(Single(DnsmasqConfKeys.FastDnsRetry, status, s => Config(s)?.FastDnsRetry, s => Sources(s)?.FastDnsRetry, null));
|
||||
list.Add(Multi(DnsmasqConfKeys.Ipset, status, Items(ec => ec?.IpsetValues, src => src?.IpsetValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.Nftset, status, Items(ec => ec?.NftsetValues, src => src?.NftsetValues)));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Server, status, null, null, Items(ec => ec?.ServerValues, src => src?.ServerValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Local, status, null, null, Items(ec => ec?.LocalValues, src => src?.LocalValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.RevServer, status, null, null, Items(ec => ec?.RevServerValues, src => src?.RevServerValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Address, status, null, null, Items(ec => ec?.AddressValues, src => src?.AddressValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ResolvFile, status, null, null, Items(ec => ec?.ResolvFiles, src => src?.ResolvFiles));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoResolv, status, s => Config(s)?.NoResolv, s => Sources(s)?.NoResolv, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DomainNeeded, status, s => Config(s)?.DomainNeeded, s => Sources(s)?.DomainNeeded, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Port, status, s => Config(s)?.Port, s => Sources(s)?.Port, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LogQueries, status, s => Config(s)?.LogQueries, s => Sources(s)?.LogQueries, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.StrictOrder, status, s => Config(s)?.StrictOrder, s => Sources(s)?.StrictOrder, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AllServers, status, s => Config(s)?.AllServers, s => Sources(s)?.AllServers, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AuthTtl, status, s => Config(s)?.AuthTtl, s => Sources(s)?.AuthTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.EdnsPacketMax, status, s => Config(s)?.EdnsPacketMax, s => Sources(s)?.EdnsPacketMax, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.QueryPort, status, s => Config(s)?.QueryPort, s => Sources(s)?.QueryPort, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.PortLimit, status, s => Config(s)?.PortLimit, s => Sources(s)?.PortLimit, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MinPort, status, s => Config(s)?.MinPort, s => Sources(s)?.MinPort, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MaxPort, status, s => Config(s)?.MaxPort, s => Sources(s)?.MaxPort, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnsLoopDetect, status, s => Config(s)?.DnsLoopDetect, s => Sources(s)?.DnsLoopDetect, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.StopDnsRebind, status, s => Config(s)?.StopDnsRebind, s => Sources(s)?.StopDnsRebind, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.RebindLocalhostOk, status, s => Config(s)?.RebindLocalhostOk, s => Sources(s)?.RebindLocalhostOk, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ClearOnReload, status, s => Config(s)?.ClearOnReload, s => Sources(s)?.ClearOnReload, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ExpandHosts, status, s => Config(s)?.ExpandHosts, s => Sources(s)?.ExpandHosts, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.BogusPriv, status, s => Config(s)?.BogusPriv, s => Sources(s)?.BogusPriv, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.RebindDomainOk, status, null, null, Items(ec => ec?.RebindDomainOkValues, src => src?.RebindDomainOkValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.BogusNxdomain, status, null, null, Items(ec => ec?.BogusNxdomainValues, src => src?.BogusNxdomainValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.IgnoreAddress, status, null, null, Items(ec => ec?.IgnoreAddressValues, src => src?.IgnoreAddressValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Alias, status, null, null, Items(ec => ec?.AliasValues, src => src?.AliasValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.FilterRr, status, null, null, Items(ec => ec?.FilterRrValues, src => src?.FilterRrValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Filterwin2k, status, s => Config(s)?.Filterwin2k, s => Sources(s)?.Filterwin2k, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.FilterA, status, s => Config(s)?.FilterA, s => Sources(s)?.FilterA, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.FilterAaaa, status, s => Config(s)?.FilterAaaa, s => Sources(s)?.FilterAaaa, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LocaliseQueries, status, s => Config(s)?.LocaliseQueries, s => Sources(s)?.LocaliseQueries, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.FastDnsRetry, status, s => Config(s)?.FastDnsRetry, s => Sources(s)?.FastDnsRetry, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Ipset, status, null, null, Items(ec => ec?.IpsetValues, src => src?.IpsetValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Nftset, status, null, null, Items(ec => ec?.NftsetValues, src => src?.NftsetValues));
|
||||
|
||||
// DNS records
|
||||
list.Add(Multi(DnsmasqConfKeys.Domain, status, Items(ec => ec?.DomainValues, src => src?.DomainValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.Cname, status, Items(ec => ec?.CnameValues, src => src?.CnameValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.MxHost, status, Items(ec => ec?.MxHostValues, src => src?.MxHostValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.Srv, status, Items(ec => ec?.SrvValues, src => src?.SrvValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.PtrRecord, status, Items(ec => ec?.PtrRecordValues, src => src?.PtrRecordValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.TxtRecord, status, Items(ec => ec?.TxtRecordValues, src => src?.TxtRecordValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.NaptrRecord, status, Items(ec => ec?.NaptrRecordValues, src => src?.NaptrRecordValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.HostRecord, status, Items(ec => ec?.HostRecordValues, src => src?.HostRecordValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DynamicHost, status, Items(ec => ec?.DynamicHostValues, src => src?.DynamicHostValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.InterfaceName, status, Items(ec => ec?.InterfaceNameValues, src => src?.InterfaceNameValues)));
|
||||
list.Add(Single(DnsmasqConfKeys.MxTarget, status, s => Config(s)?.MxTarget, s => Sources(s)?.MxTarget, null));
|
||||
list.Add(Single(DnsmasqConfKeys.Localmx, status, s => Config(s)?.Localmx, s => Sources(s)?.Localmx, null));
|
||||
list.Add(Single(DnsmasqConfKeys.Selfmx, status, s => Config(s)?.Selfmx, s => Sources(s)?.Selfmx, null));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Domain, status, null, null, Items(ec => ec?.DomainValues, src => src?.DomainValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Cname, status, null, null, Items(ec => ec?.CnameValues, src => src?.CnameValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MxHost, status, null, null, Items(ec => ec?.MxHostValues, src => src?.MxHostValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Srv, status, null, null, Items(ec => ec?.SrvValues, src => src?.SrvValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.PtrRecord, status, null, null, Items(ec => ec?.PtrRecordValues, src => src?.PtrRecordValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TxtRecord, status, null, null, Items(ec => ec?.TxtRecordValues, src => src?.TxtRecordValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NaptrRecord, status, null, null, Items(ec => ec?.NaptrRecordValues, src => src?.NaptrRecordValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.HostRecord, status, null, null, Items(ec => ec?.HostRecordValues, src => src?.HostRecordValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DynamicHost, status, null, null, Items(ec => ec?.DynamicHostValues, src => src?.DynamicHostValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.InterfaceName, status, null, null, Items(ec => ec?.InterfaceNameValues, src => src?.InterfaceNameValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MxTarget, status, s => Config(s)?.MxTarget, s => Sources(s)?.MxTarget, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Localmx, status, s => Config(s)?.Localmx, s => Sources(s)?.Localmx, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Selfmx, status, s => Config(s)?.Selfmx, s => Sources(s)?.Selfmx, null);
|
||||
|
||||
// DHCP
|
||||
list.Add(Single(DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DhcpScript, status, s => Config(s)?.DhcpScriptPath, s => Sources(s)?.DhcpScriptPath, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpRange, status, Items(ec => ec?.DhcpRanges, src => src?.DhcpRanges)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpHost, status, Items(ec => ec?.DhcpHostLines, src => src?.DhcpHostLines)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpOption, status, Items(ec => ec?.DhcpOptionLines, src => src?.DhcpOptionLines)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpOptionForce, status, Items(ec => ec?.DhcpOptionForceLines, src => src?.DhcpOptionForceLines)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpMatch, status, Items(ec => ec?.DhcpMatchValues, src => src?.DhcpMatchValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpMac, status, Items(ec => ec?.DhcpMacValues, src => src?.DhcpMacValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpNameMatch, status, Items(ec => ec?.DhcpNameMatchValues, src => src?.DhcpNameMatchValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpIgnoreNames, status, Items(ec => ec?.DhcpIgnoreNamesValues, src => src?.DhcpIgnoreNamesValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpHostsfile, status, Items(ec => ec?.DhcpHostsfilePaths, src => src?.DhcpHostsfilePaths)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpOptsfile, status, Items(ec => ec?.DhcpOptsfilePaths, src => src?.DhcpOptsfilePaths)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpHostsdir, status, Items(ec => ec?.DhcpHostsdirPaths, src => src?.DhcpHostsdirPaths)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpBoot, status, Items(ec => ec?.DhcpBootValues, src => src?.DhcpBootValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpIgnore, status, Items(ec => ec?.DhcpIgnoreValues, src => src?.DhcpIgnoreValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpVendorclass, status, Items(ec => ec?.DhcpVendorclassValues, src => src?.DhcpVendorclassValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.DhcpUserclass, status, Items(ec => ec?.DhcpUserclassValues, src => src?.DhcpUserclassValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.RaParam, status, Items(ec => ec?.RaParamValues, src => src?.RaParamValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.Slaac, status, Items(ec => ec?.SlaacValues, src => src?.SlaacValues)));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpAuthoritative, status, s => Config(s)?.DhcpAuthoritative, s => Sources(s)?.DhcpAuthoritative, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRapidCommit, status, s => Config(s)?.DhcpRapidCommit, s => Sources(s)?.DhcpRapidCommit, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LeasefileRo, status, s => Config(s)?.LeasefileRo, s => Sources(s)?.LeasefileRo, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpScript, status, s => Config(s)?.DhcpScriptPath, s => Sources(s)?.DhcpScriptPath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpLeasefile, status, s => Config(s)?.DhcpLeaseFilePath, s => Sources(s)?.DhcpLeaseFilePath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpLeaseMax, status, s => Config(s)?.DhcpLeaseMax, s => Sources(s)?.DhcpLeaseMax, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpTtl, status, s => Config(s)?.DhcpTtl, s => Sources(s)?.DhcpTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpRange, status, null, null, Items(ec => ec?.DhcpRanges, src => src?.DhcpRanges));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpHost, status, null, null, Items(ec => ec?.DhcpHostLines, src => src?.DhcpHostLines));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOption, status, null, null, Items(ec => ec?.DhcpOptionLines, src => src?.DhcpOptionLines));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOptionForce, status, null, null, Items(ec => ec?.DhcpOptionForceLines, src => src?.DhcpOptionForceLines));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpMatch, status, null, null, Items(ec => ec?.DhcpMatchValues, src => src?.DhcpMatchValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpMac, status, null, null, Items(ec => ec?.DhcpMacValues, src => src?.DhcpMacValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpNameMatch, status, null, null, Items(ec => ec?.DhcpNameMatchValues, src => src?.DhcpNameMatchValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpIgnoreNames, status, null, null, Items(ec => ec?.DhcpIgnoreNamesValues, src => src?.DhcpIgnoreNamesValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpHostsfile, status, null, null, Items(ec => ec?.DhcpHostsfilePaths, src => src?.DhcpHostsfilePaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpOptsfile, status, null, null, Items(ec => ec?.DhcpOptsfilePaths, src => src?.DhcpOptsfilePaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpHostsdir, status, null, null, Items(ec => ec?.DhcpHostsdirPaths, src => src?.DhcpHostsdirPaths));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpBoot, status, null, null, Items(ec => ec?.DhcpBootValues, src => src?.DhcpBootValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpIgnore, status, null, null, Items(ec => ec?.DhcpIgnoreValues, src => src?.DhcpIgnoreValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpVendorclass, status, null, null, Items(ec => ec?.DhcpVendorclassValues, src => src?.DhcpVendorclassValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DhcpUserclass, status, null, null, Items(ec => ec?.DhcpUserclassValues, src => src?.DhcpUserclassValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.RaParam, status, null, null, Items(ec => ec?.RaParamValues, src => src?.RaParamValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Slaac, status, null, null, Items(ec => ec?.SlaacValues, src => src?.SlaacValues));
|
||||
|
||||
// TFTP / PXE
|
||||
list.Add(Single(DnsmasqConfKeys.EnableTftp, status, s => Config(s)?.EnableTftp, s => Sources(s)?.EnableTftp, null));
|
||||
list.Add(Single(DnsmasqConfKeys.TftpSecure, status, s => Config(s)?.TftpSecure, s => Sources(s)?.TftpSecure, null));
|
||||
list.Add(Single(DnsmasqConfKeys.TftpNoFail, status, s => Config(s)?.TftpNoFail, s => Sources(s)?.TftpNoFail, null));
|
||||
list.Add(Single(DnsmasqConfKeys.TftpNoBlocksize, status, s => Config(s)?.TftpNoBlocksize, s => Sources(s)?.TftpNoBlocksize, null));
|
||||
list.Add(Single(DnsmasqConfKeys.TftpRoot, status, s => Config(s)?.TftpRootPath, s => Sources(s)?.TftpRootPath, null));
|
||||
list.Add(Single(DnsmasqConfKeys.PxePrompt, status, s => Config(s)?.PxePrompt, s => Sources(s)?.PxePrompt, null));
|
||||
list.Add(Multi(DnsmasqConfKeys.PxeService, status, Items(ec => ec?.PxeServiceValues, src => src?.PxeServiceValues)));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.EnableTftp, status, s => Config(s)?.EnableTftp, s => Sources(s)?.EnableTftp, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TftpSecure, status, s => Config(s)?.TftpSecure, s => Sources(s)?.TftpSecure, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TftpNoFail, status, s => Config(s)?.TftpNoFail, s => Sources(s)?.TftpNoFail, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TftpNoBlocksize, status, s => Config(s)?.TftpNoBlocksize, s => Sources(s)?.TftpNoBlocksize, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TftpRoot, status, s => Config(s)?.TftpRootPath, s => Sources(s)?.TftpRootPath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.PxePrompt, status, s => Config(s)?.PxePrompt, s => Sources(s)?.PxePrompt, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.PxeService, status, null, null, Items(ec => ec?.PxeServiceValues, src => src?.PxeServiceValues));
|
||||
|
||||
// DNSSEC
|
||||
list.Add(Single(DnsmasqConfKeys.Dnssec, status, s => Config(s)?.Dnssec, s => Sources(s)?.Dnssec, null));
|
||||
list.Add(Single(DnsmasqConfKeys.DnssecCheckUnsigned, status, s => Config(s)?.DnssecCheckUnsigned, s => Sources(s)?.DnssecCheckUnsigned, null));
|
||||
list.Add(Single(DnsmasqConfKeys.ProxyDnssec, status, s => Config(s)?.ProxyDnssec, s => Sources(s)?.ProxyDnssec, null));
|
||||
list.Add(Multi(DnsmasqConfKeys.TrustAnchor, status, Items(ec => ec?.TrustAnchorValues, src => src?.TrustAnchorValues)));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Dnssec, status, s => Config(s)?.Dnssec, s => Sources(s)?.Dnssec, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.DnssecCheckUnsigned, status, s => Config(s)?.DnssecCheckUnsigned, s => Sources(s)?.DnssecCheckUnsigned, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ProxyDnssec, status, s => Config(s)?.ProxyDnssec, s => Sources(s)?.ProxyDnssec, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.TrustAnchor, status, null, null, Items(ec => ec?.TrustAnchorValues, src => src?.TrustAnchorValues));
|
||||
|
||||
// Cache
|
||||
list.Add(Multi(DnsmasqConfKeys.CacheRr, status, Items(ec => ec?.CacheRrValues, src => src?.CacheRrValues)));
|
||||
list.Add(Single(DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null));
|
||||
list.Add(Single(DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null));
|
||||
list.Add(Single(DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null));
|
||||
list.Add(Single(DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null));
|
||||
list.Add(Single(DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null));
|
||||
list.Add(Single(DnsmasqConfKeys.MinCacheTtl, status, s => Config(s)?.MinCacheTtl, s => Sources(s)?.MinCacheTtl, null));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.CacheRr, status, null, null, Items(ec => ec?.CacheRrValues, src => src?.CacheRrValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.CacheSize, status, s => Config(s)?.CacheSize, s => Sources(s)?.CacheSize, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LocalTtl, status, s => Config(s)?.LocalTtl, s => Sources(s)?.LocalTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoNegcache, status, s => Config(s)?.NoNegcache, s => Sources(s)?.NoNegcache, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NegTtl, status, s => Config(s)?.NegTtl, s => Sources(s)?.NegTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MaxTtl, status, s => Config(s)?.MaxTtl, s => Sources(s)?.MaxTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MaxCacheTtl, status, s => Config(s)?.MaxCacheTtl, s => Sources(s)?.MaxCacheTtl, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.MinCacheTtl, status, s => Config(s)?.MinCacheTtl, s => Sources(s)?.MinCacheTtl, null);
|
||||
|
||||
// Process & networking
|
||||
list.Add(Single(DnsmasqConfKeys.NoPoll, status, s => Config(s)?.NoPoll, s => Sources(s)?.NoPoll, null));
|
||||
list.Add(Single(DnsmasqConfKeys.BindInterfaces, status, s => Config(s)?.BindInterfaces, s => Sources(s)?.BindInterfaces, null));
|
||||
list.Add(Single(DnsmasqConfKeys.BindDynamic, status, s => Config(s)?.BindDynamic, s => Sources(s)?.BindDynamic, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LogDebug, status, s => Config(s)?.LogDebug, s => Sources(s)?.LogDebug, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LogAsync, status, s => Config(s)?.LogAsync, s => Sources(s)?.LogAsync, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LocalService, status, s => Config(s)?.LocalService, s => Sources(s)?.LocalService, null));
|
||||
list.Add(Multi(DnsmasqConfKeys.Interface, status, Items(ec => ec?.Interfaces, src => src?.Interfaces)));
|
||||
list.Add(Multi(DnsmasqConfKeys.ListenAddress, status, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses)));
|
||||
list.Add(Multi(DnsmasqConfKeys.ExceptInterface, status, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces)));
|
||||
list.Add(Multi(DnsmasqConfKeys.AuthServer, status, Items(ec => ec?.AuthServerValues, src => src?.AuthServerValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.NoDhcpInterface, status, Items(ec => ec?.NoDhcpInterfaceValues, src => src?.NoDhcpInterfaceValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.NoDhcpv4Interface, status, Items(ec => ec?.NoDhcpv4InterfaceValues, src => src?.NoDhcpv4InterfaceValues)));
|
||||
list.Add(Multi(DnsmasqConfKeys.NoDhcpv6Interface, status, Items(ec => ec?.NoDhcpv6InterfaceValues, src => src?.NoDhcpv6InterfaceValues)));
|
||||
list.Add(Single(DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null));
|
||||
list.Add(Single(DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null));
|
||||
list.Add(Single(DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null));
|
||||
list.Add(Single(DnsmasqConfKeys.EnableDbus, status, s => Config(s)?.EnableDbus, s => Sources(s)?.EnableDbus, null));
|
||||
list.Add(Single(DnsmasqConfKeys.EnableUbus, status, s => Config(s)?.EnableUbus, s => Sources(s)?.EnableUbus, null));
|
||||
list.Add(Single(DnsmasqConfKeys.EnableRa, status, s => Config(s)?.EnableRa, s => Sources(s)?.EnableRa, null));
|
||||
list.Add(Single(DnsmasqConfKeys.LogDhcp, status, s => Config(s)?.LogDhcp, s => Sources(s)?.LogDhcp, null));
|
||||
list.Add(Single(DnsmasqConfKeys.KeepInForeground, status, s => Config(s)?.KeepInForeground, s => Sources(s)?.KeepInForeground, null));
|
||||
list.Add(Single(DnsmasqConfKeys.NoDaemon, status, s => Config(s)?.NoDaemon, s => Sources(s)?.NoDaemon, null));
|
||||
list.Add(Single(DnsmasqConfKeys.Conntrack, status, s => Config(s)?.Conntrack, s => Sources(s)?.Conntrack, null));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoPoll, status, s => Config(s)?.NoPoll, s => Sources(s)?.NoPoll, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.BindInterfaces, status, s => Config(s)?.BindInterfaces, s => Sources(s)?.BindInterfaces, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.BindDynamic, status, s => Config(s)?.BindDynamic, s => Sources(s)?.BindDynamic, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LogDebug, status, s => Config(s)?.LogDebug, s => Sources(s)?.LogDebug, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LogAsync, status, s => Config(s)?.LogAsync, s => Sources(s)?.LogAsync, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LocalService, status, s => Config(s)?.LocalService, s => Sources(s)?.LocalService, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Interface, status, null, null, Items(ec => ec?.Interfaces, src => src?.Interfaces));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ListenAddress, status, null, null, Items(ec => ec?.ListenAddresses, src => src?.ListenAddresses));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.ExceptInterface, status, null, null, Items(ec => ec?.ExceptInterfaces, src => src?.ExceptInterfaces));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.AuthServer, status, null, null, Items(ec => ec?.AuthServerValues, src => src?.AuthServerValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpInterface, status, null, null, Items(ec => ec?.NoDhcpInterfaceValues, src => src?.NoDhcpInterfaceValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpv4Interface, status, null, null, Items(ec => ec?.NoDhcpv4InterfaceValues, src => src?.NoDhcpv4InterfaceValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoDhcpv6Interface, status, null, null, Items(ec => ec?.NoDhcpv6InterfaceValues, src => src?.NoDhcpv6InterfaceValues));
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.PidFile, status, s => Config(s)?.PidFilePath, s => Sources(s)?.PidFilePath, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.User, status, s => Config(s)?.User, s => Sources(s)?.User, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Group, status, s => Config(s)?.Group, s => Sources(s)?.Group, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LogFacility, status, s => Config(s)?.LogFacility, s => Sources(s)?.LogFacility, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.EnableDbus, status, s => Config(s)?.EnableDbus, s => Sources(s)?.EnableDbus, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.EnableUbus, status, s => Config(s)?.EnableUbus, s => Sources(s)?.EnableUbus, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.EnableRa, status, s => Config(s)?.EnableRa, s => Sources(s)?.EnableRa, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.LogDhcp, status, s => Config(s)?.LogDhcp, s => Sources(s)?.LogDhcp, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.KeepInForeground, status, s => Config(s)?.KeepInForeground, s => Sources(s)?.KeepInForeground, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.NoDaemon, status, s => Config(s)?.NoDaemon, s => Sources(s)?.NoDaemon, null);
|
||||
list.AddDescriptor(registry, DnsmasqConfKeys.Conntrack, status, s => Config(s)?.Conntrack, s => Sources(s)?.Conntrack, null);
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Creates a single-value descriptor; uses registry factory when registered (e.g. integer), otherwise a plain descriptor. Used by <see cref="EffectiveConfigFieldDescriptorListExtensions.AddSingleDescriptor"/>.</summary>
|
||||
public static EffectiveConfigFieldDescriptor GetSingleDescriptor(
|
||||
IEffectiveConfigRenderFragmentRegistry? registry,
|
||||
string optionName,
|
||||
DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, object?>? getValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
|
||||
var factory = registry?.GetDescriptorFactory(sectionId, optionName);
|
||||
if (factory != null)
|
||||
return (EffectiveConfigFieldDescriptor)factory(sectionId, optionName, status, getValue, getSource, getItems);
|
||||
return Single(optionName, status, getValue, getSource, getItems);
|
||||
}
|
||||
|
||||
private static EffectiveConfigFieldDescriptor Single(string optionName, DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, object?>? getValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
|
||||
@@ -197,11 +215,4 @@ public static class EffectiveConfigFieldBuilder
|
||||
? withSource.Select(p => new ValueWithSource(p.Path, p.Source)).ToList()
|
||||
: paths.Select(p => new ValueWithSource(p, null)).ToList();
|
||||
}
|
||||
|
||||
private static EffectiveConfigFieldDescriptor Multi(string optionName, DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
|
||||
return new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems);
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for building lists of effective-config field descriptors. Keeps the field builder readable.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigFieldDescriptorListExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a field descriptor; single vs multi is determined by <see cref="EffectiveConfigOptionKindMap"/> for the given option name.
|
||||
/// </summary>
|
||||
public static void AddDescriptor(
|
||||
this List<EffectiveConfigFieldDescriptor> list,
|
||||
IEffectiveConfigRenderFragmentRegistry? registry,
|
||||
string optionName,
|
||||
DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, object?>? getValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
if (EffectiveConfigOptionKindMap.GetKind(optionName) == EffectiveConfigFieldKind.Multi)
|
||||
list.AddMultiDescriptor(optionName, status, getItems);
|
||||
else
|
||||
list.AddSingleDescriptor(registry, optionName, status, getValue, getSource, getItems);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a single-value field descriptor. Uses the registry's factory when registered (e.g. integer options), otherwise a plain descriptor.
|
||||
/// </summary>
|
||||
public static void AddSingleDescriptor(
|
||||
this List<EffectiveConfigFieldDescriptor> list,
|
||||
IEffectiveConfigRenderFragmentRegistry? registry,
|
||||
string optionName,
|
||||
DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, object?>? getValue,
|
||||
Func<DnsmasqServiceStatus?, ConfigValueSource?>? getSource,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
list.Add(EffectiveConfigFieldBuilder.GetSingleDescriptor(registry, optionName, status, getValue, getSource, getItems));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a multi-value field descriptor (e.g. server, address, dhcp-range).
|
||||
/// </summary>
|
||||
public static void AddMultiDescriptor(
|
||||
this List<EffectiveConfigFieldDescriptor> list,
|
||||
string optionName,
|
||||
DnsmasqServiceStatus? status,
|
||||
Func<DnsmasqServiceStatus?, IReadOnlyList<ValueWithSource>?>? getItems)
|
||||
{
|
||||
var sectionId = EffectiveConfigSections.GetSectionId(optionName);
|
||||
list.Add(new EffectiveConfigFieldDescriptor(sectionId, optionName, true, status, null, null, getItems));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an effective-config option has a single value (or flag) or multiple values.
|
||||
/// Single source of truth for descriptor building and UI; flags are treated as single-value.
|
||||
/// </summary>
|
||||
public enum EffectiveConfigFieldKind
|
||||
{
|
||||
Single,
|
||||
Multi,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How to parse an option from config files: last occurrence wins, key-only flag, or collect all values.
|
||||
/// Used by ConfigSetCache to choose GetLastValue / GetFlag / GetMultiValue without hard-coding per option.
|
||||
/// </summary>
|
||||
public enum EffectiveConfigParserBehavior
|
||||
{
|
||||
LastWins,
|
||||
Flag,
|
||||
Multi,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps dnsmasq option names to single vs multi-value kind. Used by the field builder so call sites
|
||||
/// do not choose AddSingleDescriptor vs AddMultiDescriptor explicitly.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigOptionKindMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, EffectiveConfigFieldKind> KindByOptionName =
|
||||
new Dictionary<string, EffectiveConfigFieldKind>(System.StringComparer.Ordinal)
|
||||
{
|
||||
[DnsmasqConfKeys.NoHosts] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.AddnHosts] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Hostsdir] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ReadEthers] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Server] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Local] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.RevServer] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Address] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.ResolvFile] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.NoResolv] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DomainNeeded] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Port] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LogQueries] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.StrictOrder] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.AllServers] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.AuthTtl] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.EdnsPacketMax] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.QueryPort] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.PortLimit] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.MinPort] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.MaxPort] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DnsLoopDetect] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.StopDnsRebind] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.RebindLocalhostOk] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ClearOnReload] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ExpandHosts] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.BogusPriv] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.RebindDomainOk] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.BogusNxdomain] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.IgnoreAddress] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Alias] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.FilterRr] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Filterwin2k] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.FilterA] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.FilterAaaa] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LocaliseQueries] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.FastDnsRetry] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Ipset] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Nftset] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Domain] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Cname] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.MxHost] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Srv] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.PtrRecord] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.TxtRecord] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.NaptrRecord] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.HostRecord] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DynamicHost] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.InterfaceName] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.MxTarget] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Localmx] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Selfmx] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpAuthoritative] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpRapidCommit] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LeasefileRo] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpScript] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpLeasefile] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpLeaseMax] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpTtl] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DhcpRange] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpHost] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpOption] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptionForce] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpMatch] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpMac] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpNameMatch] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpIgnoreNames] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpBoot] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpIgnore] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.DhcpUserclass] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.RaParam] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Slaac] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.EnableTftp] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.TftpSecure] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.TftpNoFail] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.TftpNoBlocksize] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.TftpRoot] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.PxePrompt] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.PxeService] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.Dnssec] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.ProxyDnssec] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.TrustAnchor] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.CacheRr] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.CacheSize] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LocalTtl] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.NoNegcache] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.NegTtl] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.MaxTtl] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.MaxCacheTtl] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.MinCacheTtl] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.NoPoll] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.BindInterfaces] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.BindDynamic] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LogDebug] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LogAsync] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LocalService] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Interface] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.ListenAddress] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.ExceptInterface] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.AuthServer] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.NoDhcpInterface] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.NoDhcpv4Interface] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.NoDhcpv6Interface] = EffectiveConfigFieldKind.Multi,
|
||||
[DnsmasqConfKeys.PidFile] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.User] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Group] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LogFacility] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.EnableDbus] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.EnableUbus] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.EnableRa] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.LogDhcp] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.KeepInForeground] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.NoDaemon] = EffectiveConfigFieldKind.Single,
|
||||
[DnsmasqConfKeys.Conntrack] = EffectiveConfigFieldKind.Single,
|
||||
};
|
||||
|
||||
/// <summary>Returns Single or Multi for the given option name; defaults to Single if unknown.</summary>
|
||||
public static EffectiveConfigFieldKind GetKind(string optionName) =>
|
||||
KindByOptionName.TryGetValue(optionName, out var kind) ? kind : EffectiveConfigFieldKind.Single;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps dnsmasq option names to parser behavior (LastWins / Flag / Multi). Used by ConfigSetCache so it
|
||||
/// dispatches to GetLastValue, GetFlag, or GetMultiValue from one place. Special cases (NoHosts value,
|
||||
/// AddnHosts, DhcpLeaseFilePath) remain explicit in the cache.
|
||||
/// </summary>
|
||||
public static class EffectiveConfigParserBehaviorMap
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, EffectiveConfigParserBehavior> BehaviorByOptionName =
|
||||
new Dictionary<string, EffectiveConfigParserBehavior>(System.StringComparer.Ordinal)
|
||||
{
|
||||
[DnsmasqConfKeys.NoHosts] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.AddnHosts] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Hostsdir] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.ReadEthers] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Server] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Local] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.RevServer] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Address] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.ResolvFile] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.NoResolv] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DomainNeeded] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Port] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.LogQueries] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.StrictOrder] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.AllServers] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.AuthTtl] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.EdnsPacketMax] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.QueryPort] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.PortLimit] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.MinPort] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.MaxPort] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DnsLoopDetect] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.StopDnsRebind] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.RebindLocalhostOk] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.ClearOnReload] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.ExpandHosts] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.BogusPriv] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.RebindDomainOk] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.BogusNxdomain] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.IgnoreAddress] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Alias] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.FilterRr] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Filterwin2k] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.FilterA] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.FilterAaaa] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.LocaliseQueries] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.FastDnsRetry] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Ipset] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Nftset] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Domain] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Cname] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.MxHost] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Srv] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.PtrRecord] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.TxtRecord] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.NaptrRecord] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.HostRecord] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DynamicHost] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.InterfaceName] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.MxTarget] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Localmx] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Selfmx] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DhcpAuthoritative] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DhcpRapidCommit] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.LeasefileRo] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DhcpScript] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpLeasefile] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpLeaseMax] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpTtl] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.DhcpRange] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpHost] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpOption] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptionForce] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpMatch] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpMac] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpNameMatch] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpIgnoreNames] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpHostsfile] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpOptsfile] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpHostsdir] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpBoot] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpIgnore] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpVendorclass] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.DhcpUserclass] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.RaParam] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Slaac] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.EnableTftp] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.TftpSecure] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.TftpNoFail] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.TftpNoBlocksize] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.TftpRoot] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.PxePrompt] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.PxeService] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.Dnssec] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.DnssecCheckUnsigned] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.ProxyDnssec] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.TrustAnchor] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.CacheRr] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.CacheSize] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.LocalTtl] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.NoNegcache] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.NegTtl] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.MaxTtl] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.MaxCacheTtl] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.MinCacheTtl] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.NoPoll] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.BindInterfaces] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.BindDynamic] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.LogDebug] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.LogAsync] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.LocalService] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Interface] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.ListenAddress] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.ExceptInterface] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.AuthServer] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.NoDhcpInterface] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.NoDhcpv4Interface] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.NoDhcpv6Interface] = EffectiveConfigParserBehavior.Multi,
|
||||
[DnsmasqConfKeys.PidFile] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.User] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.Group] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.LogFacility] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.EnableDbus] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.EnableUbus] = EffectiveConfigParserBehavior.LastWins,
|
||||
[DnsmasqConfKeys.EnableRa] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.LogDhcp] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.KeepInForeground] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.NoDaemon] = EffectiveConfigParserBehavior.Flag,
|
||||
[DnsmasqConfKeys.Conntrack] = EffectiveConfigParserBehavior.LastWins,
|
||||
};
|
||||
|
||||
/// <summary>Returns LastWins, Flag, or Multi for the given option name; defaults to LastWins if unknown.</summary>
|
||||
public static EffectiveConfigParserBehavior GetBehavior(string optionName) =>
|
||||
BehaviorByOptionName.TryGetValue(optionName, out var b) ? b : EffectiveConfigParserBehavior.LastWins;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user