15 Commits
Author SHA1 Message Date
alex 3b22e4fb7d Bump version to 0.0.6 for release 2026-03-02 21:12:57 +10:00
alex a3cc3968af Effective config: multi-value editor, config service fixes, save result improvements
- Add EffectiveConfigMultiValueListEditor with badge-style actions, draft merge,
  and display-mode editable badges; fix row callback capture and bounds checks
- EffectiveConfigFieldDisplay: compute multi-value state in OnParametersSet +
  HandleMultiValueChanged; BuildItemsWithSource preserves readonly/editable;
  dedupe list on commit; defensive copy for multi-value commit
- DnsmasqConfigService: generic MatchesOption via key parsing; FilterManagedOnly
  + ReadReadonlyMultiValuesByOptionAsync (single-pass readonly); EnsureManagedConfigInvariants
  + EnsureUniqueDirective; fix DHCP write cache notification (re-parse written output)
- EffectiveConfigSaveResult: ErrorCodes constants, IsValidateFailed; set Validated=false
  when validation fails; EffectiveConfigSaveFlow use IsValidateFailed; save service
  use ErrorCodes
- OptionStateBadge: OnEditableClickRequested for list-editor per-item edit
- Validation service and config save flow updates
2026-03-02 21:10:35 +10:00
alex 8ed66cdf48 Process runner, save/restore flow, status script, listen-address IPv6
- Process: Add IProcessHandle, ProcessOutputLine, StartAsync; RunAsync via StartAsync+WaitForExitAsync. Wait for stdout/stderr stream close before reading to fix empty restart output. ProcessTimeouts; ReloadService/StatusController/LogsService use config timeouts.
- Effective config: Rename reload→restart in save/restore result types and UI. Save modal: show restart output, danger alert, Stop editing/Continue editing (yellow), footer one-line; fallback exit code when no output; ensure pre visibility (CSS). Restore failed: combined output helper.
- Status script: When dnsmasq dead, run dnsmasq --test and show config error in status block (Docker harness; DNSMASQ_CONF).
- listen-address: Allow IPv6 (ValidateDraftValue accepts InterNetworkV6).
- Docker test: RestartCommand uses DnsmasqOptions timeout; DnsmasqOptions RestartTimeoutSeconds.
2026-03-01 22:23:17 +10:00
alex a776f445b6 Assembly-scanned services, options validator marker, UI tweaks, and config fixes
- Register ISettingsModalService, INotificationService, IUpdateCheckService via assembly scanning (marker interfaces)
- Add IApplicationOptionsValidator<TOptions> marker; scan only those for IValidateOptions registration
- Fix ApplicationLifecycleLoggingHostedService: log RestartCommand/ReloadCommand/StatusCommand (no ApplyCommand)
- NavMenu: spin animation for update-check button icon when checking; single icon with conditional nav-footer-spin class
- Add NotificationHost/NotificationToast components and NotificationService for toasts
- Effective config save flow, restore result, save service; various EffectiveConfig and DnsmasqOptions/validator updates
- Test and script updates; docker-compose.test.yml, appsettings, DnsmasqConfKeys/tooltips/sections
2026-03-01 19:21:09 +10:00
alex b0ce7afb58 Reorganize: Hubs→Realtime, Dnsmasq→Status/Logs/AppLogs, Extensions→DI/Hosting/Interop, EffectiveConfig→shared feature 2026-02-28 23:17:40 +10:00
alex 2cfbaf000c Reorganise Infrastructure/Services into feature namespaces
- Move registration marker interfaces to Registration/Abstractions
- Add feature folders with Abstractions subfolders: EffectiveConfig,
  Dnsmasq/Config, Dnsmasq/Hosts, Dnsmasq/Leases, Dnsmasq/Reload,
  Logs, Updates, UI/Settings, Common/Process
- Place interfaces in each feature's Abstractions namespace; align
  implementation namespaces to folder paths
- Update Program, extensions, controllers, hubs, helpers, components,
  and tests to use new namespaces
- Remove old flat Services/Abstractions and root-level service files
2026-02-28 22:17:20 +10:00
alex f8754d8d6e Shared StatusBadge, OK/Cancel/Revert, fixes, badge styling
- Add Components/Shared/StatusBadge + StatusBadgeKind; centralize badge
  styles and map Kind to Bootstrap classes. Refactor OptionStateBadge to
  compose StatusBadge; add OK/Cancel/Revert when active with
  OnConfirm/OnCancel/OnRevert callbacks and exit-action flag in
  EffectiveConfigFieldDisplay.
- Fix _valueAtActivation to use Descriptor.GetValue() (persisted value)
  so save modal shows correct before/after and no phantom pending on
  revert-to-original.
- OptionHelpController: add CancelCloseFromLabelEnter() and call when
  opening/switching help so pending 300ms close does not dismiss new help.
- Migrate Dnsmasq.razor and HostsFileSection to StatusBadge; save modal
  option chip uses NeutralPill. Centralize badge CSS in StatusBadge.
- Badge styling: font-size/weight/padding, hover lift+shadow, active
  press with visible release animation.
2026-02-28 21:34:12 +10:00
alex 71a8a61cb3 EffectiveConfig: toolbar, save modal, cancel/revert UX
- Toolbar: single expand/collapse toggle; change count after Save/Exit.
- Save modal: selective revert per change; modern list styling, empty state;
  Session + OnReverted; Save disabled when no changes.
- Cancel: prevent blur-before-click (mousedown preventDefault on cancel
  badge); _cancelRequested so cancel never commits.
- DeactivateFieldAsync in UiContext + Section; OptionStateBadge cancel
  clears draft and deactivates.
2026-02-28 20:06:10 +10:00
alex bf1f6a0d82 EffectiveConfig: migrate edit activation/commit to shared UiContext
- OptionStateBadge: add FieldKey param, call Ui.ActivateFieldAsync instead of OnActivateEdit
- EffectiveConfigOptionItem: add FieldKey, pass to badge; remove OnActivateEdit
- EffectiveConfigFieldDisplay: add FieldKey, commit only via Ui.CommitFieldAsync; remove ActiveFieldKey, OnActivateEdit, OnEditCommitted
- EffectiveConfigSectionPanel: pass FieldKey to FieldDisplay; remove OnActivateField, OnFieldEditCommitted
- EffectiveConfigPanels: remove OnActivateField, OnFieldEditCommitted params and relay
- EffectiveConfigSection: stop passing callback props to Panels (Session + UiContext drive behavior)
2026-02-28 19:25:25 +10:00
alex 91beb9d181 EffectiveConfig: single expand/collapse toolbar button
Replace separate Expand all / Collapse all buttons with one toggle button.
Icon and label switch based on AllSectionsExpanded (expand when any
section collapsed, collapse when all expanded).
2026-02-28 19:13:42 +10:00
alex 4e122ba439 EffectiveConfig: fix pending null display, OptionStateBadge, OptionHelpLabel
- Use pending != null ? pending.NewValue : Descriptor.GetValue() so clearing
  a value (port, log-queries, local-service) shows '(not set)' instead of
  reverting to the old value; fix effective value and commit logic in
  EffectiveConfigFieldDisplay.
- Rename ReadonlyBadge to OptionStateBadge; add editable/pending state and
  use it from EffectiveConfigOptionItem, EffectiveConfigFieldDisplay,
  EffectiveConfigMultiValueItem.
- OptionHelpLabel: default CssClass from HelpKey (EffectiveCssClass).
2026-02-28 19:04:38 +10:00
alex 6a7c0f62e1 Effective config refactor: session, UI context, cascade cleanup, save modal z-index
- Add IEffectiveConfigEditSession + EffectiveConfigEditSession
- Add EffectiveConfigUiContext for option help / readonly popover / commit intents
- Extract OptionHelpController, ReadonlyPopoverController, EffectiveConfigSaveFlow,
  EffectiveConfigToolbar, EffectiveConfigPanels, CopyableCodeLine, OptionHelpLabel,
  ReadonlyBadge, EffectiveConfigValueDisplayBase
- Remove redundant named CascadingValues (IsEditMode, ActiveFieldKey, ManagedFilePath)
- Remove unused ManagedFilePath cascading parameter from EffectiveConfigFieldDisplay
- Fix save modal stacking: add EffectiveConfigSaveModal.razor.css (dialog above backdrop)
- Pending changes displayed before save; hover-close fix for option help and readonly popover
2026-02-28 18:52:29 +10:00
alex f22b039e74 Effective config: readonly edit workflow and save staging
Add readonly badge/popover guidance, pending change tracking, and save confirmation flow for effective config editing. Update field renderers and config services to commit staged changes, and remove bundled Bootstrap dist assets from wwwroot.
2026-02-27 20:49:31 +10:00
alex 906e222ec1 Effective config: option kind map, parser behavior map, custom displays
- Add EffectiveConfigOptionKindMap (Single/Multi kind + LastWins/Flag/Multi parser
  behavior) and drive field builder + ConfigSetCache from it.
- Single AddDescriptor() in list extensions that dispatches via kind map to
  AddSingleDescriptor/AddMultiDescriptor.
- ConfigSetCache: ParseOptionValue/ParseOptionWithSource dispatch by option
  name; BuildEffectiveConfig and BuildEffectiveConfigSources use them with
  casts; special cases (NoHosts, AddnHosts, server/local, DhcpLeaseFilePath)
  remain explicit.
- Add IntegerValueDisplay, LogQueriesDisplay, LocalServiceDisplay; register
  integers with bounds/units in EffectiveConfigRenderFragmentRegistry.
- Add EffectiveConfigDescriptorFactory, EffectiveIntegerConfigFieldDescriptor,
  IEffectiveConfigFieldDescriptor for integer descriptor metadata.
2026-02-14 00:18:28 +10:00
alex ecd9e5cc7b UI polish: update check tooltip date, expand/collapse icons, dialog JS
- NavMenu: show short date in update-check tooltip when last check was not today
- Settings modal: expand/collapse all sections with bi-arrows-expand/collapse icons
- Effective config: add Expand all button (bi-arrows-expand), same icon style as toolbar
- Rename settings-modal.js to dialog.js; add config-option-help.js, JS extension helpers
- Config option help modal and client settings/dialog wiring updates
2026-02-13 00:59:33 +10:00
227 changed files with 5979 additions and 61360 deletions
+5 -6
View File
@@ -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"]
+6 -3
View File
@@ -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)
+10
View File
@@ -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
+1 -1
View File
@@ -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",
+20 -1
View File
@@ -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>(),
+1 -1
View File
@@ -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>
@@ -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>
@@ -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,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
@@ -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;
}
}
@@ -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);
}
}
@@ -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 });
}
@@ -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; }
}
@@ -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,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>
@@ -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>
}
@@ -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;
}
}
@@ -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>
}
@@ -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();
}
}
@@ -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;
@@ -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 }
}
@@ -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; }
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
+22 -12
View File
@@ -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;
+29 -30
View File
@@ -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;
+12 -2
View File
@@ -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
}
+17 -2
View File
@@ -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: ".
+1 -1
View File
@@ -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>
@@ -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.
@@ -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&lt;T&gt;</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,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.).
@@ -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);
}
}
@@ -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