17 Commits
Author SHA1 Message Date
alex e86a2b6bd9 Extract hosts file filter into GroupedSelectDropdown and builder
Add shared Models.Ui grouped-select model, IGroupedSelectBuilder, optional
trigger summary API, and GroupedSelectDropdown component. Hosts page uses
HostsFileFilterBuilder and hosts-specific option row styling for full paths.

Remove HostsSourcePathDisplay and file filter menu records in favor of the
builder. Bump version to 0.0.8.
2026-04-07 18:15:23 +10:00
alex 002e9a5db5 Polish multi-value editors and hosts inline editing.
Align the domain custom editor with the default multi-value layout, keep item numbering in edit mode, and restore unaffected single-value editor styling. Refine hosts inline editing with safer blur handling, explicit cancel actions, and better behavior when blank edits are abandoned.
2026-04-07 17:36:52 +10:00
alex 02a840799b Hosts page: grouped file filter with counts and source-path projection
Add SourcePathFilter to HostsPageQueryState and apply it in HostsPageProjectionService.
Replace the native select with a grouped menu (managed/system/additional/hostsdir),
per-file non-comment counts, toolbar layout, and full-path labels with end-anchored
ellipsis plus title tooltips. Include HostsSourcePathDisplay helpers, menu models,
and tests for grouping and projection filtering.
2026-04-07 16:00:25 +10:00
alex 2e713f0e3c Hosts UX, effective names, JS interop, domain UI
- Hosts: delete confirmation modal, shift range select, keyboard shortcuts,
  effective-name suffix tooltips, conditional modern header actions
- Align EffectiveNames on rows with scoped domain= + pending preview;
  unified hosts API uses full DomainValues from config snapshot
- Harden Blazor JS interop (safe helpers, IAsyncDisposable, disposal order)
- Effective config: domain multi-value display/editor, parsers, tests
- Misc: docs/testdata/docker script updates
2026-04-05 14:46:17 +10:00
alex 9627926a6a Hosts page UX, effective names, and draft sync
- Managed hosts: add slot row, selection with Ctrl/Cmd, Edit toggles to Save
  while editing, Remove, double-click to edit; header toolbar layout
- Column order: Address, Names, Effective names (when expand-hosts + domains),
  Comment; scroll cue radius and table styling tweaks
- Effective names: resolve domain suffix per row IPv4 from scoped domain=
  rules (CIDR/range) with dnsmasq-style precedence; IPv6 uses default;
  preview uses pending domain option when present
- Enter edit mode on Remove; clear stale inline edit when row disappears;
  deactivate effective-config field when interacting with hosts table
- Remove hosts-ui.js; effective names help uses notifications always
- Tests for HostsEffectiveNames domain rule resolution
2026-04-01 23:50:21 +10:00
alex eae29de045 Hosts: inline file comments (hosts(5)) and Comment column
- HostEntry.InlineComment; parse at first # on data lines; ToLine + SanitizeInlineComment
- HostsPageRow + HostsCache; pending change equality and save diff include comments
- Hosts table: dedicated Comment column; edit in own cell; empty shown as em dash
- Parser and pending-change tests
2026-04-01 11:52:21 +10:00
alex f0b4ea97a4 Hosts page UX, draft/UI split, and managed hosts save summary
- Split effective-config session into Draft + Ui (IEffectiveConfigDraft, IEffectiveConfigUiState)
- Hosts: managed overlay, edit mode from table, validation deferred to blur/Escape/Enter
- Managed hosts save modal: line-level diff (add/remove/modify/order)
- Hosts group headers: editable/read-only badges, empty-group row, Effective names help
- hosts-ui.js for touch vs hover help; table inline bind oninput for live preview
- HostsEntryEditValidator and ManagedHostsPendingChangeDiff with tests
2026-04-01 11:27:55 +10:00
alex 1bbab1c8c3 Fix address/local rule: skip NXDOMAIN address=/domain/ form
GetDomainsFromAddressDirective only considers lines with a non-empty
target after the final slash; address=/domain/ applies to all RR types
and should not require local=. Add tests for trailing-slash and # forms.
2026-03-30 19:41:08 +10:00
alex 5702844cea Cross-option rules: rev-server+bogus-priv, DHCP, port 0 netboot
- Extend bogus-priv private-reverse check to rev-server= RFC1918 IPv4
  targets (CIDR overlap); separate FieldIssues for server vs rev-server.
- Add no-ping + dhcp-sequential-ip warning (man/2.80).
- Add port=0 + enable-tftp/pxe-service warning.
- Clarify address/local 2.86+ message; comment deferring dhcp-authoritative
  DHCPv6 REBIND rule until doc/heuristics justify it.
- Tests for new behavior, pending rev-server overlay, field keys.
2026-03-30 19:29:30 +10:00
alex 84c58ccec7 Cross-option validation: field keys, bogus-priv zones, edit UX
- Add FieldKeyForOption() using EffectiveConfigSections.GetSectionId so
  cross-option issues match UI field keys; tighten RFC1918 in-addr.arpa
  detection for bogus-priv vs server=.
- GetMulti: pending change always wins; empty list when NewValue is not
  a string list (with tests).
- EffectiveConfigSection: re-run cross-option when entering edit mode or
  continuing after save failure; allow opening save modal when only
  validation summary is present.
- Toolbar: validation link aria-label describes opening the summary.
2026-03-30 11:31:02 +10:00
alex a3dc5ac45c EffectiveConfig: replace cross-option evaluator with rule service
Move cross-option checks to DI-backed rules and shared context so editor and quick-add flows use one validation pipeline. Improve warning copy and save-modal messaging to clarify that validation reflects both on-disk config and pending edits.
2026-03-29 21:35:30 +10:00
alex 31c0f65c86 Hosts page UX: simpler table, projection, and effective-config tweaks
Remove the Status column and row-level badges/actions; keep source-level
Editable on group headers. Double-click managed rows to edit; commit staged
drafts when focus leaves the row via isFocusOutsideElement in the shared
scroll-cue module. Hide inactive system-host rows when dnsmasq ignores them
and move system-hosts / expand-hosts notes below the content area.

Memoize VisibleGroups so BuildGroups runs once per render during search.
Move read-ethers metadata to the DHCP section; adjust Hosts page state copy
and related effective-config tooltips/builders where needed.
2026-03-29 09:31:29 +10:00
alex c7e7844ce7 Update hosts workflow and align DHCP metadata.
Redesign the Hosts page around source-aware grouped rows with shared table shell styling, fix effective-config save session synchronization, and move read-ethers into the DHCP model where dnsmasq actually applies it.
2026-03-26 00:24:21 +10:00
alex 333566554c Update hosts file section 2026-03-18 12:41:34 +10:00
alex 1b3a09996f Effective config: unified save pipeline, descriptors, serialization, WriteFailed rollback
- EffectiveConfigFieldRef as value type; PendingDnsmasqChange/PendingOptionChange/PendingManagedHostsChange
- Multi-file save plan (DnsmasqSaveWritePlan, DnsmasqManagedBackup) and backup/restore in save flow
- Hosts integrated into effective-config save; Hosts.razor uses shared save modal
- IEffectiveConfigDescriptorProvider + IEffectiveConfigFieldBuilder (DI); descriptors from EffectiveConfigSections
- IEffectiveConfigPageEditor, shared descriptor resolution; addn-hosts/dhcp-host descriptor fix
- DnsmasqConfText, IEffectiveConfigDirectiveSerializer; DnsmasqConfigService uses serializer for writes
- IStructuredOptionValueHandler/Registry, DhcpHostOptionValueHandler; Dhcp.razor index-based DeleteRow fix
- SaveService: roll back partial writes on failure; SaveModal: WriteFailed shows backups and Restore button
2026-03-12 01:48:19 +10:00
alex 3ca935e551 Tests: add real-world config coverage and prune brittle cases
Reduce presentation-focused test churn while adding corpus-based parser, validator, capability-guard, and round-trip save coverage to better protect future refactors.
2026-03-11 11:44:01 +10:00
alex e33402467f Reorganise codebase: EffectiveConfig, parsers, tests; move tooltips to Metadata
Infrastructure:
- Parsers under Serialization/Parsers: Dnsmasq, DnsmasqConfig, EffectiveConfig
- EffectiveConfig: Abstractions, Metadata (keys, views, field builder, tooltips,
  placeholders, write semantics), Rendering, Editing, Validation/Handlers, Readonly
- Helpers/Config: only DnsmasqFileEncoding; DnsmasqOptionTooltips moved to Metadata
- Remove empty Infrastructure/Parsers and Helpers/Dnsmasq

Tests:
- Mirror main project: Serialization/Parsers (Dnsmasq, DnsmasqConfig),
  Services/Dnsmasq/Config, Services/EffectiveConfig, Models, Helpers (TestDataHelper)
- Namespaces updated; usings for TestDataHelper and Metadata where needed

Docs: README codebase overview and test layout updated.
2026-03-11 10:50:23 +10:00
288 changed files with 11530 additions and 1667 deletions
+9 -8
View File
@@ -399,7 +399,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
### scripts/prepare-test-mount.sh
**Purpose:** Prepare the testdata mount and optionally start or stop the Docker test harness (app + dnsmasq + DHCP clients). Syncs a source dir (default: `testdata`) into a mount dir (default: `testdata-mount`), cleans up previous test data, then runs `docker compose -f docker-compose.test.yml up -d` (or only prepares the mount with `--prepare-only`). By default the script resolves and uses the **latest stable upstream dnsmasq version**; you can pin a specific version (or use the distro package) for compatibility testing.
**Purpose:** Prepare the testdata mount and optionally start or stop the Docker test harness (app + dnsmasq + DHCP clients). Syncs a source dir (default: `testdata`) into a mount dir (default: `testdata-mount`), then runs `docker compose -f docker-compose.test.yml up -d` (or only prepares the mount with `--prepare-only`). Fixture sync skips `leases` and app-managed filenames (`zz-dnsmasq-webui.conf`, `zz-dnsmasq-webui.hosts`) so rebuilds keep DHCP state and UI-written files unless you pass `--clear` or `--reset-managed`. By default the script resolves and uses the **latest stable upstream dnsmasq version** when building images; you can pin a specific version (or use the distro package) for compatibility testing.
**Usage:** `./scripts/prepare-test-mount.sh [OPTIONS] [--]`
@@ -411,6 +411,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
| `--mount DIR` | Target mount directory (default: `testdata-mount`). Compose uses `TESTDATA_MOUNT`; script exports it when you use `--mount`. |
| `--dnsmasq-version V` | Dnsmasq version for the harness image: `latest` (default), `distro`, or an exact upstream version like `2.91`. |
| `--clear` | Clear the mount dir completely before sync for a clean run. Default: preserve existing contents and sync over them. |
| `--reset-managed` | After sync, remove `*dnsmasq-webui*.conf` and `*dnsmasq-webui*.hosts` under the mount so the app recreates them; leaves leases and other files. |
| `--prepare-only` | Only prepare the mount; do not run docker compose. |
| `--build` | Pass `--build` to docker compose (rebuild images). Default: use existing images. |
| `--no-cache-build` | Run `docker compose build --pull --no-cache` before start. Use to force a fresh image build and refresh the resolved latest dnsmasq version. |
@@ -423,10 +424,10 @@ sudo ./scripts/install.sh --uninstall --purge --system
**Examples:**
```bash
# Full run: clear mount, sync testdata, start containers (no rebuild)
# Default: preserve mount (fixtures sync over; managed conf/hosts and leases kept), start (no image rebuild)
./scripts/prepare-test-mount.sh
# Rebuild images then start
# Rebuild images then start (mount state preserved unless you pass --clear or --reset-managed)
./scripts/prepare-test-mount.sh --build
# Force a fresh image build with no Docker build cache
@@ -443,8 +444,8 @@ sudo ./scripts/install.sh --uninstall --purge --system
# Then use the version printed by the script, e.g.:
# TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION=<resolved-version> docker compose -f docker-compose.test.yml up -d
# Preserve mount contents, sync over it, start (default behavior)
./scripts/prepare-test-mount.sh
# Fresh managed config/hosts after a rebuild (fixtures still synced; leases kept)
./scripts/prepare-test-mount.sh --reset-managed --build
# Stop the harness
./scripts/prepare-test-mount.sh --stop
@@ -506,10 +507,10 @@ Run from the repo root. Writes one `.html` file per option that appears in the m
**Codebase overview:**
- **Entry and config:** `Program.cs` wires the host; configuration (e.g. `Dnsmasq__*`) lives in `Models/Config/` (`DnsmasqOptions`, `ApplicationOptions`, `DnsmasqOptionsValidator`). Options are validated at startup.
- **Infrastructure:** Under `Infrastructure/`: **Client/** (HTTP API clients and `Abstractions/`), **Services/** and **Services/Abstractions/** (caches, config/hosts/leases services, reload, hosted services), **Parsers/** (dnsmasq config and hosts line parsers), **Helpers/Config/** and **Helpers/Http/** (option keys, encoding, tooltips, JSON options). A **managed** config file and optional managed hosts file are written by the app; the main config must include the managed file (e.g. via a final `conf-file=` line). Caches (`ConfigSetCache`, `HostsCache`) keep in-memory snapshots and refresh on file changes or staleness.
- **Infrastructure:** Under `Infrastructure/`: **Client/** (HTTP API clients and `Abstractions/`), **Serialization/Parsers/** (Dnsmasq version/compile, DnsmasqConfig conf-line and include parsers, EffectiveConfig syntax helpers), **Services/** (caches, config/hosts/leases services, reload, hosted services; EffectiveConfig has Metadata, Rendering, Editing, Validation, Readonly), **Helpers/Config/** (file encoding for config/hosts), **Helpers/Http/** (HTTP helpers). A **managed** config file and optional managed hosts file are written by the app; the main config must include the managed file (e.g. via a final `conf-file=` line). Caches (`ConfigSetCache`, `HostsCache`) keep in-memory snapshots and refresh on file changes or staleness.
- **Models:** `Models/Config/` (options, validators, `DnsmasqConfLine`); `Models/Contracts/` (snapshots and DTOs such as `ConfigSetSnapshot`, `HostsSnapshot`, `ManagedConfigContent`, `ProcessRunResult`, `ReloadResult`); `Models/Dnsmasq/` (status, `SaveWithReloadResult`, and `EffectiveConfig/` for effective config and sources); `Models/Client/`, `Models/Dhcp/`, `Models/Hosts/` (UI and API DTOs).
- **API and UI:** `Controllers/` expose API endpoints; `Components/` contains Blazor Server components for config editor, hosts, DHCP, Dnsmasq status, and app settings. Static assets in `wwwroot/`.
- **Tests:** `DnsmasqWebUI.Tests` contains unit tests for parsers, config set service, config, and related logic. Run with `dotnet test`.
- **Tests:** `DnsmasqWebUI.Tests` contains unit tests organised to mirror the main project: `Serialization/Parsers/` (Dnsmasq, DnsmasqConfig), `Services/Dnsmasq/Config/`, `Services/EffectiveConfig/`, `Models/`, and `Helpers/` (TestDataHelper). Run with `dotnet test`.
<!-- SUGGESTED SCREENSHOT: SOLUTION EXPLORER OR FOLDER TREE OF SRC/ -->
@@ -534,7 +535,7 @@ Use `appsettings.json` or launch settings to set `Dnsmasq__MainConfigPath` and o
```bash
./scripts/prepare-test-mount.sh
```
This clears the mount dir (by default `testdata-mount`), syncs `testdata/` into it, and starts the stack with `docker-compose.test.yml`. Use `--prepare-only` to only prepare the mount, then start compose manually; use `--build` after changing the app or Dockerfile.
This syncs `testdata/` into the mount (preserving existing managed config, hosts, and `leases` unless you use `--clear` or `--reset-managed`), then starts the stack with `docker-compose.test.yml`. Use `--prepare-only` to only prepare the mount, then start compose manually; use `--build` after changing the app or Dockerfile.
2. Open the UI (e.g. http://localhost:8080). The app and dnsmasq use the mounted config; you can edit config/hosts in the UI and trigger reload.
+7 -5
View File
@@ -17,10 +17,10 @@ From the repo root:
This will:
1. Sync `testdata/` to `testdata-mount/` (preserving existing contents unless you pass `--clear`)
2. Remove any `*dnsmasq-webui*.conf` in the mount so dnsmasq starts clean
3. Resolve the requested dnsmasq version (`latest` by default) to a concrete value when needed
4. Run `docker compose -f docker-compose.test.yml up -d --build`
1. Run `docker compose down`, then sync `testdata/` → `testdata-mount/` (preserving mount contents unless you pass `--clear`). Sync skips `leases` and `zz-dnsmasq-webui.conf` / `zz-dnsmasq-webui.hosts` so rebuilds keep DHCP leases and UI-written managed files.
2. Optionally remove managed `*dnsmasq-webui*.conf` / `*dnsmasq-webui*.hosts` in the mount **only** if you pass `--reset-managed`.
3. Resolve the requested dnsmasq version (`latest` by default) to a concrete value when building images.
4. Run `docker compose -f docker-compose.test.yml up -d` (add `--build` / `--no-cache-build` when you need a rebuild).
- **Quick restart (no image rebuild):** `./scripts/prepare-test-mount.sh --no-build`
- **Force a fresh image build:** `./scripts/prepare-test-mount.sh --no-cache-build`
@@ -28,7 +28,8 @@ This will:
- **Use distro package:** `./scripts/prepare-test-mount.sh --dnsmasq-version distro --build`
- **Prepare mount only (no start):** `./scripts/prepare-test-mount.sh --prepare-only`
Then start manually with the version printed by the script: `TESTDATA_MOUNT=./testdata-mount DNSMASQ_VERSION=<resolved-version> docker compose -f docker-compose.test.yml up -d [--build]`
- **Preserve mount (e.g. keep leases):** `./scripts/prepare-test-mount.sh --no-build` (default mount behavior)
- **Preserve mount (e.g. keep leases and managed config):** default; also use `./scripts/prepare-test-mount.sh --no-build` for a quick up without rebuilding images.
- **Drop managed conf/hosts only (keep leases):** `./scripts/prepare-test-mount.sh --reset-managed` (often combined with `--build`).
App is at **http://localhost:8080**. Main config path in the container is `/data/dnsmasq-test.conf`; managed file is `zz-dnsmasq-webui.conf` in the same directory. The app service has a healthcheck (`GET /healthz/ready`); DHCP client services use `depends_on: app: condition: service_healthy` so they start only after the app (and dnsmasq) is ready.
@@ -53,6 +54,7 @@ Stops the harness and deletes the contents of `testdata-mount/` so the next run
- `--source DIR` — Source to sync from (default: `testdata`)
- `--mount DIR` — Mount directory (default: `testdata-mount`). The script exports `TESTDATA_MOUNT=./DIR` when you use `--mount`, so compose uses it.
- `--dnsmasq-version V` — Dnsmasq version for the harness image: `latest` (default), `distro`, or an exact upstream version like `2.91`.
- `--reset-managed` — After sync, delete `*dnsmasq-webui*.conf` and `*dnsmasq-webui*.hosts` under the mount so the app recreates them.
- `--no-cache-build` — Force a fresh image build with `docker compose build --pull --no-cache` before start.
Example: sync from a custom dir and start:
+1 -1
View File
@@ -1,6 +1,6 @@
# 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 [--minimal-conf | --prepare-only | ...]
# Prepare mount: ./scripts/prepare-test-mount.sh [--clear | --reset-managed | --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).
#
+26 -8
View File
@@ -3,8 +3,8 @@
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
#
# What this script does:
# Start (default): sync source -> mount (preserving existing unless --clear), clean up previous test data,
# then docker compose up -d [--build] [--force-recreate].
# Start (default): sync source -> mount (preserving existing unless --clear); optional --reset-managed
# removes app-written files in the mount. Then docker compose up -d [--build] [--force-recreate].
# --stop: docker compose down (stop and remove containers/networks).
# --tidy: docker compose down, then clear the mount directory for a clean next run.
set -e
@@ -21,6 +21,7 @@ BUILD=false
NO_CACHE_BUILD=false
RECREATE=false
CLEAR=false
RESET_MANAGED=false
STOP=false
TIDY=false
MINIMAL_CONF=false
@@ -33,7 +34,8 @@ usage() {
echo ""
echo "Steps (when starting):"
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
echo " 2. Clean up previous test data (e.g. managed config) so the harness starts clean."
echo " Sync skips app-managed filenames (zz-dnsmasq-webui.conf / .hosts) so rebuilds keep UI state."
echo " 2. Optionally remove managed *dnsmasq-webui*.conf / *dnsmasq-webui*.hosts (only with --reset-managed)."
echo " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
echo ""
echo "Path options:"
@@ -47,6 +49,8 @@ usage() {
echo "Mount behaviour:"
echo " (default) Preserve mount dir; sync source over existing contents."
echo " --clear Clear mount dir completely before sync. Use for a clean run."
echo " --reset-managed After sync, delete *dnsmasq-webui*.conf and *dnsmasq-webui*.hosts under the"
echo " mount so the app recreates managed config/hosts (leases and other files stay)."
echo ""
echo "Compose behaviour:"
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
@@ -86,6 +90,9 @@ usage() {
echo " $0 --clear"
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
echo ""
echo " $0 --reset-managed --build"
echo " Rebuild images; sync fixtures; drop managed conf/hosts so app writes fresh."
echo ""
echo " $0 --prepare-only"
echo " Only sync testdata -> testdata-mount; no containers."
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
@@ -161,6 +168,10 @@ while [ $# -gt 0 ]; do
CLEAR=true
shift
;;
--reset-managed)
RESET_MANAGED=true
shift
;;
--prepare-only)
PREPARE_ONLY=true
shift
@@ -267,15 +278,22 @@ if [ "$CLEAR" = true ]; then
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
fi
# Exclude sample leases (harness creates real lease file) and app-managed filenames so rebuilds preserve
# UI-written config/hosts; use --reset-managed or --clear when you want those removed.
if command -v rsync >/dev/null 2>&1; then
rsync -a --exclude='leases' "$SOURCE_DIR/" "$MOUNT_DIR/"
rsync -a \
--exclude='leases' \
--exclude='zz-dnsmasq-webui.conf' \
--exclude='zz-dnsmasq-webui.hosts' \
"$SOURCE_DIR/" "$MOUNT_DIR/"
else
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
rm -f "$MOUNT_DIR/leases"
(cd "$SOURCE_DIR" && tar cf - --exclude=leases --exclude=zz-dnsmasq-webui.conf --exclude=zz-dnsmasq-webui.hosts .) | (cd "$MOUNT_DIR" && tar xf -)
fi
# Clean up previous test data (e.g. managed config) so the harness starts clean (app will create zz-dnsmasq-webui.conf on startup).
find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true
if [ "$RESET_MANAGED" = true ]; then
echo "Removing app-managed files (*dnsmasq-webui*.conf / *dnsmasq-webui*.hosts) under $MOUNT_DIR"
find "$MOUNT_DIR" \( -name '*dnsmasq-webui*.conf' -o -name '*dnsmasq-webui*.hosts' \) -type f -delete 2>/dev/null || true
fi
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)."
@@ -1,59 +0,0 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Tests for <see cref="ConfigValueSource"/> and <see cref="ConfigValueSource.GetReadOnlyTooltip"/>.
/// </summary>
public class ConfigValueSourceTests
{
[Fact]
public void GetReadOnlyTooltip_WhenManaged_ReturnsNull()
{
var source = new ConfigValueSource("/data/zz-managed.conf", "zz-managed.conf", IsManaged: true, LineNumber: 5);
Assert.Null(source.GetReadOnlyTooltip());
}
[Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
{
var fileName = "02.conf";
var fullPath = $"/etc/dnsmasq.d/{fileName}";
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: null);
var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip);
Assert.Contains(fileName, tooltip);
Assert.Contains("readonly", tooltip);
Assert.Contains(fullPath, tooltip);
Assert.DoesNotContain("line", tooltip);
}
[Fact]
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
{
var fileName = "02.conf";
var fullPath = $"/etc/dnsmasq.d/{fileName}";
int? lineNumber = 3;
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: lineNumber);
var tooltip = source.GetReadOnlyTooltip();
Assert.NotNull(tooltip);
Assert.Contains(fileName, tooltip);
Assert.Contains($"line {lineNumber}", tooltip);
Assert.Contains("readonly", tooltip);
Assert.Contains(fullPath, tooltip);
}
[Fact]
public void IsReadOnly_WhenManaged_IsFalse()
{
var source = new ConfigValueSource("/data/zz.conf", "zz.conf", IsManaged: true);
Assert.False(source.IsReadOnly);
}
[Fact]
public void IsReadOnly_WhenNotManaged_IsTrue()
{
var source = new ConfigValueSource("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false);
Assert.True(source.IsReadOnly);
}
}
@@ -1,32 +0,0 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Documents and guards the Do0x20 display label rule: Default and Disabled both show "Disabled" (product choice).
/// Do not "fix" the display to show "Default" for unset without updating this test and the component comment.
/// </summary>
public class Do0x20EncodeDisplayLabelTests
{
/// <summary>Same logic as Do0x20EncodeDisplay.razor view mode: Enabled → "Enabled", else "Disabled".</summary>
private static string GetDisplayLabel(ExplicitToggleState state) =>
state == ExplicitToggleState.Enabled ? "Enabled" : "Disabled";
[Fact]
public void Do0x20_Default_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Default));
}
[Fact]
public void Do0x20_Disabled_ShowsDisabled()
{
Assert.Equal("Disabled", GetDisplayLabel(ExplicitToggleState.Disabled));
}
[Fact]
public void Do0x20_Enabled_ShowsEnabled()
{
Assert.Equal("Enabled", GetDisplayLabel(ExplicitToggleState.Enabled));
}
}
@@ -1,22 +0,0 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
namespace DnsmasqWebUI.Tests;
/// <summary>
/// Ensures effective-config editable options list includes supported cache flags.
/// </summary>
public class EffectiveConfigEditableOptionsTests
{
/// <summary>
/// strip-mac and strip-subnet are supported in the effective-config UI and should be listed in cache options.
/// </summary>
[Fact]
public void EditableOptions_Include_StripMac_And_StripSubnet()
{
var allOptionNames = EffectiveConfigSections.GetSectionsInOrder()
.SelectMany(t => EffectiveConfigSections.GetOptionsInSection(t.SectionId))
.ToHashSet(StringComparer.Ordinal);
Assert.Contains(DnsmasqConfKeys.StripMac, allOptionNames);
Assert.Contains(DnsmasqConfKeys.StripSubnet, allOptionNames);
}
}
@@ -0,0 +1,100 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>
/// Sample config line per option for consistent parser coverage. When adding new options to the app,
/// add an entry here so the option-coverage test continues to exercise every option.
/// </summary>
public static class OptionCoverageData
{
/// <summary>Option key and a minimal valid config line (without newline).</summary>
public static IReadOnlyList<(string OptionKey, string ConfigLine)> GetParserCoverageEntries()
{
return new (string, string)[]
{
// LastWins
(DnsmasqConfKeys.Port, "port=53"),
(DnsmasqConfKeys.CacheSize, "cache-size=500"),
(DnsmasqConfKeys.LocalTtl, "local-ttl=300"),
(DnsmasqConfKeys.MxTarget, "mx-target=mail.example.com"),
(DnsmasqConfKeys.DhcpLeasefile, "dhcp-leasefile=/var/lib/dnsmasq.leases"),
(DnsmasqConfKeys.DhcpLeaseMax, "dhcp-lease-max=1000"),
(DnsmasqConfKeys.Hostsdir, "hostsdir=/etc/dnsmasq.d"),
(DnsmasqConfKeys.PidFile, "pid-file=/run/dnsmasq.pid"),
(DnsmasqConfKeys.User, "user=nobody"),
(DnsmasqConfKeys.TftpRoot, "tftp-root=/var/lib/tftpboot"),
// Flag
(DnsmasqConfKeys.NoHosts, "no-hosts"),
(DnsmasqConfKeys.ExpandHosts, "expand-hosts"),
(DnsmasqConfKeys.BogusPriv, "bogus-priv"),
(DnsmasqConfKeys.NoResolv, "no-resolv"),
(DnsmasqConfKeys.DomainNeeded, "domain-needed"),
(DnsmasqConfKeys.StrictOrder, "strict-order"),
(DnsmasqConfKeys.AllServers, "all-servers"),
(DnsmasqConfKeys.BindInterfaces, "bind-interfaces"),
(DnsmasqConfKeys.DhcpAuthoritative, "dhcp-authoritative"),
(DnsmasqConfKeys.EnableTftp, "enable-tftp"),
(DnsmasqConfKeys.Dnssec, "dnssec"),
(DnsmasqConfKeys.Conntrack, "conntrack"),
(DnsmasqConfKeys.Do0x20Encode, "do-0x20-encode"),
(DnsmasqConfKeys.No0x20Encode, "no-0x20-encode"),
// Multi - resolver / DNS
(DnsmasqConfKeys.Server, "server=1.1.1.1"),
(DnsmasqConfKeys.Local, "local=/lan/"),
(DnsmasqConfKeys.RevServer, "rev-server=192.168.1.0/24,10.0.0.1"),
(DnsmasqConfKeys.Address, "address=/example.com/127.0.0.1"),
(DnsmasqConfKeys.Domain, "domain=home.lan,192.168.1.0/24"),
(DnsmasqConfKeys.Cname, "cname=www.example.com,example.com"),
(DnsmasqConfKeys.MxHost, "mx-host=example.com,mail.example.com,10"),
(DnsmasqConfKeys.Srv, "srv-host=_http._tcp.example.com,host.example.com,80"),
(DnsmasqConfKeys.PtrRecord, "ptr-record=1.168.192.in-addr.arpa,router.lan"),
(DnsmasqConfKeys.TxtRecord, "txt-record=example.com,text"),
(DnsmasqConfKeys.NaptrRecord, "naptr-record=example.com,0,0,a,s,r"),
(DnsmasqConfKeys.DnsRr, "dns-rr=example.com,16,01:02"),
(DnsmasqConfKeys.HostRecord, "host-record=host.example.com,1.2.3.4"),
(DnsmasqConfKeys.DynamicHost, "dynamic-host=example.com"),
(DnsmasqConfKeys.CaaRecord, "caa-record=example.com,0,issue,letsencrypt.org"),
(DnsmasqConfKeys.RebindDomainOk, "rebind-domain-ok=example.com"),
(DnsmasqConfKeys.BogusNxdomain, "bogus-nxdomain=192.168.1.1"),
(DnsmasqConfKeys.IgnoreAddress, "ignore-address=192.168.1.1"),
(DnsmasqConfKeys.Alias, "alias=0.0.0.0,example.com"),
(DnsmasqConfKeys.FilterRr, "filter-rr=0.0.0.0,A"),
(DnsmasqConfKeys.CacheRr, "cache-rr=0.0.0.0,A"),
(DnsmasqConfKeys.Ipset, "ipset=/example.com/set1"),
(DnsmasqConfKeys.Nftset, "nftset=/example.com/table/set"),
(DnsmasqConfKeys.ConnmarkAllowlist, "connmark-allowlist=0xff,example.com"),
// Multi - auth / synth
(DnsmasqConfKeys.AuthServer, "auth-server=zone.example.com,eth0"),
(DnsmasqConfKeys.SynthDomain, "synth-domain=dynamic.example.com,192.168.2.1,192.168.2.100"),
(DnsmasqConfKeys.AuthZone, "auth-zone=example.com"),
(DnsmasqConfKeys.TrustAnchor, "trust-anchor=.,20326,8,2,E4F1769B8A0E3142E125223716F878F0A3B2C3D4"),
// Multi - DHCP
(DnsmasqConfKeys.DhcpRange, "dhcp-range=192.168.1.50,192.168.1.150,12h"),
(DnsmasqConfKeys.DhcpHost, "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10"),
(DnsmasqConfKeys.DhcpOption, "dhcp-option=3,192.168.1.1"),
(DnsmasqConfKeys.DhcpMatch, "dhcp-match=set:efi,option:client-arch,6"),
(DnsmasqConfKeys.DhcpMac, "dhcp-mac=set:vendor,aa:bb:cc:dd:ee:ff"),
(DnsmasqConfKeys.DhcpNameMatch, "dhcp-name-match=set:name,hostname*"),
(DnsmasqConfKeys.DhcpIgnoreNames, "dhcp-ignore-names=tag:guest"),
(DnsmasqConfKeys.DhcpBoot, "dhcp-boot=pxelinux.0"),
(DnsmasqConfKeys.Leasequery, "leasequery=10.0.0.0/24"),
(DnsmasqConfKeys.RaParam, "ra-param=eth0,high"),
(DnsmasqConfKeys.Slaac, "slaac=eth0"),
(DnsmasqConfKeys.PxeService, "pxe-service=x86PC,Install Linux"),
(DnsmasqConfKeys.DhcpRelay, "dhcp-relay=192.168.1.1,eth0"),
(DnsmasqConfKeys.DhcpCircuitid, "dhcp-circuitid=eth0,010203"),
(DnsmasqConfKeys.DhcpRemoteid, "dhcp-remoteid=010203"),
(DnsmasqConfKeys.DhcpSubscrid, "dhcp-subscrid=010203"),
(DnsmasqConfKeys.TagIf, "tag-if=set:tag,eth0"),
(DnsmasqConfKeys.SharedNetwork, "shared-network=name,192.168.1.0/24"),
(DnsmasqConfKeys.DhcpOptionPxe, "dhcp-option-pxe=set:known,1"),
(DnsmasqConfKeys.DhcpVendorclass, "dhcp-vendorclass=set:pxe,PXEClient"),
(DnsmasqConfKeys.DhcpUserclass, "dhcp-userclass=set:efi,EFI"),
(DnsmasqConfKeys.AddnHosts, "addn-hosts=/etc/hosts.extra"),
(DnsmasqConfKeys.Interface, "interface=eth0"),
(DnsmasqConfKeys.ListenAddress, "listen-address=127.0.0.1"),
(DnsmasqConfKeys.ResolvFile, "resolv-file=/etc/resolv.dnsmasq"),
};
}
}
@@ -0,0 +1,51 @@
using System.Text.Json;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>Expected parser/effective state for a real-world corpus case. Counts and booleans only.</summary>
public sealed class CorpusExpected
{
public int? ServerCountMin { get; set; }
public int? LocalCountMin { get; set; }
public int? AddressCountMin { get; set; }
public int? CacheSize { get; set; }
public int? AddnHostsCountMin { get; set; }
public int? DomainCountMin { get; set; }
public bool? NoHosts { get; set; }
public bool? NoResolv { get; set; }
public bool? BogusPriv { get; set; }
public string? Do0x20State { get; set; }
public int? Port { get; set; }
public bool? ExpandHosts { get; set; }
public int? CnameCountMin { get; set; }
public int? TxtRecordCountMin { get; set; }
public int? SrvCountMin { get; set; }
public int? PtrRecordCountMin { get; set; }
public int? MxHostCountMin { get; set; }
public int? AuthZoneCountMin { get; set; }
public int? SynthDomainCountMin { get; set; }
public int? AuthServerCountMin { get; set; }
}
/// <summary>Single real-world corpus case from dnsmasq-real-cases.json.</summary>
public sealed class CorpusCase
{
public string File { get; set; } = "";
public CorpusExpected Expected { get; set; } = new();
public List<string> SemanticInvalidOptions { get; set; } = [];
}
/// <summary>Loads real-world corpus index and resolves paths via TestDataHelper.</summary>
public static class RealWorldCasesHelper
{
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public static IReadOnlyList<CorpusCase> LoadCases()
{
var path = TestDataHelper.GetPath("real-world/dnsmasq-real-cases.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<CorpusCase>>(json, JsonOptions) ?? [];
}
public static string Resolve(CorpusCase c) => TestDataHelper.GetPath(c.File);
}
@@ -0,0 +1,50 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>Ensures corpus index and files stay in sync; fails fast when testdata drifts.</summary>
public class RealWorldCorpusIntegrityTests
{
[Fact]
public void EveryCorpusCase_PointsToExistingFile()
{
var cases = RealWorldCasesHelper.LoadCases();
Assert.NotEmpty(cases);
foreach (var c in cases)
{
var path = RealWorldCasesHelper.Resolve(c);
Assert.True(File.Exists(path), $"Corpus file missing: {c.File}");
}
}
[Fact]
public void EveryBadCase_DeclaresSemanticInvalidOptions()
{
var cases = RealWorldCasesHelper.LoadCases();
var badFiles = new[] { "bad/", "real-world/bad/" };
foreach (var c in cases)
{
var isBad = badFiles.Any(p => c.File.Contains(p, StringComparison.Ordinal));
if (isBad)
Assert.NotEmpty(c.SemanticInvalidOptions);
}
}
[Fact]
public void SemanticInvalidOptions_ArePresentInParsedValues()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
{
if (c.SemanticInvalidOptions.Count == 0)
continue;
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
foreach (var option in c.SemanticInvalidOptions)
{
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
Assert.NotEmpty(values);
}
}
}
}
@@ -1,4 +1,4 @@
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Helpers;
/// <summary>
/// Resolves paths to testdata files. Files are copied to output (testdata/) when the project builds.
@@ -0,0 +1,149 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Editing;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
public class EffectiveConfigMultiDisplayContextTests
{
private static EffectiveMultiValueConfigFieldDescriptor DomainDescriptor(
IReadOnlyList<ValueWithSource> baseline) =>
new(
EffectiveConfigSections.SectionHosts,
DnsmasqConfKeys.Domain,
Status: null,
ResolveItems: _ => baseline,
Behavior: new DefaultMultiValueEditBehavior(),
Validator: null);
private static ConfigValueSource ReadonlySource() =>
new("/etc/dnsmasq.conf", "dnsmasq.conf", IsManaged: false, LineNumber: 1);
[Fact]
public void From_BaselineOnly_UsesBaselineItems()
{
var baseline = new List<ValueWithSource>
{
new("a.example", null),
new("b.example", null)
};
var d = DomainDescriptor(baseline);
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, pendingList: null, hasPendingChange: false, draftList: null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Same(baseline, ctx.BaselineItems);
Assert.Equal(["a.example", "b.example"], ctx.EffectiveValues);
Assert.False(ctx.HasPendingChange);
Assert.False(ctx.HasDraftChange);
Assert.False(ctx.HasPendingOrDraft);
}
[Fact]
public void From_PendingOverridesBaseline_PreservesSourcesWhereValuesMatch()
{
var ro = ReadonlySource();
var baseline = new List<ValueWithSource>
{
new("a.example", ro),
new("b.example", null)
};
var d = DomainDescriptor(baseline);
var pending = new List<string> { "a.example", "c.example" };
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, pending, hasPendingChange: true, draftList: null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Equal(["a.example", "c.example"], ctx.EffectiveValues);
Assert.True(ctx.HasPendingChange);
Assert.Equal(ro, ctx.EffectiveItems[0].Source);
Assert.Null(ctx.EffectiveItems[1].Source);
}
[Fact]
public void From_DraftOverridesPending()
{
var baseline = new List<ValueWithSource> { new("a.example", null) };
var d = DomainDescriptor(baseline);
var pending = new List<string> { "b.example" };
var draft = new List<string> { "c.example" };
var ctx = EffectiveContext(d, baseline, pending, draft);
Assert.Equal(["c.example"], ctx.EffectiveValues);
Assert.True(ctx.HasDraftChange);
Assert.True(ctx.HasPendingOrDraft);
}
[Fact]
public void From_AllReadonlyNotEditMode_SetsRowSourceFromFirstItem()
{
var ro = ReadonlySource();
var baseline = new List<ValueWithSource>
{
new("a.example", ro)
};
var d = DomainDescriptor(baseline);
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, null, false, null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Equal(ro, ctx.RowSource);
}
[Fact]
public void From_EditModeOrMixedWritable_ClearsRowSource()
{
var ro = ReadonlySource();
var baseline = new List<ValueWithSource>
{
new("a.example", ro)
};
var d = DomainDescriptor(baseline);
var ctxEdit = EffectiveConfigMultiDisplayContext.From(
d, baseline, null, false, null,
isEditMode: true, isActive: false, fieldKey: "k");
Assert.Null(ctxEdit.RowSource);
var mixed = new List<ValueWithSource>
{
new("a.example", ro),
new("b.example", null)
};
var d2 = DomainDescriptor(mixed);
var ctxMixed = EffectiveConfigMultiDisplayContext.From(
d2, mixed, null, false, null,
isEditMode: false, isActive: false, fieldKey: "k");
Assert.Null(ctxMixed.RowSource);
}
[Fact]
public void From_IsActiveEditor_RespectsCapabilityDisabled()
{
var baseline = new List<ValueWithSource> { new("a.example", null) };
var d = DomainDescriptor(baseline) with { IsCapabilityDisabled = true };
var ctx = EffectiveConfigMultiDisplayContext.From(
d, baseline, null, false, null,
isEditMode: true, isActive: true, fieldKey: "k");
Assert.False(ctx.IsActiveEditor);
Assert.False(ctx.ShowEditableBadge);
}
private static EffectiveConfigMultiDisplayContext EffectiveContext(
EffectiveMultiValueConfigFieldDescriptor d,
IReadOnlyList<ValueWithSource> baseline,
IReadOnlyList<string>? pending,
IReadOnlyList<string>? draft) =>
EffectiveConfigMultiDisplayContext.From(
d, baseline, pending, pending != null, draft,
isEditMode: false, isActive: false, fieldKey: "k");
}
@@ -0,0 +1,90 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
public class ManagedHostsPendingChangeDiffTests
{
[Fact]
public void Build_same_entries_returns_empty()
{
var e = new HostEntry { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } };
var oldL = new List<HostEntry> { e };
var newL = new List<HostEntry>
{
new()
{
Id = e.Id,
LineNumber = e.LineNumber,
Address = e.Address,
Names = new List<string>(e.Names)
}
};
Assert.Empty(ManagedHostsPendingChangeDiff.Build(oldL, newL));
}
[Fact]
public void Build_added_entry()
{
var oldL = new List<HostEntry>();
var newL = new List<HostEntry>
{
new() { Id = "new:x", LineNumber = 1, Address = "192.168.0.1", Names = new List<string> { "router" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var a = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Added, a.Kind);
Assert.Contains("192.168.0.1", a.AfterLine, StringComparison.Ordinal);
Assert.Contains("router", a.AfterLine, StringComparison.Ordinal);
}
[Fact]
public void Build_removed_entry()
{
var oldL = new List<HostEntry>
{
new() { Id = "1", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "x" } }
};
var newL = new List<HostEntry>();
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var r = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Removed, r.Kind);
Assert.Contains("10.0.0.2", r.BeforeLine, StringComparison.Ordinal);
}
[Fact]
public void Build_modified_address()
{
var oldL = new List<HostEntry>
{
new() { Id = "k", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "h" } }
};
var newL = new List<HostEntry>
{
new() { Id = "k", LineNumber = 1, Address = "10.0.0.9", Names = new List<string> { "h" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var m = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.Modified, m.Kind);
Assert.Contains("10.0.0.1", m.BeforeLine, StringComparison.Ordinal);
Assert.Contains("10.0.0.9", m.AfterLine, StringComparison.Ordinal);
}
[Fact]
public void Build_order_only()
{
var oldL = new List<HostEntry>
{
new() { Id = "a", LineNumber = 1, Address = "10.0.0.1", Names = new List<string> { "a" } },
new() { Id = "b", LineNumber = 2, Address = "10.0.0.2", Names = new List<string> { "b" } }
};
var newL = new List<HostEntry>
{
new() { Id = "b", LineNumber = 1, Address = "10.0.0.2", Names = new List<string> { "b" } },
new() { Id = "a", LineNumber = 2, Address = "10.0.0.1", Names = new List<string> { "a" } }
};
var d = ManagedHostsPendingChangeDiff.Build(oldL, newL);
var o = Assert.Single(d);
Assert.Equal(ManagedHostsLineDiffKind.OrderOnly, o.Kind);
}
}
@@ -0,0 +1,53 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.EffectiveConfig;
public class PendingManagedHostsChangeTests
{
[Fact]
public void HasChanges_WhenEntriesDiffer_ReturnsTrue()
{
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
var newEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.2", Names = new List<string> { "host1" } } };
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
Assert.True(pending.HasChanges);
}
[Fact]
public void HasChanges_WhenEntriesSame_ReturnsFalse()
{
var entries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string> { "host1" } } };
var clone = entries.Select(e => new HostEntry { Id = e.Id, Address = e.Address, Names = new List<string>(e.Names) }).ToList();
var pending = new PendingManagedHostsChange(entries, clone, "/etc/hosts");
Assert.False(pending.HasChanges);
}
[Fact]
public void HasChanges_WhenCountDiffers_ReturnsTrue()
{
var oldEntries = new List<HostEntry> { new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() } };
var newEntries = new List<HostEntry>
{
new() { Id = "1", Address = "192.168.1.1", Names = new List<string>() },
new() { Id = "2", Address = "192.168.1.2", Names = new List<string>() }
};
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
Assert.True(pending.HasChanges);
}
[Fact]
public void HasChanges_WhenInlineCommentDiffers_ReturnsTrue()
{
var oldEntries = new List<HostEntry>
{
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "old" }
};
var newEntries = new List<HostEntry>
{
new() { Id = "1", Address = "10.0.0.1", Names = new List<string> { "a" }, InlineComment = "new" }
};
var pending = new PendingManagedHostsChange(oldEntries, newEntries, "/etc/hosts");
Assert.True(pending.HasChanges);
}
}
@@ -0,0 +1,93 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public class HostsEffectiveNamesDomainRulesTests
{
[Fact]
public void Expand_UsesMatchingScopedDomainForIpv4()
{
var names = new[] { "router" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.9");
Assert.Equal(new[] { "router", "router.corp.lan" }, result);
}
[Fact]
public void Expand_FallsBackToDefaultDomainWhenNoScopedMatch()
{
var names = new[] { "cache" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.99.1.9");
Assert.Equal(new[] { "cache", "cache.home.lan" }, result);
}
[Fact]
public void Expand_UsesLastParsedScopedRuleWhenRangesOverlap()
{
var names = new[] { "app" };
var domains = new[]
{
"home.lan",
"corp-wide.lan,10.0.0.0/8",
"corp-subnet.lan,10.20.0.0/16"
};
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "10.20.1.5");
Assert.Equal(new[] { "app", "app.corp-subnet.lan" }, result);
}
[Fact]
public void Expand_UsesDefaultDomainForIpv6Address()
{
var names = new[] { "node" };
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var result = HostsEffectiveNames.Expand(names, expandHosts: true, domains, "2001:db8::1");
Assert.Equal(new[] { "node", "node.home.lan" }, result);
}
[Fact]
public void ExplainSuffixSource_ScopedRuleMentionsRawLine()
{
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var msg = HostsEffectiveNames.ExplainSuffixSource(
expandHosts: true,
domains,
"10.20.1.9",
new[] { "router" });
Assert.Equal("Suffix from scoped rule: corp.lan,10.20.0.0/16", msg);
}
[Fact]
public void ExplainSuffixSource_DefaultDomainWhenNoScopedMatch()
{
var domains = new[] { "home.lan", "corp.lan,10.20.0.0/16" };
var msg = HostsEffectiveNames.ExplainSuffixSource(
expandHosts: true,
domains,
"10.99.1.9",
new[] { "cache" });
Assert.Equal("Suffix from default rule: home.lan", msg);
}
[Fact]
public void ExplainSuffixSource_ExpandHostsOff()
{
var msg = HostsEffectiveNames.ExplainSuffixSource(
expandHosts: false,
new[] { "home.lan" },
"10.0.0.1",
new[] { "a" });
Assert.Contains("expand-hosts is off", msg);
}
}
@@ -0,0 +1,34 @@
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public class HostsEntryEditValidatorTests
{
[Fact]
public void Empty_is_invalid()
{
var e = HostsEntryEditValidator.GetErrors("", Array.Empty<string>());
Assert.NotEmpty(e);
}
[Fact]
public void Valid_ipv4_and_name_ok()
{
Assert.True(HostsEntryEditValidator.IsValid("192.168.1.1", new[] { "router" }));
}
[Fact]
public void Missing_names_invalid()
{
var e = HostsEntryEditValidator.GetErrors("10.0.0.1", Array.Empty<string>());
Assert.Contains(e, x => x.Contains("hostname", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Field_errors_split_address_and_names()
{
Assert.NotEmpty(HostsEntryEditValidator.GetAddressFieldErrors(""));
Assert.Empty(HostsEntryEditValidator.GetHostnameFieldErrors(new[] { "h" }));
Assert.NotEmpty(HostsEntryEditValidator.GetHostnameFieldErrors(Array.Empty<string>()));
}
}
@@ -0,0 +1,111 @@
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Models.Ui;
namespace DnsmasqWebUI.Tests.Models.Hosts;
public sealed class HostsFileFilterBuilderTests
{
private static HostsPageRow Row(HostsRowSourceKind kind, string path, bool isComment = false) =>
new(
Id: "id",
SourceKind: kind,
SourcePath: path,
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "127.0.0.1",
Names: [],
EffectiveNames: [],
IsComment: isComment,
LineNumber: 1);
[Fact]
public void Build_InjectsManagedPath_WhenNotInRows()
{
var managed = "/data/managed.hosts";
var builder = new HostsFileFilterBuilder([], managed);
var model = builder.Build();
Assert.NotNull(model.AllOption);
var managedSection = model.Sections.Single();
Assert.Equal("Managed hosts", managedSection.Label);
Assert.Equal("managed", managedSection.Kind);
Assert.Single(managedSection.Options);
Assert.Equal(managed, managedSection.Options[0].Value);
Assert.Equal(0, managedSection.Options[0].Count);
}
[Fact]
public void Build_OrdersGroupsManagedSystemAddn_AndCountsNonComments()
{
var rows = new[]
{
Row(HostsRowSourceKind.SystemHosts, "/etc/hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts"),
Row(HostsRowSourceKind.Managed, "/m/a.hosts", isComment: true),
Row(HostsRowSourceKind.AddnHosts, "/extra/b.hosts"),
};
var builder = new HostsFileFilterBuilder(rows, managedHostsPath: null);
var model = builder.Build();
Assert.Equal(3, model.Sections.Count);
Assert.Equal("managed", model.Sections[0].Kind);
Assert.Equal("system", model.Sections[1].Kind);
Assert.Equal("addn", model.Sections[2].Kind);
Assert.Single(model.Sections[0].Options);
Assert.Equal(1, model.Sections[0].Options[0].Count);
}
[Fact]
public void GetSummaryText_AllFiles_ShowsRecordCount()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
Assert.Equal("All files · 1 records", builder.GetSummaryText(""));
Assert.Equal("All files · 1 records", builder.GetSummaryText(" "));
}
[Fact]
public void GetSummaryText_SelectedFile_UsesShortLabelAndPrefix()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/my.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
Assert.Equal("Managed · my.hosts (1)", builder.GetSummaryText("/m/my.hosts"));
}
[Fact]
public void GetSummaryText_UnknownPath_FallsBackToShortLabel()
{
var builder = new HostsFileFilterBuilder([], null);
Assert.Equal("orphan.txt", builder.GetSummaryText("/tmp/orphan.txt"));
}
[Fact]
public void GetTriggerSummary_SingleSource_SplitsPrefixPrimaryAndCount()
{
var rows = new[] { Row(HostsRowSourceKind.AddnHosts, "/etc/extra.d/hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
var s = builder.GetTriggerSummary("/etc/extra.d/hosts");
Assert.Equal(GroupedSelectTriggerSummaryKind.SingleSource, s.Kind);
Assert.Equal("Additional", s.CategoryPrefix);
Assert.Equal("hosts", s.PrimaryLabel);
Assert.Equal("1", s.SecondaryMeta);
Assert.Equal("Additional · hosts (1)", s.AccessibleFullText);
}
[Fact]
public void GetTriggerSummary_AllSources_WithRecords_HasSecondaryMeta()
{
var rows = new[] { Row(HostsRowSourceKind.Managed, "/m/x.hosts") };
var builder = new HostsFileFilterBuilder(rows, null);
var s = builder.GetTriggerSummary("");
Assert.Equal(GroupedSelectTriggerSummaryKind.AllSources, s.Kind);
Assert.Null(s.CategoryPrefix);
Assert.Equal("All files", s.PrimaryLabel);
Assert.Equal("1 records", s.SecondaryMeta);
}
}
@@ -0,0 +1,58 @@
using DnsmasqWebUI.Infrastructure.Serialization;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
namespace DnsmasqWebUI.Tests.Serialization;
public class DnsmasqConfTextTests
{
[Fact]
public void DirectivePrefix_ReturnsOptionEquals()
{
Assert.Equal("dhcp-host=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.DhcpHost));
Assert.Equal("server=", DnsmasqConfText.DirectivePrefix(DnsmasqConfKeys.Server));
}
[Fact]
public void DirectiveLine_WhenValueNullOrEmpty_ReturnsOptionOnly()
{
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", null));
Assert.Equal("dhcp-host", DnsmasqConfText.DirectiveLine("dhcp-host", ""));
}
[Fact]
public void DirectiveLine_WhenValueProvided_ReturnsOptionEqualsValue()
{
Assert.Equal("dhcp-host=11:22:33:44:55:66,192.168.1.1", DnsmasqConfText.DirectiveLine("dhcp-host", "11:22:33:44:55:66,192.168.1.1"));
}
[Fact]
public void StripDirectivePrefix_WhenLineStartsWithPrefix_ReturnsRest()
{
var line = "dhcp-host=mac,ip";
Assert.Equal("mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
}
[Fact]
public void StripDirectivePrefix_WhenLineDoesNotStartWithPrefix_ReturnsFullLine()
{
var line = "other=value";
Assert.Equal("other=value", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, line));
}
[Fact]
public void StripDirectivePrefix_WhenLineIsCommented_PreservesLeadingHashInResult()
{
Assert.Equal("#mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=mac,ip"));
Assert.Equal("##mac,ip", DnsmasqConfText.StripDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=mac,ip"));
}
[Fact]
public void HasDirectivePrefix_MatchesOptionAndCommentedForms()
{
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "dhcp-host=value"));
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "#dhcp-host=value"));
Assert.True(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "##dhcp-host=value"));
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "server=8.8.8.8"));
Assert.False(DnsmasqConfText.HasDirectivePrefix(DnsmasqConfKeys.DhcpHost, "other"));
}
}
@@ -0,0 +1,66 @@
using DnsmasqWebUI.Infrastructure.Serialization;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Serialization;
public class EffectiveConfigDirectiveSerializerTests
{
private readonly EffectiveConfigDirectiveSerializer _sut = new();
[Fact]
public void SerializeSingle_Flag_True_WritesBareKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, true);
Assert.Equal(DnsmasqConfKeys.Conntrack, result);
}
[Fact]
public void SerializeSingle_Flag_False_WritesEmpty()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Conntrack, false);
Assert.Equal("", result);
}
[Fact]
public void SerializeSingle_SingleValue_WritesKeyEqualsValue()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Port, 5353);
Assert.Equal("port=5353", result);
}
[Fact]
public void SerializeSingle_KeyOnlyOrValue_Empty_WritesBareKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.UseStaleCache, "");
Assert.Equal(DnsmasqConfKeys.UseStaleCache, result);
}
[Fact]
public void SerializeMulti_MultiValue_WritesEachDirective()
{
var result = _sut.SerializeMulti(DnsmasqConfKeys.Server, ["1.1.1.1", "8.8.8.8"]);
Assert.Equal(["server=1.1.1.1", "server=8.8.8.8"], result);
}
[Fact]
public void SerializeMulti_MultiKeyOnlyOrValue_WritesMixedForms()
{
var result = _sut.SerializeMulti(DnsmasqConfKeys.Leasequery, ["", "net:tag"]);
Assert.Equal(["leasequery", "leasequery=net:tag"], result);
}
[Fact]
public void SerializeSingle_InversePair_Enabled_WritesPositiveKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Enabled);
Assert.Equal(DnsmasqConfKeys.Do0x20Encode, result);
}
[Fact]
public void SerializeSingle_InversePair_Disabled_WritesNegativeKey()
{
var result = _sut.SerializeSingle(DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Disabled);
Assert.Equal(DnsmasqConfKeys.No0x20Encode, result);
}
}
@@ -0,0 +1,106 @@
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
public class DhcpHostOptionValueHandlerTests
{
private readonly DhcpHostOptionValueHandler _sut = new();
[Fact]
public void OptionName_EqualsDnsmasqConfKeysDhcpHost()
{
Assert.Equal(DnsmasqConfKeys.DhcpHost, _sut.OptionName);
}
[Fact]
public void SerializeValue_EmitsValueOnlyForm()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "11:22:33:44:55:66" },
Address = "192.168.1.10"
};
var value = _sut.SerializeValue(entry);
Assert.Equal("11:22:33:44:55:66, 192.168.1.10", value);
}
[Fact]
public void SerializeValue_IgnoreEntry_EmitsIgnoreToken()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Ignore = true
};
var value = _sut.SerializeValue(entry);
Assert.Contains("ignore", value);
Assert.Contains("aa:bb:cc:dd:ee:ff", value);
}
[Fact]
public void SerializeValue_EmptyEntry_ReturnsEmptyString()
{
var entry = new DhcpHostEntry();
var value = _sut.SerializeValue(entry);
Assert.Equal("", value);
}
[Fact]
public void TryParseValue_RoundTripsStructuredFields()
{
var input = new DhcpHostEntry
{
MacAddresses = new List<string> { "11:22:33:44:55:66" },
Address = "192.168.1.10",
Extra = new List<string> { "id:01:02:03", "set:test" },
Comment = "note",
IsComment = true
};
var value = _sut.SerializeValue(input);
var ok = _sut.TryParseValue(value, 1, out var output);
Assert.True(ok);
Assert.NotNull(output);
Assert.True(output!.IsComment);
Assert.Contains("id:01:02:03", output.Extra!);
Assert.Equal("note", output.Comment);
}
[Fact]
public void TryParseValue_IsComment_RoundTrips()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Address = "192.168.1.1",
IsComment = true
};
var value = _sut.SerializeValue(entry);
Assert.StartsWith("#", value);
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
Assert.True(ok);
Assert.NotNull(roundTripped);
Assert.True(roundTripped!.IsComment);
Assert.Single(roundTripped.MacAddresses!);
Assert.Equal("aa:bb:cc:dd:ee:ff", roundTripped.MacAddresses[0]);
Assert.Equal("192.168.1.1", roundTripped.Address);
}
[Fact]
public void TryParseValue_Comment_EmitsTrailingComment_AndRoundTrips()
{
var entry = new DhcpHostEntry
{
MacAddresses = new List<string> { "aa:bb:cc:dd:ee:ff" },
Address = "192.168.1.1",
Comment = " my note "
};
var value = _sut.SerializeValue(entry);
Assert.Contains(" # ", value);
var ok = _sut.TryParseValue(value, 1, out var roundTripped);
Assert.True(ok);
Assert.NotNull(roundTripped);
Assert.Equal("my note", roundTripped!.Comment?.Trim());
}
}
@@ -0,0 +1,126 @@
using System.Reflection;
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers;
using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dhcp;
namespace DnsmasqWebUI.Tests.Serialization.OptionHandlers;
/// <summary>Ensures structured handlers use DnsmasqConfKeys and registry has no duplicate option names.</summary>
public class StructuredOptionValueHandlerRegistryTests
{
[Fact]
public void DhcpHostOptionValueHandler_OptionName_EqualsDnsmasqConfKeysDhcpHost()
{
var handler = new DhcpHostOptionValueHandler();
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
}
[Fact]
public void DhcpHostOptionValueHandler_OptionName_IsFromDnsmasqConfKeys()
{
var handler = new DhcpHostOptionValueHandler();
var validKeys = GetDnsmasqConfKeysValues();
Assert.Contains(handler.OptionName, validKeys);
}
[Fact]
public void Registry_WithDhcpHostHandler_ReturnsHandlerForDhcpHost()
{
var handler = new DhcpHostOptionValueHandler();
var registry = new StructuredOptionValueHandlerRegistry(new[] { handler });
var resolved = registry.Get<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
Assert.NotNull(resolved);
Assert.Equal(DnsmasqConfKeys.DhcpHost, resolved.OptionName);
}
[Fact]
public void Registry_IsStructured_WhenHandlerExists_ReturnsTrue()
{
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
Assert.True(registry.IsStructured(DnsmasqConfKeys.DhcpHost));
Assert.False(registry.IsStructured(DnsmasqConfKeys.Server));
}
[Fact]
public void Registry_GetRequired_WhenHandlerExists_ReturnsTypedHandler()
{
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
var handler = registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
Assert.Equal(DnsmasqConfKeys.DhcpHost, handler.OptionName);
}
[Fact]
public void Registry_GetRequired_WhenOptionIsNotStructured_Throws()
{
var registry = new StructuredOptionValueHandlerRegistry([new DhcpHostOptionValueHandler()]);
var ex = Assert.Throws<InvalidOperationException>(() => registry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.Server));
Assert.Contains("not declared as a structured option", ex.Message);
}
[Fact]
public void Registry_DuplicateOptionNames_Throws()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new StructuredOptionValueHandlerRegistry([
new DhcpHostOptionValueHandler(),
new DuplicateDhcpHostHandler()
]));
Assert.Contains("Duplicate structured option handlers", ex.Message);
}
[Fact]
public void Semantics_DhcpHost_HasStructuredValueTypeMatchingHandler()
{
var structuredType = EffectiveConfigSpecialOptionSemantics.GetStructuredValueType(DnsmasqConfKeys.DhcpHost);
Assert.NotNull(structuredType);
Assert.Equal(typeof(DhcpHostEntry), structuredType);
var handler = new DhcpHostOptionValueHandler();
Assert.Equal(structuredType, handler.ValueType);
}
[Fact]
public void Registry_HandlerWithoutStructuredMetadata_Throws()
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new StructuredOptionValueHandlerRegistry([new InvalidServerHandler()]));
Assert.Contains("no StructuredValueType", ex.Message);
}
private static HashSet<string> GetDnsmasqConfKeysValues()
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in typeof(DnsmasqConfKeys).GetFields(BindingFlags.Public | BindingFlags.Static))
{
if (field.FieldType == typeof(string) && field.GetValue(null) is string value)
set.Add(value);
}
return set;
}
private sealed class DuplicateDhcpHostHandler : IStructuredOptionValueHandler<DhcpHostEntry>
{
public string OptionName => DnsmasqConfKeys.DhcpHost;
public Type ValueType => typeof(DhcpHostEntry);
public string SerializeLine(DhcpHostEntry value) => "";
public string SerializeValue(DhcpHostEntry value) => "";
public bool TryParseValue(string text, int lineNumber, out DhcpHostEntry? value)
{
value = null;
return false;
}
}
private sealed class InvalidServerHandler : IStructuredOptionValueHandler<string>
{
public string OptionName => DnsmasqConfKeys.Server;
public Type ValueType => typeof(string);
public string SerializeLine(string value) => "";
public string SerializeValue(string value) => value;
public bool TryParseValue(string text, int lineNumber, out string? value)
{
value = text;
return true;
}
}
}
@@ -1,7 +1,7 @@
using DnsmasqWebUI.Infrastructure.Helpers.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.Dnsmasq;
/// <summary>
/// Tests for parsing "Compile time options: ..." from dnsmasq --version output.
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqConfDhcpHostLineParser. Format: dhcp-host=[hwaddr][,id:...][,set:tag][,tag:tag][,ip][,hostname][,lease][,ignore]
@@ -1,7 +1,7 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqConfDirectiveParser. Key/value parsing (TryParseKeyValue) and comment stripping (StripComment)
@@ -1,8 +1,10 @@
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqConfFileLineParser. Parses dnsmasq config file lines into Blank, Comment, AddnHosts, DhcpHost, Other.
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Parser semantics: flags (option present/absent/with value), last-wins, case-sensitive, path order.</summary>
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>DHCP advanced, TFTP/PXE, DNSSEC, enable-dbus/ubus, fast-dns-retry.</summary>
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Boolean flags: no-hosts, bogus-priv, strict-order, domain-needed, no-poll, bind-*, rebind-*, clear-on-reload, filter*, localise-queries, log-debug, dhcp-authoritative, leasefile-ro.</summary>
@@ -1,8 +1,10 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Hosts (addn-hosts, hostsdir) and DHCP lease file parsing.</summary>
@@ -1,9 +1,9 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Config discovery: conf-file=, conf-dir=, GetIncludedPaths, GetFirstConfDir, GetIncludedPathsWithSource, conf-dir suffix.</summary>
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Interfaces, auth-server, DHCP/option/resolv multi-value, rebind/bogus/ignore/alias, filter-rr, cache-rr.</summary>
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Positive parser tests for added options: key wired and "option present" behaviour.
@@ -1,7 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests that the parser correctly handles the official dnsmasq example (testdata/dnsmasq.official.conf.example).
@@ -79,32 +80,6 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
Assert.False(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.LogDhcp));
}
[Fact]
public void OfficialExample_FileContainsExpectedOptionNames()
{
var path = GetOfficialExamplePath();
var content = File.ReadAllText(path);
// Sanity: official example documents the options we support (as commented lines).
Assert.Contains("read-ethers", content);
Assert.Contains("dhcp-option-force", content);
Assert.Contains("dhcp-rapid-commit", content);
Assert.Contains("dhcp-script", content);
Assert.Contains("tftp-no-fail", content);
Assert.Contains("tftp-no-blocksize", content);
Assert.Contains("mx-target", content);
Assert.Contains("localmx", content);
Assert.Contains("selfmx", content);
Assert.Contains("enable-ra", content);
Assert.Contains("log-dhcp", content);
Assert.Contains("ipset=", content);
Assert.Contains("nftset=", content);
Assert.Contains("dhcp-mac", content);
Assert.Contains("dhcp-name-match", content);
Assert.Contains("dhcp-ignore-names", content);
// SRV records use option name srv-host (not "srv")
Assert.Contains("srv-host", content);
}
[Fact]
public void GetAddnHostsPathsFromConfigFiles_OfficialExample_AllCommented_ReturnsEmpty()
{
@@ -123,4 +98,23 @@ public class DnsmasqConfIncludeParserOfficialExampleTests
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.False(noHosts);
}
/// <summary>Official-like sample with commented and active lines: commented ignored, active parsed, state reflects active only.</summary>
[Fact]
public void OfficialLikeActive_CommentedLinesIgnored_ActiveLinesParsed()
{
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-official-like-active.conf");
Assert.True(File.Exists(mainPath));
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.NotNull(portVal);
Assert.True(int.TryParse(portVal, out var port) && port == 5353);
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
Assert.Single(servers);
Assert.Equal("1.1.1.1", servers[0]);
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
}
}
@@ -0,0 +1,73 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Ensures every option in the coverage list is parsed consistently: one config line per option,
/// dispatch by parser behavior (Flag / LastWins / Multi), assert we get a value back.
/// Add new options to <see cref="OptionCoverageData.GetParserCoverageEntries"/> when adding to the app.
/// </summary>
public class DnsmasqConfIncludeParserOptionCoverageTests
{
public static IEnumerable<object[]> GetCoverageEntries()
{
foreach (var (optionKey, configLine) in OptionCoverageData.GetParserCoverageEntries())
yield return new object[] { optionKey, configLine };
}
[Theory]
[MemberData(nameof(GetCoverageEntries))]
public void EveryOption_ParseSingleLine_ReturnsExpectedResult(string optionKey, string configLine)
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-opt-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var conf = Path.Combine(dir, "dnsmasq.conf");
try
{
File.WriteAllText(conf, configLine + "\n");
var paths = new[] { conf };
if (optionKey == DnsmasqConfKeys.AddnHosts)
{
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.True(addn.Count >= 1, $"addn-hosts should parse from: {configLine}");
return;
}
var behavior = EffectiveConfigParserBehaviorMap.GetBehavior(optionKey);
switch (behavior)
{
case EffectiveConfigParserBehavior.Flag:
{
var flag = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, optionKey);
Assert.True(flag, $"Flag option {optionKey} should be true when set.");
break;
}
case EffectiveConfigParserBehavior.LastWins:
{
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, optionKey);
Assert.NotNull(value);
Assert.True(value!.Length > 0, $"LastWins option {optionKey} should return non-empty value.");
break;
}
case EffectiveConfigParserBehavior.Multi:
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, optionKey);
Assert.True(list.Count >= 1, $"Multi option {optionKey} should return at least one value.");
break;
}
default:
Assert.Fail($"Unknown behavior for {optionKey}");
break;
}
}
finally
{
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
}
@@ -0,0 +1,148 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Corpus-driven parser tests using testdata/real-world. Asserts counts, booleans, and effective state only.
/// </summary>
public class DnsmasqConfIncludeParserRealWorldCorpusTests
{
public static IEnumerable<object[]> GetCases()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
yield return new object[] { c };
}
[Theory]
[MemberData(nameof(GetCases))]
public void GetIncludedPaths_RealWorldCorpus_ReturnsAtLeastMain(CorpusCase c)
{
var mainPath = RealWorldCasesHelper.Resolve(c);
Assert.True(File.Exists(mainPath), $"Corpus file missing: {c.File}");
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
Assert.NotNull(paths);
Assert.NotEmpty(paths);
Assert.Contains(Path.GetFullPath(mainPath), paths);
}
[Theory]
[MemberData(nameof(GetCases))]
public void ParseEffectiveSignals_FromRealWorldCorpus(CorpusCase c)
{
var e = c.Expected;
var hasAny = e.ServerCountMin is not null || e.NoHosts is not null || e.Do0x20State is not null ||
e.Port is not null || e.ExpandHosts is not null || e.LocalCountMin is not null ||
e.AddressCountMin is not null || e.CacheSize is not null || e.AddnHostsCountMin is not null ||
e.DomainCountMin is not null || e.NoResolv is not null || e.BogusPriv is not null ||
e.CnameCountMin is not null || e.TxtRecordCountMin is not null || e.SrvCountMin is not null ||
e.PtrRecordCountMin is not null || e.MxHostCountMin is not null || e.AuthZoneCountMin is not null ||
e.SynthDomainCountMin is not null || e.AuthServerCountMin is not null;
if (!hasAny)
return;
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
if (e.ServerCountMin is int minServers)
{
var servers = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Server);
Assert.True(servers.Count >= minServers, $"Expected at least {minServers} server(s) for {c.File}");
}
if (e.LocalCountMin is int minLocal)
{
var locals = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Local);
Assert.True(locals.Count >= minLocal, $"Expected at least {minLocal} local(s) for {c.File}");
}
if (e.AddressCountMin is int minAddr)
{
var addrs = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Address);
Assert.True(addrs.Count >= minAddr, $"Expected at least {minAddr} address(es) for {c.File}");
}
if (e.CacheSize is int cacheSize)
{
var (val, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.CacheSize);
Assert.NotNull(val);
Assert.True(int.TryParse(val, out var n) && n == cacheSize);
}
if (e.AddnHostsCountMin is int minAddn)
{
var addn = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(paths);
Assert.True(addn.Count >= minAddn);
}
if (e.DomainCountMin is int minDomain)
{
var domains = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Domain);
Assert.True(domains.Count >= minDomain);
}
if (e.NoHosts is bool noHostsExpected)
{
var noHosts = DnsmasqConfIncludeParser.GetNoHostsFromConfigFiles(paths);
Assert.Equal(noHostsExpected, noHosts);
}
if (e.NoResolv is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.NoResolv));
}
if (e.BogusPriv is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.BogusPriv));
}
if (e.Do0x20State == "Disabled")
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.No0x20Encode));
}
if (e.Port is int portExpected)
{
var (portVal, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
Assert.NotNull(portVal);
Assert.True(int.TryParse(portVal, out var port) && port == portExpected);
}
if (e.ExpandHosts is true)
{
Assert.True(DnsmasqConfIncludeParser.GetFlagFromConfigFiles(paths, DnsmasqConfKeys.ExpandHosts));
}
if (e.CnameCountMin is int minCname)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Cname);
Assert.True(list.Count >= minCname);
}
if (e.TxtRecordCountMin is int minTxt)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.TxtRecord);
Assert.True(list.Count >= minTxt);
}
if (e.SrvCountMin is int minSrv)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.Srv);
Assert.True(list.Count >= minSrv);
}
if (e.PtrRecordCountMin is int minPtr)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.PtrRecord);
Assert.True(list.Count >= minPtr);
}
if (e.MxHostCountMin is int minMx)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.MxHost);
Assert.True(list.Count >= minMx);
}
if (e.AuthZoneCountMin is int minAz)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthZone);
Assert.True(list.Count >= minAz);
}
if (e.SynthDomainCountMin is int minSd)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.SynthDomain);
Assert.True(list.Count >= minSd);
}
if (e.AuthServerCountMin is int minAs)
{
var list = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, DnsmasqConfKeys.AuthServer);
Assert.True(list.Count >= minAs);
}
}
}
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>DNS records and local names: hostsdir, domain, cname, mx-host, srv, ptr/txt/naptr/host-record, dynamic-host, interface-name.</summary>
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Server/local, rev-server, dhcp-range, all-servers, log-queries.</summary>
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>Single-value options (last wins): local-ttl, pid-file, user, group, auth-ttl, edns-packet-max, query-port, port-limit, min/max-port, log-async, local-service, dhcp-lease-max, neg/max/min-cache-ttl, dhcp-ttl.</summary>
@@ -1,7 +1,7 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
public class DnsmasqDhcpRangeValueParserTests
{
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for DnsmasqLeasesFileLineParser. Format per dnsmasq author (dnsmasq-discuss 2006): five space-separated
@@ -1,7 +1,9 @@
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Infrastructure.Parsers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
namespace DnsmasqWebUI.Tests;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.DnsmasqConfig;
/// <summary>
/// Tests for HostsFileLineParser. Format: IP_address canonical_hostname [aliases...] (hosts(5), RFC 952).
@@ -137,6 +139,17 @@ public class HostsFileLineParserTests
Assert.Equal(address, e.Address);
Assert.Single(e.Names);
Assert.Equal(name, e.Names[0]);
Assert.Equal(comment, e.InlineComment);
}
[Fact]
public void ToLine_WithInlineComment_AppendsHashAndRoundTrips()
{
var line = "192.168.0.5 router # edge gateway";
var e = HostsFileLineParser.ParseLine(line, 1);
Assert.NotNull(e);
var back = HostsFileLineParser.ToLine(e!);
Assert.Equal(line, back);
}
[Fact]
@@ -0,0 +1,63 @@
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Serialization.Parsers.EffectiveConfig;
public class DomainRuleFieldsTests
{
[Theory]
[InlineData("home.arpa", "home.arpa")]
[InlineData("#", "# (resolv.conf search)")]
[InlineData("example.com,192.168.0.0/24", "example.com · 192.168.0.0/24")]
[InlineData("example.com,192.168.0.0/24,local", "example.com · 192.168.0.0/24 · local DNS")]
[InlineData("example.com,eth0", "example.com · interface eth0")]
[InlineData("example.com,local", "example.com · local DNS")]
public void FormatSummary_FormatsExpected(string line, string expectedContains)
{
var s = DomainRuleFields.FormatSummary(line);
Assert.Equal(expectedContains, s);
}
[Fact]
public void Parse_RoundTrip_Unconditional()
{
var f = DomainRuleFields.Parse("my.domain");
Assert.False(f.IsRaw);
Assert.Equal("my.domain", f.ToConfigLine());
}
[Fact]
public void Parse_RoundTrip_RangeAndLocal()
{
var line = "my.domain,10.0.0.0/24,local";
var f = DomainRuleFields.Parse(line);
Assert.False(f.IsRaw);
Assert.Equal(line, f.ToConfigLine());
}
[Fact]
public void Parse_RoundTrip_Interface()
{
var line = "my.domain,br0";
var f = DomainRuleFields.Parse(line);
Assert.False(f.IsRaw);
Assert.Equal(DomainRuleScope.InterfaceName, f.Scope);
Assert.Equal(line, f.ToConfigLine());
}
[Fact]
public void Parse_TooManyTokens_UsesRaw()
{
var line = "a,b,c,d";
var f = DomainRuleFields.Parse(line);
Assert.True(f.IsRaw);
Assert.Equal(line, f.ToConfigLine());
}
[Fact]
public void FromRawConfigLine_PreservesLine()
{
var f = DomainRuleFields.FromRawConfigLine(" weird,line,here ");
Assert.True(f.IsRaw);
Assert.Equal("weird,line,here", f.ToConfigLine());
}
}
@@ -1,11 +1,12 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
/// <summary>
/// Tests effective-config resolution in ConfigSetCache (BuildEffectiveConfig / BuildEffectiveConfigSources).
@@ -134,4 +135,52 @@ public class ConfigSetCacheEffectiveConfigTests
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task RealWorldCorpus_EdgeCommentedActiveMix_Do0x20StateAndSource()
{
var mainPath = TestDataHelper.GetPath("real-world/edge/dnsmasq-real-edge-commented-active-mix.conf");
Assert.True(File.Exists(mainPath));
var dir = Path.GetDirectoryName(mainPath)!;
var managedName = "zz-managed.conf";
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Disabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
Assert.Single(snapshot.Config.ServerValues);
Assert.Equal("1.1.1.1", snapshot.Config.ServerValues[0]);
}
[Fact]
public async Task RealWorldCorpus_GoodHomeBasic_ServerCountAndSource()
{
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-home-basic.conf");
Assert.True(File.Exists(mainPath));
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.ServerValues.Count >= 2);
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
}
[Fact]
public async Task RealWorldCorpus_GoodResolverRich_LocalAddressCacheSizeFlags()
{
var mainPath = TestDataHelper.GetPath("real-world/good/dnsmasq-real-resolver-rich.conf");
Assert.True(File.Exists(mainPath));
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "zz-managed.conf" });
using var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.ServerValues.Count >= 2);
Assert.True(snapshot.Config.LocalValues.Count >= 2);
Assert.True(snapshot.Config.AddressValues.Count >= 2);
Assert.Equal(1000, snapshot.Config.CacheSize);
Assert.True(snapshot.Config.NoResolv);
Assert.True(snapshot.Config.BogusPriv);
Assert.True(snapshot.Config.ExpandHosts);
}
}
@@ -1,11 +1,11 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
/// <summary>
/// Save-path tests for DnsmasqConfigService.ApplyEffectiveConfigChangesAsync.
@@ -194,6 +194,43 @@ public class DnsmasqConfigServiceApplyChangesTests
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_SingleValueNull_RemovesExistingLine()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-apply-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
var managedPath = Path.Combine(dir, managedName);
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
File.WriteAllText(managedPath, "port=54\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
cache.Invalidate();
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Port, "54", null, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
var content = await File.ReadAllTextAsync(managedPath);
var lines = content.TrimEnd().Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToList();
Assert.DoesNotContain(lines, l => l == DnsmasqConfKeys.Port || l.StartsWith($"{DnsmasqConfKeys.Port}=", StringComparison.Ordinal));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyEffectiveConfigChangesAsync_KeyOnlyOrValue_KeyOnly_WritesBareKey()
{
@@ -303,6 +340,140 @@ public class DnsmasqConfigServiceApplyChangesTests
}
}
[Fact]
public async Task ApplyChanges_Do0x20Enabled_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Do0x20Encode, ExplicitToggleState.Default, ExplicitToggleState.Enabled, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(ExplicitToggleState.Enabled, snapshot.Config.Do0x20EncodeState);
Assert.NotNull(snapshot.Sources.Do0x20Encode);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_UseStaleCache_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionCache, DnsmasqConfKeys.UseStaleCache, null, "60", null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal("60", snapshot.Config.UseStaleCache);
Assert.NotNull(snapshot.Sources.UseStaleCache);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Server_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server, null, new List<string> { "1.1.1.1", "8.8.8.8" }, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.Equal(2, snapshot.Config.ServerValues.Count);
Assert.Contains("1.1.1.1", snapshot.Config.ServerValues);
Assert.Contains("8.8.8.8", snapshot.Config.ServerValues);
Assert.Equal(2, snapshot.Sources.ServerValues.Count);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Conntrack_RoundTripsToEffectiveState()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-roundtrip-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var changes = new List<PendingEffectiveConfigChange>
{
new(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Conntrack, false, true, null)
};
await configService.ApplyEffectiveConfigChangesAsync(changes);
cache.Invalidate();
var snapshot = await cache.GetSnapshotAsync();
Assert.True(snapshot.Config.Conntrack);
Assert.NotNull(snapshot.Sources.Conntrack);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task ApplyChanges_Do0x20_Disabled_WritesNo0x20Encode()
{
@@ -5,7 +5,7 @@ using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.Dnsmasq.Config;
/// <summary>
/// Tests for DnsmasqConfigSetService, including GetEffectiveConfigWithSources.
@@ -0,0 +1,462 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
public class CrossOptionRulesTests
{
private static EffectiveConfigCrossOptionContext Ctx(EffectiveDnsmasqConfig cfg) =>
new(CrossOptionTestHelpers.Status(cfg), []);
[Fact]
public void NoResolvWithoutUpstreamsRule_Warns_when_no_server_and_no_resolv_file()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = true };
var rule = new NoResolvWithoutUpstreamsRule();
var issues = rule.Evaluate(Ctx(cfg));
var key = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server);
Assert.Single(issues);
Assert.Equal(key, issues[0].FieldKey);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void NoResolvWithoutUpstreamsRule_No_issue_when_resolv_file_configured()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
NoResolv = true,
ResolvFiles = ["/run/resolv.conf"]
};
var rule = new NoResolvWithoutUpstreamsRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void ConntrackWithQueryPortRule_Error_when_both_set()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Conntrack = true, QueryPort = 5353 };
var rule = new ConntrackWithQueryPortRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity);
Assert.Contains("query-port", issues[0].Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void RebindExceptionsRequireStopDnsRebindRule_Warns_on_localhost_ok_without_stop()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { RebindLocalhostOk = true };
var rule = new RebindExceptionsRequireStopDnsRebindRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_private_in_addr_server()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = ["/0.168.192.in-addr.arpa/10.0.0.1"]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server),
issues[0].FieldKey);
}
[Theory]
[InlineData("/10.in-addr.arpa/8.8.8.8")]
[InlineData("/31.172.in-addr.arpa/8.8.8.8")]
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_rfc1918_zone_roots(string serverValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = [serverValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
Assert.Single(rule.Evaluate(Ctx(cfg)));
}
[Theory]
[InlineData("/210.in-addr.arpa/8.8.8.8")]
[InlineData("/110.in-addr.arpa/8.8.8.8")]
[InlineData("/1.0.0.192.in-addr.arpa/8.8.8.8")]
[InlineData("/15.172.in-addr.arpa/8.8.8.8")]
[InlineData("/32.172.in-addr.arpa/8.8.8.8")]
[InlineData("/10.0.0.192.in-addr.arpa/8.8.8.8")]
public void BogusPrivBlocksPrivateReverseServerRule_No_issue_for_non_private_in_addr_substrings(string serverValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = [serverValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Theory]
[InlineData("192.168.1.0/24,8.8.8.8")]
[InlineData("10.0.0.0/8,8.8.8.8")]
[InlineData("172.16.0.0/12,8.8.8.8")]
public void BogusPrivBlocksPrivateReverseServerRule_Warns_for_rfc1918_rev_server(string revServerValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
RevServerValues = [revServerValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer),
issues[0].FieldKey);
}
[Theory]
[InlineData("8.8.8.0/24,1.1.1.1")]
[InlineData("1.0.0.0/8,1.1.1.1")]
public void BogusPrivBlocksPrivateReverseServerRule_No_issue_for_public_rev_server(string revServerValue)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
RevServerValues = [revServerValue]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void BogusPrivBlocksPrivateReverseServerRule_Emits_separate_issues_for_server_and_rev_server()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
ServerValues = ["/0.168.192.in-addr.arpa/10.0.0.1"],
RevServerValues = ["192.168.2.0/24,8.8.8.8"]
};
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Equal(2, issues.Count);
Assert.Contains(
issues,
i => i.FieldKey == EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Server));
Assert.Contains(
issues,
i => i.FieldKey == EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer));
}
[Fact]
public void BogusPrivBlocksPrivateReverseServerRule_Pending_rev_server_overrides_disk()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
BogusPriv = true,
RevServerValues = ["8.8.8.0/24,1.1.1.1"]
};
var pending = new[]
{
new PendingOptionChange(
"resolver",
DnsmasqConfKeys.RevServer,
Array.Empty<string>(),
new[] { "192.168.1.0/24,8.8.8.8" },
null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
var rule = new BogusPrivBlocksPrivateReverseServerRule();
var issues = rule.Evaluate(ctx);
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.RevServer),
issues[0].FieldKey);
}
[Fact]
public void NoPingWithDhcpSequentialIpRule_Warns_when_both_set()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
NoPing = true,
DhcpSequentialIp = true
};
var rule = new NoPingWithDhcpSequentialIpRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DhcpSequentialIp),
issues[0].FieldKey);
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
public void NoPingWithDhcpSequentialIpRule_No_issue_unless_both(bool noPing, bool sequential)
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
NoPing = noPing,
DhcpSequentialIp = sequential
};
var rule = new NoPingWithDhcpSequentialIpRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_Warns_when_port_zero_and_enable_tftp()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Port = 0,
EnableTftp = true
};
var rule = new DnsPortZeroWithTftpOrPxeRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Port),
issues[0].FieldKey);
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_Warns_when_port_zero_and_pxe_service()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Port = 0,
PxeServiceValues = ["x86PC,Boot from network,pxelinux"]
};
var rule = new DnsPortZeroWithTftpOrPxeRule();
Assert.Single(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_No_issue_when_port_nonzero_even_with_netboot()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Port = 53,
EnableTftp = true,
PxeServiceValues = ["x86PC,Boot from network,pxelinux"]
};
var rule = new DnsPortZeroWithTftpOrPxeRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnsPortZeroWithTftpOrPxeRule_No_issue_when_port_zero_without_tftp_or_pxe()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Port = 0 };
var rule = new DnsPortZeroWithTftpOrPxeRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnssecPrerequisitesRule_Error_when_build_lacks_dnssec()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true };
var status = CrossOptionTestHelpers.Status(cfg, dnsmasqSupportsDnssec: false);
var ctx = new EffectiveConfigCrossOptionContext(status, []);
var rule = new DnssecPrerequisitesRule();
var issues = rule.Evaluate(ctx);
var dnssecKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.Dnssec);
Assert.Contains(issues, i =>
i.Severity == FieldIssueSeverity.Error && i.FieldKey == dnssecKey);
}
[Fact]
public void DnssecPrerequisitesRule_Warns_when_no_trust_anchors()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Dnssec = true };
var rule = new DnssecPrerequisitesRule();
var issues = rule.Evaluate(Ctx(cfg));
var trustAnchorKey = EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.TrustAnchor);
Assert.Contains(issues, i =>
i.Severity == FieldIssueSeverity.Warning &&
i.FieldKey == trustAnchorKey &&
i.Message.Contains("trust-anchor", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void ProxyDnssecCacheWarningRule_Warns_when_cache_not_zero()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
ProxyDnssec = true,
CacheSize = 150
};
var rule = new ProxyDnssecCacheWarningRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.CacheSize),
issues[0].FieldKey);
}
[Fact]
public void ConnmarkAllowlistEnableRequiresAllowlistRule_Error_when_enable_key_only()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ConnmarkAllowlistEnable = "" };
var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Error, issues[0].Severity);
}
[Fact]
public void ConnmarkAllowlistEnableRequiresAllowlistRule_No_issue_when_allowlists_present()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
ConnmarkAllowlistEnable = "0xff",
ConnmarkAllowlistValues = ["0x1,*.example.com"]
};
var rule = new ConnmarkAllowlistEnableRequiresAllowlistRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void QueryPortIgnoredForSourceBoundServerRule_Warns_when_server_has_at()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
QueryPort = 12345,
ServerValues = ["10.0.0.1@192.168.1.1"]
};
var rule = new QueryPortIgnoredForSourceBoundServerRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void AddSubnetCacheBehaviorRule_Warns_when_add_subnet_set()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { AddSubnet = "24,96" };
var rule = new AddSubnetCacheBehaviorRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.AddSubnet),
issues[0].FieldKey);
}
[Fact]
public void Filterwin2kSrvWarningRule_Warns_when_enabled()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { Filterwin2k = true };
var rule = new Filterwin2kSrvWarningRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Contains("SRV", issues[0].Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_Warns_for_domain_address()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/example.com/192.0.2.1"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_No_issue_when_matching_local_exists()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/example.com/192.0.2.1"],
LocalValues = ["/example.com/"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_No_issue_for_nxdomain_form_without_ip()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/ads.example/"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
Assert.Empty(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void AddressLocalDnsmasq286CompatibilityRule_Warns_for_hash_target_without_local()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
AddressValues = ["/blocked.example/#"]
};
var rule = new AddressLocalDnsmasq286CompatibilityRule();
Assert.Single(rule.Evaluate(Ctx(cfg)));
}
[Fact]
public void DnssecCheckUnsignedRequiresDnssecRule_Warns_when_dnssec_check_unsigned_set_without_dnssec()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Dnssec = false,
DnssecCheckUnsigned = ""
};
var rule = new DnssecCheckUnsignedRequiresDnssecRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.DnssecCheckUnsigned),
issues[0].FieldKey);
}
[Fact]
public void LocalServiceIgnoredByBindSettingsRule_Warns_when_local_service_with_interface_filters()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
LocalService = "net",
Interfaces = ["eth0"]
};
var rule = new LocalServiceIgnoredByBindSettingsRule();
var issues = rule.Evaluate(Ctx(cfg));
Assert.Single(issues);
Assert.Equal(FieldIssueSeverity.Warning, issues[0].Severity);
Assert.Equal(
EffectiveConfigCrossOptionContext.FieldKeyForOption(DnsmasqConfKeys.LocalService),
issues[0].FieldKey);
}
[Fact]
public void Pending_overlay_overrides_config_for_cross_option_rules()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { NoResolv = false };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.NoResolv, false, true, null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
var rule = new NoResolvWithoutUpstreamsRule();
var issues = rule.Evaluate(ctx);
Assert.Single(issues);
}
}
@@ -0,0 +1,226 @@
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
internal static class CrossOptionTestHelpers
{
/// <summary>Minimal baseline effective config; override with record <c>with</c> in tests.</summary>
public static EffectiveDnsmasqConfig BaselineConfig() =>
new(
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
HostsdirPath: null,
ServerValues: Array.Empty<string>(),
LocalValues: Array.Empty<string>(),
RevServerValues: Array.Empty<string>(),
AddressValues: Array.Empty<string>(),
Interfaces: Array.Empty<string>(),
ListenAddresses: Array.Empty<string>(),
ExceptInterfaces: Array.Empty<string>(),
DhcpRanges: Array.Empty<string>(),
DhcpHostLines: Array.Empty<string>(),
DhcpOptionLines: Array.Empty<string>(),
DhcpMatchValues: Array.Empty<string>(),
DhcpBootValues: Array.Empty<string>(),
DhcpIgnoreValues: Array.Empty<string>(),
DhcpVendorclassValues: Array.Empty<string>(),
DhcpUserclassValues: Array.Empty<string>(),
RaParamValues: Array.Empty<string>(),
SlaacValues: Array.Empty<string>(),
PxeServiceValues: Array.Empty<string>(),
TrustAnchorValues: Array.Empty<string>(),
ResolvFiles: Array.Empty<string>(),
RebindDomainOkValues: Array.Empty<string>(),
BogusNxdomainValues: Array.Empty<string>(),
IgnoreAddressValues: Array.Empty<string>(),
AliasValues: Array.Empty<string>(),
FilterRrValues: Array.Empty<string>(),
CacheRrValues: Array.Empty<string>(),
AuthServerValues: Array.Empty<string>(),
NoDhcpInterfaceValues: Array.Empty<string>(),
NoDhcpv4InterfaceValues: Array.Empty<string>(),
NoDhcpv6InterfaceValues: Array.Empty<string>(),
DomainValues: Array.Empty<string>(),
CnameValues: Array.Empty<string>(),
MxHostValues: Array.Empty<string>(),
SrvValues: Array.Empty<string>(),
PtrRecordValues: Array.Empty<string>(),
TxtRecordValues: Array.Empty<string>(),
NaptrRecordValues: Array.Empty<string>(),
HostRecordValues: Array.Empty<string>(),
DynamicHostValues: Array.Empty<string>(),
InterfaceNameValues: Array.Empty<string>(),
DhcpOptionForceLines: Array.Empty<string>(),
IpsetValues: Array.Empty<string>(),
NftsetValues: Array.Empty<string>(),
DhcpMacValues: Array.Empty<string>(),
DhcpNameMatchValues: Array.Empty<string>(),
DhcpIgnoreNamesValues: Array.Empty<string>(),
DhcpHostsfilePaths: Array.Empty<string>(),
DhcpOptsfilePaths: Array.Empty<string>(),
DhcpHostsdirPaths: Array.Empty<string>(),
DhcpOptsdirPaths: Array.Empty<string>(),
ConnmarkAllowlistValues: Array.Empty<string>(),
CaaRecordValues: Array.Empty<string>(),
DnsRrValues: Array.Empty<string>(),
SynthDomainValues: Array.Empty<string>(),
AuthZoneValues: Array.Empty<string>(),
AuthSoaValues: Array.Empty<string>(),
AuthSecServersValues: Array.Empty<string>(),
AuthPeerValues: Array.Empty<string>(),
DhcpRelayValues: Array.Empty<string>(),
DhcpCircuitidValues: Array.Empty<string>(),
DhcpRemoteidValues: Array.Empty<string>(),
DhcpSubscridValues: Array.Empty<string>(),
DhcpProxyValues: Array.Empty<string>(),
TagIfValues: Array.Empty<string>(),
BridgeInterfaceValues: Array.Empty<string>(),
SharedNetworkValues: Array.Empty<string>(),
DhcpOptionPxeValues: Array.Empty<string>(),
ExpandHosts: false,
BogusPriv: false,
StrictOrder: false,
AllServers: false,
NoResolv: false,
DomainNeeded: false,
NoPoll: false,
BindInterfaces: false,
BindDynamic: false,
NoNegcache: false,
DnsLoopDetect: false,
StopDnsRebind: false,
RebindLocalhostOk: false,
ClearOnReload: false,
Filterwin2k: false,
FilterA: false,
FilterAaaa: false,
LocaliseQueries: false,
LogDebug: false,
DhcpAuthoritative: false,
LeasefileRo: false,
EnableTftp: false,
TftpSecure: false,
TftpNoFail: false,
TftpNoBlocksize: false,
Dnssec: false,
DnssecCheckUnsigned: null,
ReadEthers: false,
DhcpRapidCommit: false,
Localmx: false,
Selfmx: false,
EnableRa: false,
LogDhcp: false,
KeepInForeground: false,
NoDaemon: false,
ProxyDnssec: false,
ConnmarkAllowlistEnable: null,
NoRoundRobin: false,
DnssecNoTimecheck: false,
DnssecDebug: false,
LeasequeryValues: Array.Empty<string>(),
DhcpGenerateNames: null,
DhcpBroadcast: null,
DhcpSequentialIp: false,
DhcpIgnoreClid: false,
BootpDynamic: null,
NoPing: false,
ScriptArp: false,
ScriptOnRenewal: false,
DhcpNoOverride: false,
QuietDhcp: false,
QuietDhcp6: false,
QuietRa: false,
QuietTftp: false,
DhcpLeaseFilePath: null,
CacheSize: null,
Port: null,
LocalTtl: null,
PidFilePath: null,
User: null,
Group: null,
LogFacility: null,
LogQueries: null,
AuthTtl: null,
EdnsPacketMax: null,
QueryPort: null,
PortLimit: null,
MinPort: null,
MaxPort: null,
LogAsync: null,
LocalService: null,
DhcpLeaseMax: null,
NegTtl: null,
MaxTtl: null,
MaxCacheTtl: null,
MinCacheTtl: null,
DhcpTtl: null,
TftpRootPath: null,
PxePrompt: null,
EnableDbus: null,
EnableUbus: null,
FastDnsRetry: null,
DhcpScriptPath: null,
MxTarget: null,
DnsForwardMax: null,
DumpfilePath: null,
Dumpmask: null,
AddCpeId: null,
DnssecTimestamp: null,
DnssecLimits: null,
DhcpAlternatePort: null,
DhcpDuid: null,
DhcpLuascriptPath: null,
DhcpScriptuser: null,
DhcpPxeVendor: null,
UseStaleCache: null,
AddMac: null,
StripMac: false,
AddSubnet: null,
StripSubnet: false,
Umbrella: null,
Do0x20EncodeState: ExplicitToggleState.Default,
Conntrack: false);
public static DnsmasqServiceStatus Status(EffectiveDnsmasqConfig config, bool dnsmasqSupportsDnssec = true) =>
new(
SystemHostsPath: null,
SystemHostsPathExists: false,
ManagedHostsFilePath: null,
ManagedHostsPathExists: false,
NoHosts: false,
AddnHostsPaths: Array.Empty<string>(),
EffectiveConfig: config,
EffectiveConfigSources: null,
MainConfigPath: null,
ManagedFilePath: null,
LeasesPath: null,
MainConfigPathExists: false,
ManagedFilePathExists: false,
LeasesPathConfigured: false,
LeasesPathExists: false,
ConfigFiles: null,
ReloadCommandConfigured: false,
StatusCommandConfigured: false,
StatusShowConfigured: false,
LogsConfigured: false,
LogsPath: null,
StatusShowCommand: null,
LogsCommand: null,
DnsmasqStatus: "active",
StatusCommandExitCode: null,
StatusCommandStdout: null,
StatusCommandStderr: null,
StatusShowOutput: null,
LogsOutput: null,
DhcpRangeStart: null,
DhcpRangeEnd: null,
DnsmasqVersion: "2.91",
MinimumSupportedDnsmasqVersion: "2.91",
DnsmasqVersionSupported: true,
DnsmasqVersionError: null,
DnsmasqSupportsDhcp: true,
DnsmasqSupportsTftp: true,
DnsmasqSupportsDnssec: dnsmasqSupportsDnssec,
DnsmasqSupportsDbus: false);
}
@@ -0,0 +1,46 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
public class EffectiveConfigCrossOptionContextTests
{
[Fact]
public void GetMulti_WhenPendingNewValueIsNull_ReturnsEmpty_NotDiskValues()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1", "8.8.8.8"] };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, new List<string> { "1.1.1.1" }, null, null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues));
}
[Fact]
public void GetMulti_WhenPendingNewValueIsList_ReturnsPending_NotDisk()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["9.9.9.9"] };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, new List<string> { "1.1.1.1" }, null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
var v = ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues);
Assert.Single(v);
Assert.Equal("1.1.1.1", v[0]);
}
[Fact]
public void GetMulti_WhenPendingNewValueIsWrongType_ReturnsEmpty_NotDiskValues()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with { ServerValues = ["1.1.1.1"] };
var pending = new[]
{
new PendingOptionChange("resolver", DnsmasqConfKeys.Server, null, "not-a-list", null)
};
var ctx = new EffectiveConfigCrossOptionContext(CrossOptionTestHelpers.Status(cfg), pending);
Assert.Empty(ctx.GetMulti(DnsmasqConfKeys.Server, c => c.ServerValues));
}
}
@@ -0,0 +1,30 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Rules;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig.CrossOption;
public class EffectiveConfigCrossOptionValidationServiceTests
{
[Fact]
public void Validate_aggregates_issues_from_all_rules()
{
var cfg = CrossOptionTestHelpers.BaselineConfig() with
{
Conntrack = true,
QueryPort = 1,
Filterwin2k = true
};
var status = CrossOptionTestHelpers.Status(cfg);
IReadOnlyList<IEffectiveConfigCrossOptionRule> rules =
[
new ConntrackWithQueryPortRule(),
new Filterwin2kSrvWarningRule()
];
var service = new EffectiveConfigCrossOptionValidationService(rules);
var issues = service.Validate(status, []);
Assert.Equal(2, issues.Count);
}
}
@@ -0,0 +1,82 @@
using System.Linq;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
using Microsoft.Extensions.Logging.Abstractions;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
public class EffectiveConfigEditSessionChangedEventTests
{
[Fact]
public void TrackCommit_RaisesChanged()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.Ui.EnterEditMode();
var raised = false;
session.Changed += () => raised = true;
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
Assert.True(raised);
}
[Fact]
public void EnterEditMode_RaisesChanged()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
var raised = false;
session.Changed += () => raised = true;
session.Ui.EnterEditMode();
Assert.True(raised);
}
[Fact]
public void RevertChange_RaisesChanged()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.Ui.EnterEditMode();
session.Draft.TrackCommit(new EffectiveConfigEditCommittedArgs("resolver", "port", null, 5353, null));
var raised = false;
session.Changed += () => raised = true;
session.Draft.RevertChange("resolver", "port");
Assert.True(raised);
}
[Fact]
public void Ui_EnterEditMode_DoesNotClearDraft()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
var oldEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a" } } };
var newEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "b" } } };
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/tmp/managed.hosts");
Assert.Single(session.Draft.PendingChanges);
session.Ui.EnterEditMode();
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
}
[Fact]
public void SetManagedHostsDraft_StoresChange_AndRevertManagedHostsDraft_RemovesIt()
{
var saveService = new StubSaveService();
var session = new EffectiveConfigEditSession(saveService);
session.Ui.EnterEditMode();
var oldEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a" } } };
var newEntries = new List<HostEntry> { new() { Address = "1.2.3.4", Names = new List<string> { "a", "b" } } };
session.Draft.SetManagedHostsDraft(oldEntries, newEntries, "/etc/hosts");
Assert.Single(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
session.Draft.RevertManagedHostsDraft();
Assert.Empty(session.Draft.PendingChanges.OfType<PendingManagedHostsChange>());
}
private sealed class StubSaveService : IEffectiveConfigSaveService
{
public Task<EffectiveConfigSaveResult> SaveAsync(IReadOnlyList<PendingDnsmasqChange> changes, CancellationToken ct = default) =>
Task.FromResult(EffectiveConfigSaveResult.NoChanges());
public Task<EffectiveConfigRestoreResult> RestoreAsync(IReadOnlyList<DnsmasqManagedBackup> backups, CancellationToken ct = default) =>
Task.FromResult(new EffectiveConfigRestoreResult(false, false, -1, null, "Stub"));
}
}
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests for option -> feature mapping and capability checks.
@@ -72,7 +72,6 @@ public class EffectiveConfigFeatureRequirementsTests
var (isDisabled, reason) = EffectiveConfigFeatureRequirements.GetCapabilityDisabled(DnsmasqConfKeys.DnssecCheckUnsigned, status);
Assert.True(isDisabled);
Assert.NotNull(reason);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -92,8 +91,7 @@ public class EffectiveConfigFeatureRequirementsTests
var noDnssecCaps = new DnsmasqCompileCapabilities(true, true, false, false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
Assert.False(EffectiveConfigFeatureRequirements.IsSupportedByCapabilities(DnsmasqConfKeys.DnssecCheckUnsigned, noDnssecCaps));
var reason = EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec);
Assert.Contains("DNSSEC", reason, StringComparison.OrdinalIgnoreCase);
Assert.NotNull(EffectiveConfigFeatureRequirements.GetUnsupportedReason(DnsmasqFeature.Dnssec));
}
private static DnsmasqServiceStatus CreateStatusWithCapabilities(bool dhcp = true, bool tftp = true, bool dnssec = true, bool dbus = false)
@@ -1,8 +1,8 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests that field descriptors get IsCapabilityDisabled set when the status indicates the option is not supported by the dnsmasq build.
@@ -23,7 +23,6 @@ public class EffectiveConfigFieldBuilderCapabilityTests
Assert.True(descriptor.IsCapabilityDisabled);
Assert.NotNull(descriptor.CapabilityDisabledReason);
Assert.Contains("DNSSEC", descriptor.CapabilityDisabledReason, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -0,0 +1,45 @@
using DnsmasqWebUI.Components.EffectiveConfig;
using DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.AspNetCore.Components;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
public class EffectiveConfigRenderFragmentRegistryMultiTests
{
[Fact]
public void BuildMultiFieldComponentFragment_Domain_ReturnsFragment()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DomainSemanticHandler()]));
var fragment = registry.BuildMultiFieldComponentFragment(
EffectiveConfigSections.SectionHosts,
DnsmasqConfKeys.Domain,
EventCallback<IReadOnlyList<string>>.Empty);
Assert.NotNull(fragment);
}
[Fact]
public void DomainMultiValueDisplay_InheritsMultiBase()
{
Assert.Equal(typeof(EffectiveConfigMultiValueDisplayBase), typeof(DomainMultiValueDisplay).BaseType);
}
/// <summary>Custom domain UI must still use RegisterSemanticMultiDescriptor wiring so per-item validation is not dropped.</summary>
[Fact]
public void GetMultiDescriptorFactory_Domain_WiresSemanticValidator()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DomainSemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain);
Assert.NotNull(factory);
var descriptor = factory!(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain, status: null, getItems: _ => null);
Assert.NotNull(descriptor.Validator);
Assert.Null(descriptor.Validator!.ValidateItem("example.com", Array.Empty<string>(), null));
Assert.NotNull(descriptor.Validator.ValidateItem("not a valid domain name!", Array.Empty<string>(), null));
}
}
@@ -0,0 +1,119 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Dnsmasq;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Save path capability guard: unsupported-option changes return UnsupportedCapabilities
/// before validation or reload can run.
/// </summary>
public class EffectiveConfigSaveServiceCapabilityTests
{
[Fact]
public async Task SaveAsync_WhenCapabilitiesMissingDnssec_ReturnsUnsupportedCapabilitiesErrorCode()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-cap-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
var managedName = "zz-managed.conf";
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = managedName });
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var versionService = new StubVersionService(CreateSupportedVersionInfo(dnssec: false));
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
var saveService = new EffectiveConfigSaveService(
setService,
configService,
cache,
new StubHostsFileService(),
new UnexpectedValidationService(),
semanticValidationService,
new UnexpectedReloadService(),
versionService,
NullLogger<EffectiveConfigSaveService>.Instance);
var changes = new List<PendingDnsmasqChange>
{
new PendingOptionChange(EffectiveConfigSections.SectionDnssec, DnsmasqConfKeys.DnssecCheckUnsigned, null, "no", null)
};
var result = await saveService.SaveAsync(changes);
Assert.Equal(EffectiveConfigSaveResult.ErrorCodes.UnsupportedCapabilities, result.ErrorCode);
Assert.False(File.Exists(Path.Combine(dir, managedName)));
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
private sealed class StubVersionService : IDnsmasqVersionService
{
private readonly DnsmasqVersionInfo _info;
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) =>
Task.FromResult(_info);
}
private static DnsmasqVersionInfo CreateSupportedVersionInfo(bool dnssec)
{
var capabilities = new DnsmasqCompileCapabilities(
Dhcp: true,
Tftp: true,
Dnssec: dnssec,
Dbus: false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP" });
return new DnsmasqVersionInfo(
new Version(2, 91),
new Version(2, 91),
ProbeSucceeded: true,
IsSupported: true,
"dnsmasq --version",
null,
capabilities);
}
private sealed class UnexpectedValidationService : IConfigValidationService
{
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("ValidateAsync should not be called when capabilities are unsupported.");
}
private sealed class UnexpectedReloadService : IReloadService
{
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("ReloadAsync should not be called when capabilities are unsupported.");
}
private sealed class StubHostsFileService : IHostsFileService
{
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
}
}
@@ -0,0 +1,216 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Version.Abstractions;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Models.Hosts;
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Config.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.Contracts;
using DnsmasqWebUI.Models.Dhcp;
using DnsmasqWebUI.Models.Dnsmasq;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests for multi-file backup/restore and managed-hosts-only save.
/// </summary>
public class EffectiveConfigSaveServiceMultiBackupTests
{
[Fact]
public async Task SaveAsync_WhenOnlyManagedHostsChange_BacksUpManagedHostsFile_WritesHosts_ValidatesAndReloads()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-hosts-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
ConfigSetCache? cache = null;
try
{
File.WriteAllText(mainPath, "port=53\n");
var options = Options.Create(new DnsmasqOptions
{
MainConfigPath = mainPath,
ManagedFileName = "zz-managed.conf",
ManagedHostsFileName = "zz-dnsmasq-webui.hosts"
});
cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
var configService = new DnsmasqConfigService(setService, cache, NullLogger<DnsmasqConfigService>.Instance);
var set = await setService.GetConfigSetAsync();
var managedHostsPath = set.ManagedHostsFilePath!;
File.WriteAllText(managedHostsPath, "192.168.1.1 host1\n");
var writtenEntries = new List<IReadOnlyList<HostEntry>>();
var capturingHostsService = new CapturingHostsFileService(writtenEntries);
var versionService = new StubVersionService(CreateSupportedVersionInfo());
var semanticValidationService = new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>()));
var saveService = new EffectiveConfigSaveService(
setService,
configService,
cache,
capturingHostsService,
new StubValidationService(success: true),
semanticValidationService,
new StubReloadService(success: true),
versionService,
NullLogger<EffectiveConfigSaveService>.Instance);
var oldEntries = new List<HostEntry> { new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" } };
var newEntries = new List<HostEntry>
{
new() { Address = "192.168.1.1", Names = new List<string> { "host1" }, Id = "192.168.1.1|host1" },
new() { Address = "192.168.1.2", Names = new List<string> { "host2" }, Id = "192.168.1.2|host2" }
};
var changes = new List<PendingDnsmasqChange>
{
new PendingManagedHostsChange(oldEntries, newEntries, managedHostsPath)
};
var result = await saveService.SaveAsync(changes);
Assert.True(result.Saved);
Assert.True(result.Validated);
Assert.True(result.Restarted);
Assert.Null(result.ErrorCode);
Assert.Single(result.Backups);
Assert.Equal(DnsmasqManagedTargetKind.ManagedHosts, result.Backups[0].Kind);
Assert.Equal(managedHostsPath, result.Backups[0].TargetPath);
Assert.True(File.Exists(result.Backups[0].BackupPath));
Assert.Single(writtenEntries);
Assert.Equal(2, writtenEntries[0].Count);
}
finally
{
cache?.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
[Fact]
public async Task RestoreAsync_WhenBackupsContainTwoTargets_RestoresBothThenReloads()
{
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-restore-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
var mainPath = Path.Combine(dir, "dnsmasq.conf");
File.WriteAllText(mainPath, "port=53\n");
var target1 = Path.Combine(dir, "target1.conf");
var target2 = Path.Combine(dir, "target2.hosts");
var backup1 = Path.Combine(dir, "backup1.bak");
var backup2 = Path.Combine(dir, "backup2.bak");
File.WriteAllText(target1, "original1");
File.WriteAllText(target2, "original2");
File.WriteAllText(backup1, "restored1");
File.WriteAllText(backup2, "restored2");
var backups = new List<DnsmasqManagedBackup>
{
new(DnsmasqManagedTargetKind.ManagedConfig, target1, backup1),
new(DnsmasqManagedTargetKind.ManagedHosts, target2, backup2)
};
var options = Options.Create(new DnsmasqOptions { MainConfigPath = mainPath, ManagedFileName = "x.conf" });
var cache = new ConfigSetCache(options, NullLogger<ConfigSetCache>.Instance);
var setService = new DnsmasqConfigSetService(cache);
try
{
var saveService = new EffectiveConfigSaveService(
setService,
new StubConfigService(),
cache,
new StubHostsFileService(),
new StubValidationService(success: true),
new EffectiveConfigSemanticValidationService(new OptionSemanticValidator(Array.Empty<IOptionSemanticHandler>())),
new StubReloadService(success: true),
new StubVersionService(CreateSupportedVersionInfo()),
NullLogger<EffectiveConfigSaveService>.Instance);
var result = await saveService.RestoreAsync(backups);
Assert.True(result.Restored);
Assert.True(result.Restarted);
Assert.Equal("restored1", File.ReadAllText(target1));
Assert.Equal("restored2", File.ReadAllText(target2));
}
finally
{
cache.Dispose();
if (Directory.Exists(dir))
Directory.Delete(dir, recursive: true);
}
}
private static DnsmasqVersionInfo CreateSupportedVersionInfo()
{
var capabilities = new DnsmasqCompileCapabilities(
Dhcp: true, Tftp: true, Dnssec: true, Dbus: false,
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "DHCP", "TFTP", "DNSSEC" });
return new DnsmasqVersionInfo(
new Version(2, 91), new Version(2, 91),
ProbeSucceeded: true, IsSupported: true, "dnsmasq --version", null, capabilities);
}
private sealed class CapturingHostsFileService : IHostsFileService
{
private readonly List<IReadOnlyList<HostEntry>> _written;
public CapturingHostsFileService(List<IReadOnlyList<HostEntry>> written) => _written = written;
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default)
{
_written.Add(entries);
return Task.CompletedTask;
}
}
private sealed class StubVersionService : IDnsmasqVersionService
{
private readonly DnsmasqVersionInfo _info;
public StubVersionService(DnsmasqVersionInfo info) => _info = info;
public Task<DnsmasqVersionInfo> GetVersionInfoAsync(CancellationToken ct = default) => Task.FromResult(_info);
}
private sealed class StubValidationService : IConfigValidationService
{
private readonly bool _success;
public StubValidationService(bool success) => _success = success;
public Task<ConfigValidationResult> ValidateAsync(CancellationToken ct = default) =>
Task.FromResult(new ConfigValidationResult(_success, true, _success ? 0 : 1, null, null, null));
}
private sealed class StubReloadService : IReloadService
{
private readonly bool _success;
public StubReloadService(bool success) => _success = success;
public Task<ReloadResult> ReloadAsync(CancellationToken ct = default) =>
Task.FromResult(new ReloadResult(_success, _success ? 0 : 1, null, null));
}
private sealed class StubHostsFileService : IHostsFileService
{
public Task<IReadOnlyList<HostEntry>> ReadAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<HostEntry>>(new List<HostEntry>());
public Task WriteAsync(IReadOnlyList<HostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
}
private sealed class StubConfigService : IDnsmasqConfigService
{
public Task ApplyEffectiveConfigChangesAsync(IReadOnlyList<PendingEffectiveConfigChange> changes, CancellationToken ct = default) => Task.CompletedTask;
public Task<IReadOnlyList<DhcpHostEntry>> ReadDhcpHostsAsync(CancellationToken ct = default) => Task.FromResult<IReadOnlyList<DhcpHostEntry>>(Array.Empty<DhcpHostEntry>());
public Task WriteDhcpHostsAsync(IReadOnlyList<DhcpHostEntry> entries, CancellationToken ct = default) => Task.CompletedTask;
public Task<ManagedConfigContent> ReadManagedConfigAsync(CancellationToken ct = default) => Task.FromResult(new ManagedConfigContent(Array.Empty<DnsmasqConfLine>(), ""));
public Task WriteManagedConfigAsync(IReadOnlyList<DnsmasqConfLine> lines, CancellationToken ct = default) => Task.CompletedTask;
}
}
@@ -1,9 +1,11 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Wiring tests that ensure special-option semantics actually feed parser and registry behavior.
@@ -11,24 +13,26 @@ namespace DnsmasqWebUI.Tests;
public class EffectiveConfigSemanticsWiringTests
{
[Fact]
public void ParserBehaviorMap_UsesSpecialSemantics_ForAllSpecialOptions()
public void Registry_WiresSemanticValidator_ForDnsRr()
{
foreach (var option in EffectiveConfigSpecialOptionSemantics.GetAllOptionNames())
{
var semantics = EffectiveConfigSpecialOptionSemantics.TryGetSemantics(option)!;
Assert.Equal(semantics.ParserBehavior, EffectiveConfigParserBehaviorMap.GetBehavior(option));
}
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new DnsRrSemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr);
Assert.NotNull(factory);
var descriptor = factory!(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.DnsRr, status: null, getItems: _ => null);
Assert.NotNull(descriptor.Validator);
Assert.Null(descriptor.Validator!.ValidateItem("example.com,16,01:02", Array.Empty<string>()));
Assert.NotNull(descriptor.Validator.ValidateItem("example.com,not-a-number", Array.Empty<string>()));
}
[Fact]
public void Registry_WiresLeasequeryMultiValidator_FromSpecialSemantics()
{
var registry = new EffectiveConfigRenderFragmentRegistry(new OptionSemanticValidator([new LeasequerySemanticHandler()]));
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.Leasequery);
var factory = registry.GetMultiDescriptorFactory(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.Leasequery);
Assert.NotNull(factory);
var descriptor = factory!(
EffectiveConfigFieldBuilder.SectionDhcp,
EffectiveConfigSections.SectionDhcp,
DnsmasqConfKeys.Leasequery,
status: null,
getItems: _ => null);
@@ -1,6 +1,6 @@
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests for EffectiveDnsmasqConfig, including GetLogsPath (log-facility as file path).
@@ -0,0 +1,50 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
using DnsmasqWebUI.Tests.Helpers;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>Corpus-driven semantic validation: bad samples produce errors for expected option keys only.</summary>
public class OptionSemanticValidatorRealWorldCorpusTests
{
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new DomainSemanticHandler(),
new DnsRrSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthServerSemanticHandler(),
new SrvSemanticHandler(),
]);
public static IEnumerable<object[]> GetCasesWithInvalidOptions()
{
foreach (var c in RealWorldCasesHelper.LoadCases())
{
if (c.SemanticInvalidOptions.Count > 0)
yield return new object[] { c };
}
}
[Theory]
[MemberData(nameof(GetCasesWithInvalidOptions))]
public void SemanticInvalidOptions_FromCorpus_AreRejected(CorpusCase c)
{
var mainPath = RealWorldCasesHelper.Resolve(c);
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(mainPath);
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
foreach (var option in c.SemanticInvalidOptions)
{
var values = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(paths, option);
Assert.NotEmpty(values);
var anyInvalid = values.Any(v =>
_validator.ValidateMultiItem(option, v, semantics) is not null);
Assert.True(anyInvalid, $"Expected at least one invalid value for option '{option}' in {c.File}");
}
}
}
@@ -0,0 +1,119 @@
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Ensures every specialized option (one with a semantic handler) has at least one valid and one invalid
/// test case; asserts only Null/NotNull, not error message text.
/// </summary>
public class OptionSemanticValidatorSpecializedTests
{
private static readonly OptionValidationSemantics ComplexAllowEmpty = new(OptionValidationKind.Complex, allowEmpty: true);
private readonly IOptionSemanticValidator _validator = new OptionSemanticValidator([
new LeasequerySemanticHandler(),
new ServerSemanticHandler(),
new LocalSemanticHandler(),
new RevServerSemanticHandler(),
new AddressSemanticHandler(),
new TrustAnchorSemanticHandler(),
new AliasSemanticHandler(),
new IpsetSemanticHandler(),
new NftsetSemanticHandler(),
new IgnoreAddressSemanticHandler(),
new ConnmarkAllowlistSemanticHandler(),
new DhcpRangeSemanticHandler(),
new DhcpHostSemanticHandler(),
new DhcpOptionSemanticHandler(),
new DhcpMatchSemanticHandler(),
new DhcpMacSemanticHandler(),
new DhcpRelaySemanticHandler(),
new DhcpProxySemanticHandler(),
new RaParamSemanticHandler(),
new DhcpNameMatchSemanticHandler(),
new DhcpIgnoreSemanticHandler(),
new DhcpVendorclassSemanticHandler(),
new DhcpUserclassSemanticHandler(),
new TagIfSemanticHandler(),
new BridgeInterfaceSemanticHandler(),
new SharedNetworkSemanticHandler(),
new DhcpOptionPxeSemanticHandler(),
new RebindDomainOkSemanticHandler(),
new BogusNxdomainSemanticHandler(),
new DhcpIgnoreNamesSemanticHandler(),
new DhcpBootSemanticHandler(),
new SlaacSemanticHandler(),
new PxeServiceSemanticHandler(),
new DhcpCircuitidSemanticHandler(),
new DhcpRemoteidSemanticHandler(),
new DhcpSubscrIdSemanticHandler(),
new FilterRrSemanticHandler(),
new CacheRrSemanticHandler(),
new InterfaceNameSemanticHandler(),
new AuthServerSemanticHandler(),
new CnameSemanticHandler(),
new MxHostSemanticHandler(),
new PtrRecordSemanticHandler(),
new InterfaceNameRecordSemanticHandler(),
new CaaRecordSemanticHandler(),
new SrvSemanticHandler(),
new NaptrRecordSemanticHandler(),
new DnsRrSemanticHandler(),
new DynamicHostSemanticHandler(),
new AuthSoaSemanticHandler(),
new AuthSecServersSemanticHandler(),
new AuthPeerSemanticHandler(),
new HostRecordSemanticHandler(),
new TxtRecordSemanticHandler(),
new DomainSemanticHandler(),
new SynthDomainSemanticHandler(),
new AuthZoneSemanticHandler(),
]);
/// <summary>Option key, one valid value (validator returns null), one invalid value (validator returns non-null).</summary>
public static IEnumerable<object[]> GetSpecializedValidInvalidPairs()
{
yield return new object[] { DnsmasqConfKeys.Leasequery, "10.0.0.0/24", "not-an-ip" };
yield return new object[] { DnsmasqConfKeys.Server, "1.1.1.1", "" };
yield return new object[] { DnsmasqConfKeys.Local, "/lan/", "" };
yield return new object[] { DnsmasqConfKeys.Address, "/example.com/127.0.0.1", "/bad..name/1.2.3.4" };
yield return new object[] { DnsmasqConfKeys.Domain, "home.lan,192.168.1.0/24", "example.com,192.168.1.0/24,local,extra" };
yield return new object[] { DnsmasqConfKeys.AuthServer, "zone.example.com,eth0", ",eth0" };
yield return new object[] { DnsmasqConfKeys.SynthDomain, "dynamic.example.com,192.168.2.1,192.168.2.100", "example.com," };
yield return new object[] { DnsmasqConfKeys.DnsRr, "example.com,16,01:02", "example.com,not-a-number" };
yield return new object[] { DnsmasqConfKeys.Srv, "_http._tcp.example.com,host.example.com,80", "_sip._tcp,target,99999" };
yield return new object[] { DnsmasqConfKeys.NaptrRecord, "example.com,0,0,a,s,r", "example.com,not-num,0,a,s,r" };
yield return new object[] { DnsmasqConfKeys.TxtRecord, "example.com,text", "" };
yield return new object[] { DnsmasqConfKeys.Cname, "www.example.com,example.com", "bad..name,example.com" };
yield return new object[] { DnsmasqConfKeys.MxHost, "example.com,mail.example.com,10", "bad..name,mail.example.com,10" };
yield return new object[] { DnsmasqConfKeys.PtrRecord, "1.168.192.in-addr.arpa,router.lan", "bad..arpa,host" };
yield return new object[] { DnsmasqConfKeys.RebindDomainOk, "example.com", "bad..name" };
yield return new object[] { DnsmasqConfKeys.BogusNxdomain, "192.168.1.1", "not-an-ip" };
yield return new object[] { DnsmasqConfKeys.ConnmarkAllowlist, "0xff,example.com", "not-a-mark,example.com" };
yield return new object[] { DnsmasqConfKeys.DhcpRange, "192.168.1.50,192.168.1.150,12h", "not-an-ip,192.168.1.150" };
yield return new object[] { DnsmasqConfKeys.DhcpHost, "aa:bb:cc:dd:ee:ff,192.168.1.10", "ignore" };
yield return new object[] { DnsmasqConfKeys.DhcpOption, "3,192.168.1.1", "option:," };
yield return new object[] { DnsmasqConfKeys.DhcpMatch, "set:efi,option:client-arch,6", "option:client-arch,6" };
yield return new object[] { DnsmasqConfKeys.DhcpMac, "set:vendor,aa:bb:cc:dd:ee:ff", "aa:bb:cc:dd:ee:ff" };
yield return new object[] { DnsmasqConfKeys.DhcpNameMatch, "set:tag,hostname*", "hostname*" };
yield return new object[] { DnsmasqConfKeys.DhcpIgnoreNames, "tag:guest", "guest" };
yield return new object[] { DnsmasqConfKeys.AuthZone, "example.com", "bad..name" };
yield return new object[] { DnsmasqConfKeys.FilterRr, "A,AAAA", "" };
yield return new object[] { DnsmasqConfKeys.CacheRr, "A,TXT", "" };
}
[Theory]
[MemberData(nameof(GetSpecializedValidInvalidPairs))]
public void SpecializedOption_ValidAccepted_InvalidRejected(string optionKey, string validValue, string invalidValue)
{
var validErr = _validator.ValidateMultiItem(optionKey, validValue, ComplexAllowEmpty);
Assert.Null(validErr);
var invalidErr = _validator.ValidateMultiItem(optionKey, invalidValue, ComplexAllowEmpty);
Assert.NotNull(invalidErr);
}
}
@@ -1,8 +1,10 @@
using DnsmasqWebUI.Infrastructure.Helpers.Config;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions;
using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Handlers;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Tests;
namespace DnsmasqWebUI.Tests.Services.EffectiveConfig;
/// <summary>
/// Tests for the central semantic validator (handler dispatch and generic kind validation).
@@ -45,7 +47,7 @@ public class OptionSemanticValidatorTests
new PxeServiceSemanticHandler(),
new DhcpCircuitidSemanticHandler(),
new DhcpRemoteidSemanticHandler(),
new DhcpSubscridSemanticHandler(),
new DhcpSubscrIdSemanticHandler(),
new FilterRrSemanticHandler(),
new CacheRrSemanticHandler(),
new InterfaceNameSemanticHandler(),
@@ -55,7 +57,7 @@ public class OptionSemanticValidatorTests
new PtrRecordSemanticHandler(),
new InterfaceNameRecordSemanticHandler(),
new CaaRecordSemanticHandler(),
new SrvHostSemanticHandler(),
new SrvSemanticHandler(),
new NaptrRecordSemanticHandler(),
new DnsRrSemanticHandler(),
new DynamicHostSemanticHandler(),
@@ -935,4 +937,72 @@ public class OptionSemanticValidatorTests
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "", semantics));
Assert.NotNull(_validator.ValidateMultiItem(DnsmasqConfKeys.AuthZone, "bad..name", semantics));
}
[Fact]
public void ValidateMultiItem_AuthServer_InvalidGlueDomain_IsRejected()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.AuthServer, ",eth0", semantics);
Assert.NotNull(err);
}
[Fact]
public void ValidateMultiItem_SynthDomain_InvalidAddressRange_IsRejected()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.SynthDomain, "example.com,", semantics);
Assert.NotNull(err);
}
[Fact]
public void ValidateMultiItem_Domain_ExtraFields_AreRejected()
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Domain, "example.com,192.168.1.0/24,local,extra", semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("example.com,not-a-number")]
[InlineData(",16,01:02")]
[InlineData("bad..name,16,01:02")]
public void ValidateMultiItem_DnsRr_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.DnsRr, value, semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("_sip._tcp,host,not-a-port")]
[InlineData("")]
[InlineData("_sip._tcp,target,99999")] // port out of range
public void ValidateMultiItem_SrvHost_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.Srv, value, semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("example.com,not-num,0,a,s,r")]
[InlineData("bad..name,0,0,a,s,r")]
[InlineData("short,0,0,a,s")] // fewer than 6 fields
public void ValidateMultiItem_NaptrRecord_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.NaptrRecord, value, semantics);
Assert.NotNull(err);
}
[Theory]
[InlineData("")]
[InlineData("bad..name,text")]
[InlineData(",only-text")]
public void ValidateMultiItem_TxtRecord_Malformed_IsRejected(string value)
{
var semantics = new OptionValidationSemantics(OptionValidationKind.Complex, allowEmpty: true);
var err = _validator.ValidateMultiItem(DnsmasqConfKeys.TxtRecord, value, semantics);
Assert.NotNull(err);
}
}
@@ -0,0 +1,80 @@
using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Hosts;
using DnsmasqWebUI.Models.Hosts;
namespace DnsmasqWebUI.Tests.Services.Hosts;
public class HostsPageProjectionServiceTests
{
private readonly HostsPageProjectionService _sut = new();
[Fact]
public void BuildGroups_SourcePathFilter_KeepsOnlyThatFile()
{
var rows = new HostsPageRow[]
{
new(
"m1",
HostsRowSourceKind.Managed,
"/data/managed.hosts",
IsEditable: true,
IsActive: true,
InactiveReason: null,
Address: "10.0.0.1",
Names: new[] { "a" },
EffectiveNames: new[] { "a" },
IsComment: false,
LineNumber: 1),
new(
"x1",
HostsRowSourceKind.AddnHosts,
"/etc/extra.hosts",
IsEditable: false,
IsActive: true,
InactiveReason: null,
Address: "10.0.0.2",
Names: new[] { "b" },
EffectiveNames: new[] { "b" },
IsComment: false,
LineNumber: 1),
};
var query = new HostsPageQueryState(
Search: "",
Grouping: HostsGroupingMode.Source,
Sort: HostsSortMode.LineNumber,
Descending: false,
SourceKindFilter: null,
SourcePathFilter: "/etc/extra.hosts",
EditableFilter: null,
ActiveFilter: null);
var groups = _sut.BuildGroups(rows, query);
Assert.Single(groups);
Assert.Equal("/etc/extra.hosts", groups[0].Subtitle);
Assert.Single(groups[0].Rows);
}
[Fact]
public void BuildGroups_SourcePathFilter_EmptyMeansAllFiles()
{
var rows = new HostsPageRow[]
{
new("m1", HostsRowSourceKind.Managed, "/a", true, true, null, "1.1.1.1", new[] { "a" }, new[] { "a" }, false, 1),
new("x1", HostsRowSourceKind.AddnHosts, "/b", false, true, null, "2.2.2.2", new[] { "b" }, new[] { "b" }, false, 1),
};
var query = new HostsPageQueryState(
"",
HostsGroupingMode.Source,
HostsSortMode.LineNumber,
false,
null,
SourcePathFilter: null,
null,
null);
var groups = _sut.BuildGroups(rows, query);
Assert.Equal(2, groups.Count);
}
}
@@ -52,15 +52,12 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
{
if (firstRender)
{
try
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/dialog.js");
if (_jsModule != null)
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
catch (InvalidOperationException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
}
if (IsVisible && _moduleLoaded && _jsModule != null)
@@ -146,6 +143,8 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
_cts.Cancel();
_cts.Dispose();
_dotNetRef?.Dispose();
_dotNetRef = null;
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
}
@@ -62,13 +62,12 @@ public partial class AppLogsSection : IAsyncDisposable
_hubConnection.On<LogsUpdatePayload>("AppLogsUpdate", OnAppLogsUpdate);
try
_logsJs = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/logs.js");
if (_logsJs == null)
{
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
Logger.LogDebug("AppLogsSection: logs JS module unavailable (prerender, disconnect, or import failure)");
return;
}
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (prerender)"); return; }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (circuit disconnected)"); return; }
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import failed"); return; }
await _hubConnection.StartAsync(_cts.Token);
RestartPollTimer();
@@ -169,6 +168,7 @@ public partial class AppLogsSection : IAsyncDisposable
}
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
catch (JSDisconnectedException) { /* Circuit gone before InvokeAsync completed */ }
}
private void SetJustUpdated()
@@ -63,13 +63,12 @@ public partial class LogsSection : IAsyncDisposable
_hubConnection.On<LogsUpdatePayload>("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate);
try
_logsJs = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/logs.js");
if (_logsJs == null)
{
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
Logger.LogDebug("LogsSection: logs JS module unavailable (prerender, disconnect, or import failure)");
return;
}
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (prerender)"); return; }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (circuit disconnected)"); return; }
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: JS import failed"); return; }
await _hubConnection.StartAsync(_cts.Token);
RestartPollTimer();
@@ -135,6 +134,7 @@ public partial class LogsSection : IAsyncDisposable
}
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
catch (JSDisconnectedException) { /* Circuit gone before InvokeAsync completed */ }
}
private void SetJustUpdated()
@@ -1,4 +1,5 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Extensions.Interop
@inject IJSRuntime JSRuntime
<div class="d-flex align-items-center gap-1 @CssClass">
@@ -15,13 +16,7 @@
private async Task CopyAsync()
{
if (string.IsNullOrEmpty(Text)) return;
try
{
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
}
catch
{
// Clipboard not available or failed
}
// window.copyToClipboard from wwwroot/js/copy-to-clipboard.js; swallow disconnect / clipboard failures.
await JSRuntime.InvokeVoidAsyncSafe("copyToClipboard", args: new object?[] { Text });
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Components;
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
namespace DnsmasqWebUI.Components.EffectiveConfig;
/// <summary>
/// Base for custom effective-config multi-value displays; mirrors <see cref="EffectiveConfigValueDisplayBase"/> for singles.
/// </summary>
public abstract class EffectiveConfigMultiValueDisplayBase : ComponentBase
{
[Parameter]
public EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
[Parameter]
public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
protected IReadOnlyList<ValueWithSource> EffectiveItems => Context.EffectiveItems;
protected IReadOnlyList<string> EffectiveValues => Context.EffectiveValues;
protected Task NotifyValuesChanged(IReadOnlyList<string> values) => OnValuesChanged.InvokeAsync(values);
}
@@ -31,7 +31,7 @@ public abstract class EffectiveConfigValueDisplayBase : ComponentBase
/// <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 =>
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().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();
@@ -1,13 +1,13 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@inject IEffectiveConfigDescriptorProvider DescriptorProvider
<div class="ec-sections small">
@{
var views = EffectiveConfigViews.GetViewsForContext(Context);
var descriptorsBySection = EffectiveConfigViews.GetDescriptorsBySection(Status, views, RenderFragmentRegistry);
var descriptorsBySection = Status != null ? DescriptorProvider.GetBySection(Status, views) : new Dictionary<string, IReadOnlyList<EffectiveConfigFieldDescriptor>>();
var hasSearch = !string.IsNullOrWhiteSpace(SearchTerm);
var term = SearchTerm?.Trim() ?? "";
}
@@ -1,11 +1,12 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions
@inject IEffectiveConfigEditSession Session
@inject IEffectiveConfigCrossOptionValidationService CrossOptionValidation
@inject IJSRuntime JSRuntime
@implements IDisposable
@if (Status != null)
{
@@ -17,17 +18,17 @@
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"
ValidationSummary="@Session.GetValidationSummary()"
IsEditMode="@Session.Ui.IsEditMode" PendingChangesCount="@Session.Draft.PendingChanges.Count"
ValidationSummary="@Session.Draft.GetValidationSummary()"
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" />
OnSectionToggle="OnSectionToggle" IsEditMode="@Session.Ui.IsEditMode" ActiveFieldKey="@Session.Ui.ActiveFieldKey" />
</CascadingValue>
</CascadingValue>
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.PendingChanges" Status="@Status" Session="@Session"
<EffectiveConfigSaveFlow ShowModal="@_showSaveModal" PendingChanges="@Session.Draft.PendingChanges" Status="@Status" Session="@Session"
OnClose="CloseSaveModal" OnRevertedNotify="OnRevertedFromSaveModal" OnSaveCompleted="OnSaveCompleted" OnContinueEditingAfterSavedFailure="ContinueEditingAfterSavedFailureAsync" OnCloseAndEndEdit="CloseSaveModalAndEndEdit" />
</div>
}
@@ -59,6 +60,7 @@
protected override void OnInitialized()
{
Session.Changed += HandleSessionChanged;
_uiContext = new EffectiveConfigUiContext
{
RequestOptionHelpAsync = HandleOptionHelpRequested,
@@ -69,12 +71,19 @@
return Task.CompletedTask;
},
ActivateFieldAsync = fieldKey => { ActivateField(fieldKey); return Task.CompletedTask; },
DeactivateFieldAsync = () => { Session.DeactivateField(); return InvokeAsync(StateHasChanged); },
DeactivateFieldAsync = () => { Session.Ui.DeactivateField(); return InvokeAsync(StateHasChanged); },
CommitFieldAsync = args => { HandleFieldEditCommitted(args); return Task.CompletedTask; },
RevertFieldAsync = (sectionId, optionName) => { Session.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
RevertFieldAsync = (sectionId, optionName) => { Session.Draft.RevertChange(sectionId, optionName); RunCrossOptionEvaluator(); return InvokeAsync(StateHasChanged); }
};
}
private void HandleSessionChanged() => _ = InvokeAsync(StateHasChanged);
public void Dispose()
{
Session.Changed -= HandleSessionChanged;
}
private async Task OnSearchTermChanged(string value)
{
_searchTerm = value ?? "";
@@ -145,55 +154,72 @@
private void EnterEditMode()
{
Session.EnterEditMode();
Session.Ui.EnterEditMode();
RefreshCrossOptionAfterEnteringEditMode();
StateHasChanged();
}
private async Task ExitEditModeWithConfirmAsync()
{
if (Session.PendingChanges.Count > 0)
if (Session.Draft.PendingChanges.Count > 0)
{
var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
var module = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/dialog.js");
if (module == null)
return;
try
{
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
if (!confirmed) return;
var confirmed = await module.InvokeAsyncSafe<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
if (confirmed != true)
return;
}
finally
{
await module.DisposeAsync();
await module.DisposeAsyncSafe();
}
}
Session.ExitEditModeDiscard();
Session.Draft.DiscardAllDraft();
Session.Ui.ExitEditMode();
StateHasChanged();
}
private void DeactivateActiveFieldIfClicked()
{
if (Session.IsEditMode && Session.ActiveFieldKey != null)
if (Session.Ui.IsEditMode && Session.Ui.ActiveFieldKey != null)
{
Session.DeactivateField();
Session.Ui.DeactivateField();
StateHasChanged();
}
}
private void ActivateField(string fieldKey)
{
Session.ActivateField(fieldKey);
Session.Ui.ActivateField(fieldKey);
StateHasChanged();
}
private void HandleFieldEditCommitted(EffectiveConfigEditCommittedArgs args)
{
Session.TrackCommit(args);
Session.Draft.TrackCommit(args);
Session.Ui.DeactivateField();
RunCrossOptionEvaluator();
StateHasChanged();
}
private void RunCrossOptionEvaluator()
{
var issues = EffectiveConfigCrossOptionEvaluator.Evaluate(Status, Session.PendingChanges);
Session.SetCrossOptionIssues(issues);
var optionChanges = Session.Draft.PendingChanges.OfType<PendingOptionChange>().ToList();
var issues = CrossOptionValidation.Validate(Status, optionChanges);
Session.Draft.SetCrossOptionIssues(issues);
}
/// <summary>
/// After entering edit mode, repopulate cross-option issues from disk + pending.
/// </summary>
private void RefreshCrossOptionAfterEnteringEditMode()
{
if (Status != null)
RunCrossOptionEvaluator();
}
private void OnRevertedFromSaveModal()
@@ -204,7 +230,10 @@
private void OpenSaveModal()
{
if (Session.PendingChanges.Count == 0) return;
var hasPending = Session.Draft.PendingChanges.Count > 0;
var hasValidation = Session.Draft.GetValidationSummary().Count > 0;
if (!hasPending && !hasValidation)
return;
_showSaveModal = true;
StateHasChanged();
}
@@ -218,16 +247,19 @@
private async Task ContinueEditingAfterSavedFailureAsync()
{
_showSaveModal = false;
Session.ExitEditModeDiscard();
Session.Draft.DiscardAllDraft();
Session.Ui.ExitEditMode();
await OnSaveCompleted.InvokeAsync();
Session.EnterEditMode();
Session.Ui.EnterEditMode();
RefreshCrossOptionAfterEnteringEditMode();
StateHasChanged();
}
private void CloseSaveModalAndEndEdit()
{
_showSaveModal = false;
Session.ExitEditModeDiscard();
Session.Draft.DiscardAllDraft();
Session.Ui.ExitEditMode();
StateHasChanged();
}
}
@@ -172,6 +172,16 @@
margin-bottom: 0.35rem;
}
/* Option label + field-level badges (editable / ok / cancel) on one row */
::deep .ec-field-label-row {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem 0.5rem;
flex: 0 1 auto;
min-width: 0;
}
::deep .ec-field:last-child {
margin-bottom: 0;
}
@@ -206,7 +216,7 @@
/* When a field is active: value + badges in one block so any click inside (checkbox, input, ok/cancel) doesn't deactivate */
::deep .ec-field-value-when-editing {
display: inline-flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 0.25rem;
min-width: 0;
@@ -239,11 +249,6 @@
word-break: break-word;
}
/* Row-level ok/cancel (and revert) for multi-value: right-align on the row below the list */
::deep .ec-field .ec-field-value-multi + .status-badge-inline {
margin-left: auto;
}
::deep .ec-field-value-list {
margin: 0;
padding-left: 1.25rem;
@@ -262,6 +267,24 @@
margin-bottom: 0;
}
::deep .ec-field-value-list li.ec-multi-list-empty-hint {
list-style: none;
margin-left: -1.25rem;
}
/* Shared with default and custom (e.g. domain) multi-value editors */
::deep .ec-field-value-list li.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);
}
::deep .ec-field-value-list li.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
margin-left: auto;
}
/* Long paths/URLs wrap; allow break anywhere so nothing is clipped */
::deep .ec-field-value-item {
display: inline;
@@ -288,7 +311,11 @@
margin-left: auto;
}
@media (max-width: 991.98px) {
@media (max-width: 575.98px) {
::deep .ec-field-value-when-editing {
flex-wrap: wrap;
}
::deep .ec-field-value-when-editing > .status-badge-inline {
flex-basis: 100%;
margin-left: 0;
@@ -37,7 +37,7 @@
{
<span class="text-danger fw-medium ec-toolbar-changes-link" role="button" tabindex="0"
title="@ValidationErrorTooltip"
aria-label="Open save dialog"
aria-label="Open validation summary"
@onclick="OnOpenSaveModal"
@onkeydown="OnValidationLinkKeyDown">
@ValidationErrorCount error(s)
@@ -48,7 +48,7 @@
{
<span class="text-warning ec-toolbar-changes-link" role="button" tabindex="0"
title="@ValidationWarningTooltip"
aria-label="Open save dialog"
aria-label="Open validation summary"
@onclick="OnOpenSaveModal"
@onkeydown="OnValidationLinkKeyDown">
@ValidationWarningCount warning(s)
@@ -52,7 +52,7 @@ else
return;
}
var pending = Session?.PendingChanges.FirstOrDefault(c =>
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
if (pending?.OldValue is ExplicitToggleState previousState)
@@ -0,0 +1,579 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject INotificationService NotificationService
<div class="ec-field-value ec-field-value-multi ec-domain-rule-editor" @onclick:stopPropagation="true">
<ol class="ec-field-value-list ec-domain-rule-list">
@if (_displayItems.Count > 0)
{
@for (var i = 0; i < _displayItems.Count; i++)
{
var item = _displayItems[i];
var anchorId = $"{ReadonlyBadgeAnchorIdBase}-{i}";
var itemReadonly = item.Source?.IsReadOnly == true;
var rowIndex = i;
var rowValue = item.Value;
<li class="ec-domain-rule-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-domain-rule-row-editing" : "")" @key="anchorId">
<div class="ec-domain-rule-row-body">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-domain-rule-form">
<div class="ec-domain-rule-toolbar">
<div class="ec-domain-rule-toolbar__group">
<span class="form-label mb-0">Editor mode</span>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@DomainRuleEditorHelpText"
aria-label="@DomainRuleEditorHelpText"
@onclick="ShowDomainRuleEditorHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(false)">
Guided
</button>
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(true)">
Raw line
</button>
</div>
</div>
@if (_draftUseRaw)
{
<input class="form-control form-control-sm font-monospace"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]" />
}
else
{
<div>
<label class="form-label" for="@($"{anchorId}-dom")">Domain</label>
<input id="@($"{anchorId}-dom")" class="form-control form-control-sm"
placeholder="e.g. home.arpa or #"
@bind="_draftDomain" @bind:event="oninput" />
<div class="ec-domain-rule-hint-row">
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@HashDomainHelpText"
aria-label="@HashDomainHelpText"
@onclick="ShowHashDomainHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
</div>
<div>
<label class="form-label" for="@($"{anchorId}-scope")">Applies to</label>
<select id="@($"{anchorId}-scope")" class="form-select form-select-sm"
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
<option value="unconditional">Any client address (default)</option>
<option value="range">Limit to this subnet or IP</option>
<option value="interface">Limit to subnets on this interface</option>
</select>
</div>
@if (_draftScopeSelect == "range")
{
<div>
<label class="form-label" for="@($"{anchorId}-mid")">Subnet or IP</label>
<input id="@($"{anchorId}-mid")" class="form-control form-control-sm"
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
@bind="_draftMiddle" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc")">
Also add matching <code>--local</code> forward and reverse DNS
</label>
</div>
}
else if (_draftScopeSelect == "interface")
{
<div>
<label class="form-label" for="@($"{anchorId}-if")">Interface</label>
<input id="@($"{anchorId}-if")" class="form-control form-control-sm"
placeholder="e.g. eth0 or br0"
@bind="_draftMiddle" @bind:event="oninput" />
</div>
}
else if (_draftScopeSelect == "unconditional")
{
<div class="form-check ec-domain-rule-local-check">
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc2")"
@bind="_draftAddLocal" />
<label class="form-check-label" for="@($"{anchorId}-loc2")">
Append <code>,local</code>
</label>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@LocalSuffixHelpText"
aria-label="@LocalSuffixHelpText"
@onclick="ShowLocalSuffixHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
}
}
<div class="ec-domain-rule-actions">
<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" />
</div>
</div>
}
else
{
<div class="ec-domain-rule-summary">
<span class="ec-field-value-item">@DisplayLine(rowValue)</span>
@if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal))
{
<code class="text-muted small">@rowValue</code>
}
<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"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
</div>
}
</div>
</li>
}
}
else if (!AllowAddManagedValue)
{
<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 @(_addingNew ? "ec-multi-list-row-with-controls" : "")" @key="AddItemKey">
@if (_addingNew)
{
<div class="ec-domain-rule-form">
<div class="ec-domain-rule-toolbar">
<div class="ec-domain-rule-toolbar__group">
<span class="form-label mb-0">Editor mode</span>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@DomainRuleEditorHelpText"
aria-label="@DomainRuleEditorHelpText"
@onclick="ShowDomainRuleEditorHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
<div class="ec-domain-rule-mode-toggle" role="group" aria-label="Domain rule editor mode">
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(!_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(!_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(false)">
Guided
</button>
<button type="button"
class="btn btn-sm ec-domain-rule-mode-btn @(_draftUseRaw ? "ec-domain-rule-mode-btn--active" : null)"
aria-pressed="@(_draftUseRaw ? "true" : "false")"
@onclick="() => OnDraftUseRawChanged(true)">
Raw line
</button>
</div>
</div>
@if (_draftUseRaw)
{
<input class="form-control form-control-sm font-monospace"
@bind="_draftRawLine" @bind:event="oninput"
placeholder="domain[,subnet|IP|interface[,local]]" />
}
else
{
<div>
<label class="form-label" for="domain-add-dom">Domain</label>
<input id="domain-add-dom" class="form-control form-control-sm"
placeholder="e.g. home.arpa or #"
@bind="_draftDomain" @bind:event="oninput" />
<div class="ec-domain-rule-hint-row">
<div class="ec-domain-rule-hint">Use <code>#</code> to take the search domain from resolv.conf.</div>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@HashDomainHelpText"
aria-label="@HashDomainHelpText"
@onclick="ShowHashDomainHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
</div>
<div>
<label class="form-label" for="domain-add-scope">Applies to</label>
<select id="domain-add-scope" class="form-select form-select-sm"
@bind="_draftScopeSelect" @bind:after="OnDraftScopeAfterChange">
<option value="unconditional">Any client address (default)</option>
<option value="range">Limit to this subnet or IP</option>
<option value="interface">Limit to subnets on this interface</option>
</select>
</div>
@if (_draftScopeSelect == "range")
{
<div>
<label class="form-label" for="domain-add-mid">Subnet or IP</label>
<input id="domain-add-mid" class="form-control form-control-sm"
placeholder="e.g. 192.168.0.0/24 or 192.168.0.1"
@bind="_draftMiddle" @bind:event="oninput" />
<div class="ec-domain-rule-hint">Single IP, CIDR, or start,end pair per dnsmasq docs.</div>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" id="domain-add-loc"
@bind="_draftAddLocal" />
<label class="form-check-label" for="domain-add-loc">
Also add matching <code>--local</code> forward and reverse DNS
</label>
</div>
}
else if (_draftScopeSelect == "interface")
{
<div>
<label class="form-label" for="domain-add-if">Interface</label>
<input id="domain-add-if" class="form-control form-control-sm"
placeholder="e.g. eth0 or br0"
@bind="_draftMiddle" @bind:event="oninput" />
</div>
}
else if (_draftScopeSelect == "unconditional")
{
<div class="form-check ec-domain-rule-local-check">
<input type="checkbox" class="form-check-input" id="domain-add-loc2"
@bind="_draftAddLocal" />
<label class="form-check-label" for="domain-add-loc2">
Append <code>,local</code>
</label>
<button type="button"
class="btn btn-link btn-sm text-muted ec-domain-rule-help p-0 border-0"
title="@LocalSuffixHelpText"
aria-label="@LocalSuffixHelpText"
@onclick="ShowLocalSuffixHelp">
<i class="bi bi-question-circle" aria-hidden="true"></i>
</button>
</div>
}
}
<div class="ec-domain-rule-actions">
<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" />
</div>
</div>
}
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>
</div>
@code {
[Parameter] public IReadOnlyList<ValueWithSource>? SourceItems { get; set; }
[Parameter] public string OptionName { get; set; } = "domain";
[Parameter] public string ReadonlyBadgeAnchorIdBase { get; set; } = "";
[Parameter] public bool IsEditMode { get; set; }
[Parameter] public bool AllowAddManagedValue { get; set; } = true;
[Parameter] public IMultiValueEditBehavior? Behavior { get; set; }
[Parameter] public IMultiValueOptionValidator? Validator { get; set; }
[Parameter] public string? FieldKey { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> OnValuesChanged { get; set; }
private const string AddItemKey = "domain-add-item";
private static readonly TimeSpan ValidationToastDedupeWindow = TimeSpan.FromSeconds(2);
private int? _editingIndex;
private bool _addingNew;
private bool _didAutoOpenAdd;
private string? _lastValidationErrorMessage;
private DateTime _lastValidationErrorUtc;
private List<ValueWithSource> _displayItems = new();
private bool _draftUseRaw;
private string _draftRawLine = "";
private string _draftDomain = "";
private string _draftScopeSelect = "unconditional";
private string _draftMiddle = "";
private bool _draftAddLocal;
private IReadOnlyList<string> _currentValues => _displayItems.Select(x => x.Value).ToList();
protected override void OnParametersSet()
{
_displayItems = (SourceItems ?? Array.Empty<ValueWithSource>()).ToList();
if (!_didAutoOpenAdd && (SourceItems?.Count ?? 0) == 0 && AllowAddManagedValue)
{
_didAutoOpenAdd = true;
_addingNew = true;
ResetDraftForNew();
}
}
private static string DisplayLine(string rowValue) =>
DomainRuleFields.FormatSummary(rowValue);
private void ResetDraftForNew()
{
_draftUseRaw = false;
_draftRawLine = "";
_draftDomain = "";
_draftScopeSelect = "unconditional";
_draftMiddle = "";
_draftAddLocal = false;
}
private void BeginEdit(int index, string current)
{
_addingNew = false;
_editingIndex = index;
LoadDraftFromLine(current);
}
private void BeginAdd()
{
_editingIndex = null;
_addingNew = true;
ResetDraftForNew();
}
private void CancelInline()
{
_editingIndex = null;
_addingNew = false;
_lastValidationErrorMessage = null;
ResetDraftForNew();
}
private void LoadDraftFromLine(string line)
{
var parsed = DomainRuleFields.Parse(line);
if (parsed.IsRaw)
{
_draftUseRaw = true;
_draftRawLine = parsed.RawLineFallback ?? line;
_draftDomain = "";
_draftScopeSelect = "unconditional";
_draftMiddle = "";
_draftAddLocal = false;
return;
}
_draftUseRaw = false;
_draftRawLine = "";
_draftDomain = parsed.DomainPart;
_draftScopeSelect = parsed.Scope switch
{
DomainRuleScope.AddressRangeOrIp => "range",
DomainRuleScope.InterfaceName => "interface",
_ => "unconditional",
};
_draftMiddle = parsed.MiddlePart;
_draftAddLocal = parsed.AddLocalForRange;
}
private void OnDraftUseRawChanged(bool useRaw)
{
if (useRaw && !_draftUseRaw)
{
var fields = BuildFieldsFromDraftStructured();
_draftRawLine = fields.ToConfigLine();
}
else if (!useRaw && _draftUseRaw)
LoadDraftFromLine(_draftRawLine.Trim());
_draftUseRaw = useRaw;
}
private void ShowDomainRuleEditorHelp() => NotificationService.ShowInfo(DomainRuleEditorHelpText);
private void ShowLocalSuffixHelp() => NotificationService.ShowInfo(LocalSuffixHelpText);
private void ShowHashDomainHelp() => NotificationService.ShowInfo(HashDomainHelpText);
private void OnDraftScopeAfterChange()
{
if (_draftScopeSelect == "unconditional")
_draftMiddle = "";
if (_draftScopeSelect == "interface")
_draftAddLocal = false;
}
private DomainRuleFields BuildFieldsFromDraftStructured()
{
var dom = (_draftDomain ?? "").Trim();
var mid = (_draftMiddle ?? "").Trim();
var scope = _draftScopeSelect switch
{
"range" => DomainRuleScope.AddressRangeOrIp,
"interface" => DomainRuleScope.InterfaceName,
_ => DomainRuleScope.Unconditional,
};
if (scope == DomainRuleScope.Unconditional)
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", _draftAddLocal, null);
if (scope == DomainRuleScope.InterfaceName)
return new DomainRuleFields(dom, DomainRuleScope.InterfaceName, mid, false, null);
if (mid.Length == 0 && _draftAddLocal)
return new DomainRuleFields(dom, DomainRuleScope.Unconditional, "", true, null);
return new DomainRuleFields(dom, DomainRuleScope.AddressRangeOrIp, mid, _draftAddLocal, null);
}
private DomainRuleFields BuildFieldsFromDraft() =>
_draftUseRaw ? DomainRuleFields.FromRawConfigLine(_draftRawLine) : BuildFieldsFromDraftStructured();
private void ShowValidationError(string message)
{
var now = DateTime.UtcNow;
if (string.Equals(_lastValidationErrorMessage, message, StringComparison.Ordinal) &&
(now - _lastValidationErrorUtc) < ValidationToastDedupeWindow)
return;
_lastValidationErrorMessage = message;
_lastValidationErrorUtc = now;
NotificationService.ShowError(message);
}
private async Task SaveEdit(int index)
{
var next = _currentValues.ToList();
if (index < 0 || index >= next.Count) return;
var fields = BuildFieldsFromDraft();
var normalized = fields.ToConfigLine();
if (string.IsNullOrWhiteSpace(normalized))
{
ShowValidationError("Domain name (or #) is required.");
return;
}
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
{
ShowValidationError("Interface name is required for this scope.");
return;
}
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
var validationError = Validator?.ValidateItem(normalized, _currentValues, index);
if (validationError != null)
{
ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && next.Where((v, i) => i != index).Any(v => string.Equals(v, normalized, StringComparison.Ordinal)))
{
ShowValidationError("Duplicate values are not allowed.");
return;
}
next[index] = normalized;
_displayItems[index] = _displayItems[index] with { Value = normalized };
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task SaveNew()
{
var fields = BuildFieldsFromDraft();
var normalized = fields.ToConfigLine();
if (string.IsNullOrWhiteSpace(normalized))
{
ShowValidationError("Domain name (or #) is required.");
return;
}
if (!_draftUseRaw && _draftScopeSelect == "interface" && string.IsNullOrWhiteSpace(_draftMiddle))
{
ShowValidationError("Interface name is required for this scope.");
return;
}
normalized = Behavior?.Normalize(normalized) ?? normalized.Trim();
var current = _currentValues;
var validationError = Validator?.ValidateItem(normalized, current, null);
if (validationError != null)
{
ShowValidationError(validationError);
return;
}
if (Behavior?.AllowDuplicates == false && current.Contains(normalized, StringComparer.Ordinal))
{
ShowValidationError("Duplicate values are not allowed.");
return;
}
var next = current.ToList();
next.Add(normalized);
_displayItems.Add(new ValueWithSource(normalized, null));
await OnValuesChanged.InvokeAsync(next);
CancelInline();
}
private async Task RemoveAt(int index)
{
if (index < 0 || index >= _displayItems.Count) return;
var item = _displayItems[index];
if (item?.Source?.IsReadOnly == true) return;
var next = _displayItems.Select(x => x.Value).ToList();
next.RemoveAt(index);
_displayItems.RemoveAt(index);
await OnValuesChanged.InvokeAsync(next);
StateHasChanged();
}
private const string DomainRuleEditorHelpText =
"domain= sets the suffix dnsmasq uses for DHCP and local DNS naming. Guided mode builds the rule from fields; Raw line mode lets you enter the exact dnsmasq config line directly.";
private const string LocalSuffixHelpText =
"Appending ,local behaves like a matching --local rule for this domain, making dnsmasq answer locally instead of forwarding it upstream. This is uncommon for an unconditional domain= rule.";
private const string HashDomainHelpText =
"Use # as the domain value to take the search domain from resolv.conf instead of hard-coding a suffix in dnsmasq.";
}
@@ -0,0 +1,150 @@
.ec-domain-rule-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);
}
/* Keep <ol> markers (decimal) like the default multi-value editor; do not reset list-style here. */
.ec-domain-rule-editor .ec-field-value-list {
margin-bottom: 0;
}
.ec-domain-rule-row {
min-height: 2rem;
}
.ec-domain-rule-row-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.ec-domain-rule-row.ec-domain-rule-row-editing .ec-domain-rule-row-body {
align-items: stretch;
}
.ec-domain-rule-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 36rem;
margin-left: 0.5rem;
}
.ec-domain-rule-form .form-label {
margin-bottom: 0.15rem;
font-size: 0.8rem;
font-weight: 600;
color: var(--bs-secondary-color, #6c757d);
}
.ec-domain-rule-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem 0.75rem;
flex-wrap: wrap;
}
.ec-domain-rule-toolbar__group {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.ec-domain-rule-mode-toggle {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 999px;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
}
.ec-domain-rule-mode-btn {
border: 0;
border-radius: 999px;
padding: 0.2rem 0.65rem;
font-size: 0.75rem;
font-weight: 600;
color: #475569;
background: transparent;
box-shadow: none;
}
.ec-domain-rule-mode-btn:hover,
.ec-domain-rule-mode-btn:focus-visible {
color: #0f172a;
background: rgba(148, 163, 184, 0.12);
}
.ec-domain-rule-mode-btn--active,
.ec-domain-rule-mode-btn--active:hover,
.ec-domain-rule-mode-btn--active:focus-visible {
color: #fff;
background: linear-gradient(180deg, #0d6efd 0%, #0a58ca 100%);
}
.ec-domain-rule-help {
line-height: 1;
text-decoration: none;
}
.ec-domain-rule-help:hover,
.ec-domain-rule-help:focus-visible {
color: var(--bs-primary) !important;
}
.ec-domain-rule-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.35rem;
}
.ec-domain-rule-editor .ec-field-value-list li.ec-multi-list-add-item .ec-domain-rule-actions .status-badge-inline,
.ec-domain-rule-actions .status-badge-inline {
margin-left: 0;
}
.ec-domain-rule-summary {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
margin-left: 0.5rem;
}
.ec-domain-rule-raw {
font-family: var(--bs-font-monospace);
font-size: 0.85rem;
}
.ec-domain-rule-hint {
font-size: 0.75rem;
color: var(--bs-secondary-color, #6c757d);
margin-top: -0.25rem;
}
.ec-domain-rule-hint-row {
display: flex;
align-items: flex-start;
gap: 0.25rem;
}
.ec-domain-rule-local-check {
display: flex;
align-items: flex-start;
gap: 0.35rem;
}
.ec-domain-rule-local-check .form-check-label {
flex: 1 1 auto;
min-width: 0;
}
@@ -0,0 +1,56 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
@inherits EffectiveConfigMultiValueDisplayBase
@if (Context.IsActiveEditor)
{
<DomainEffectiveConfigListEditor
SourceItems="EffectiveItems"
OptionName="@Context.Descriptor.OptionName"
ReadonlyBadgeAnchorIdBase="@_anchorBase"
IsEditMode="@Context.IsEditMode"
AllowAddManagedValue="@Context.IsEditMode"
FieldKey="@Context.FieldKey"
Behavior="@_multiDesc?.Behavior"
Validator="@_multiDesc?.Validator"
OnValuesChanged="@NotifyValuesChanged" />
}
else
{
<div class="ec-field-value ec-field-value-multi">
<ol class="ec-field-value-list">
@if (Context.EffectiveItems.Count > 0)
{
@foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index)))
{
var anchorId = $"{_anchorBase}-{index}";
<li>
<EffectiveConfigMultiValueItem
Value="@item.Value"
DisplayValue="@DomainRuleFields.FormatSummary(item.Value)"
Source="@item.Source"
OptionName="@Context.Descriptor.OptionName"
IsEditMode="@Context.IsEditMode"
ReadonlyBadgeAnchorId="@anchorId"
FieldKey="@Context.FieldKey" />
</li>
}
}
else
{
<li class="ec-multi-list-empty-hint">
<span class="text-muted">(none)</span>
</li>
}
</ol>
</div>
}
@code {
private readonly string _anchorBase = "ec-domain-" + Guid.NewGuid().ToString("N")[..8];
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc =>
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
}
@@ -1,5 +1,4 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Models.Dnsmasq
@inherits EffectiveConfigValueDisplayBase
@@ -2,7 +2,6 @@
@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
@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry
@@ -46,30 +45,32 @@
else if (Descriptor.IsMultiValue)
{
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
if (customMulti is not null)
if (customMulti is not null && _multiContext is not null)
{
@* Custom multi editor (e.g. server): same row shell as generic multi, value slot uses custom fragment. *@
<li class="ec-field @(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled ? "ec-field-editing" : "")">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
<span class="ec-field-value">
<CascadingValue Value="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)">
<CascadingValue Value="@IsEditMode">
@customMulti(Descriptor)
</CascadingValue>
@* Custom multi: row badge only while this field is active (ok/cancel/revert). Otherwise per-item / entry badges in the fragment suffice — avoids duplicating "readonly" when RowSource matches item sources. *@
var customMultiActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
<li class="ec-field ec-field--multi-value @(customMultiActiveEditor ? "ec-field-editing" : "")">
<div class="ec-field-label-row">
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
@if (!Descriptor.IsCapabilityDisabled)
{
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@(customMultiActiveEditor ? _multiContext.RowSource : null)" OptionName="@Descriptor.OptionName" Value="@_multiContext.EffectiveItems"
IsInteractive="@IsEditMode" ShowEditableBadge="true" HasPendingChange="@_multiContext.HasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@customMultiActiveEditor"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
</div>
<CascadingValue Value="@customMultiActiveEditor">
<CascadingValue Value="@IsEditMode">
@customMulti(_multiContext)
</CascadingValue>
</span>
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
</CascadingValue>
</li>
}
else
else if (_multiContext is not null)
{
@* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
Source="@_multiValueRowSource" ShowEditableBadge="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
<EffectiveConfigMultiValueRow Context="@_multiContext" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
}
}
@@ -123,14 +124,7 @@ else
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;
private EffectiveConfigMultiDisplayContext? _multiContext;
protected override void OnParametersSet()
{
@@ -138,7 +132,7 @@ else
_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 =>
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
var descriptorValue = Descriptor.IsMultiValue
@@ -147,7 +141,15 @@ else
_hasPendingChange = pending != null;
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
_multiContext = EffectiveConfigMultiDisplayContext.From(
Descriptor,
Descriptor.GetItems(),
pending?.NewValue as IReadOnlyList<string>,
_hasPendingChange,
_draftValue as IReadOnlyList<string>,
IsEditMode,
IsActive,
FieldKey);
if (!_wasActive && IsActive)
{
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
@@ -162,45 +164,6 @@ else
_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;
@@ -280,7 +243,20 @@ else
_draftValue = values.ToList();
_hasDraftValue = true;
if (Descriptor.IsMultiValue)
ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList<string>, _draftValue as IReadOnlyList<string>);
{
var pending = Session?.Draft.PendingChanges.OfType<PendingOptionChange>().FirstOrDefault(c =>
string.Equals(c.SectionId, Descriptor.SectionId, StringComparison.OrdinalIgnoreCase) &&
string.Equals(c.OptionName, Descriptor.OptionName, StringComparison.OrdinalIgnoreCase));
_multiContext = EffectiveConfigMultiDisplayContext.From(
Descriptor,
Descriptor.GetItems(),
pending?.NewValue as IReadOnlyList<string>,
pending != null,
_draftValue as IReadOnlyList<string>,
IsEditMode,
IsActive,
FieldKey);
}
StateHasChanged();
}
@@ -306,7 +282,7 @@ else
{
_draftValue = null;
_hasDraftValue = false;
Session?.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
Session?.Draft.RevertChange(Descriptor.SectionId, Descriptor.OptionName);
_exitAction = ExitAction.Revert;
if (Ui != null)
await Ui.DeactivateFieldAsync();
@@ -2,21 +2,27 @@
@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="true"
FieldKey="@FieldKey" ReadonlyClickActivatesField="@ReadonlyClickActivatesField" ReadonlyTitleOverride="@ReadonlyTitleOverride" />
<span class="ec-field-value-item" title="@Source?.GetReadOnlyTooltip()">
@(DisplayValue ?? Value)
<OptionStateBadge
AnchorId="@(ReadonlyBadgeAnchorId ?? "")"
Source="@Source"
OptionName="@OptionName"
Value="@Value"
IsInteractive="@(IsEditMode && !string.IsNullOrEmpty(ReadonlyBadgeAnchorId))"
ShowEditableBadge="@(Source?.IsReadOnly != true)"
EditableAsButton="true"
FieldKey="@FieldKey" />
</span>
@code {
/// <summary>Raw config line for badges / popovers.</summary>
[Parameter] public string Value { get; set; } = "";
/// <summary>When set, shown instead of <see cref="Value"/> in the value span (e.g. friendly summary).</summary>
[Parameter] public string? DisplayValue { 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; }
}
@@ -1,11 +1,9 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using Microsoft.AspNetCore.Components.Web
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
@inject IJSRuntime JSRuntime
@inject INotificationService NotificationService
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
@@ -21,47 +19,49 @@
var rowValue = item.Value;
<li class="ec-multi-list-row @(_editingIndex == rowIndex && !itemReadonly ? "ec-multi-list-row-with-controls" : "")" @key="anchorId">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input class="form-control form-control-sm ec-multi-list-input"
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
@bind="_draftValue" @bind:event="oninput" />
<div class="ec-multi-list-row-body">
@if (_editingIndex == rowIndex && !itemReadonly)
{
<div class="ec-multi-list-row-controls">
<div class="ec-multi-list-input-group">
<input class="form-control form-control-sm ec-multi-list-input"
placeholder="@(DnsmasqOptionPlaceholders.Get(OptionName) ?? "Value")"
@bind="_draftValue" @bind:event="oninput" />
</div>
<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>
</div>
<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>
</div>
}
else
{
<span class="ec-field-value-item">@rowValue</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"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</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"
PreventBlurOnMouseDown="true"
OnClick="@(() => RemoveAt(rowIndex))" Class="status-badge-inline" />
}
</span>
}
</div>
</li>
}
}
@@ -13,14 +13,18 @@
}
.ec-multi-list-row {
min-height: 2rem;
}
.ec-multi-list-row-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
min-height: 2rem;
min-width: 0;
}
.ec-multi-list-row.ec-multi-list-row-with-controls {
.ec-multi-list-row.ec-multi-list-row-with-controls .ec-multi-list-row-body {
align-items: flex-start;
}
@@ -47,18 +51,18 @@
width: 100%;
}
.ec-multi-list-row .ec-field-value-item {
.ec-multi-list-row-body .ec-field-value-item {
min-width: 0;
}
.ec-multi-list-row .status-badge-inline {
.ec-multi-list-row-body .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 {
.ec-multi-list-row-body .form-control.ec-multi-list-input {
max-width: none;
flex-shrink: 0;
width: 100%;
@@ -86,7 +90,7 @@
max-width: none;
}
.ec-multi-list-add-item .status-badge-inline {
.ec-multi-list-add-item > .ec-multi-list-row-controls > .status-badge-inline {
margin-left: auto;
}
@@ -2,67 +2,75 @@
@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)
<li class="ec-field ec-field--multi-value @(Context.IsActiveEditor ? "ec-field-editing" : "")">
<div class="ec-field-label-row">
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
@if (Context.IsActiveEditor || Context.ShowEditableBadge)
{
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@(Context.IsActiveEditor ? Context.RowSource : null)" OptionName="@Context.Descriptor.OptionName" Value="@Context.EffectiveItems"
IsInteractive="@Context.IsEditMode" ShowEditableBadge="@Context.ShowEditableBadge" HasPendingChange="@Context.HasPendingOrDraft"
EditableAsButton="true" FieldKey="@Context.FieldKey" IsActiveEditor="@Context.IsActiveEditor"
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
</div>
@if (Context.IsActiveEditor)
{
<EffectiveConfigMultiValueListEditor
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
OptionName="@Label"
SourceItems="@(Context.EffectiveItems)"
OptionName="@Context.Descriptor.OptionName"
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
IsEditMode="@IsEditMode"
AllowAddManagedValue="@IsEditMode"
FieldKey="@FieldKey"
Behavior="@(_multiDesc?.Behavior)"
Validator="@(_multiDesc?.Validator)"
IsEditMode="@Context.IsEditMode"
AllowAddManagedValue="@Context.IsEditMode"
FieldKey="@Context.FieldKey"
Behavior="@(MultiDesc?.Behavior)"
Validator="@(MultiDesc?.Validator)"
OnValuesChanged="@ItemsChanged" />
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason"
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
}
else
{
<div 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)))
<ol class="ec-field-value-list">
@if (Context.EffectiveItems.Count > 0)
{
@foreach (var (item, index) in Context.EffectiveItems.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>
var anchorId = ReadonlyBadgeAnchorIdBase != null
? $"{ReadonlyBadgeAnchorIdBase}-{index}"
: "";
<li>
<EffectiveConfigMultiValueItem
Value="@item.Value"
Source="@item.Source"
OptionName="@Context.Descriptor.OptionName"
IsEditMode="@Context.IsEditMode"
ReadonlyBadgeAnchorId="@anchorId"
FieldKey="@Context.FieldKey" />
</li>
}
</ol>
}
else
{
<span class="text-muted">(none)</span>
<OptionStateBadge OptionName="@Label" ShowEditableBadge="@(!(Descriptor?.IsCapabilityDisabled == true))" EditableAsButton="true"
FieldKey="@FieldKey" IsActiveEditor="false" IsInteractive="true" IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
}
}
else if (Context.EffectiveItems.Count == 0)
{
<li class="ec-multi-list-empty-hint">
<span class="text-muted">(none)</span>
</li>
}
</ol>
</div>
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
[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<IReadOnlyList<string>> ItemsChanged { get; set; }
[Parameter] public EventCallback OnConfirmRequested { get; set; }
[Parameter] public EventCallback OnCancelRequested { get; set; }
[Parameter] public EventCallback OnRevertRequested { get; set; }
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
private EffectiveMultiValueConfigFieldDescriptor? MultiDesc =>
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
}
@@ -1,5 +1,4 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@@ -1,5 +1,6 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Extensions.DependencyInjection
@using DnsmasqWebUI.Extensions.Interop
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@@ -57,14 +58,8 @@
{
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 */ }
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
_moduleLoaded = _jsModule != null;
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
@@ -93,15 +88,21 @@
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded && _jsModule != null)
try
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
}
}
finally
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
protected override async Task OnParametersSetAsync()
@@ -18,7 +18,7 @@
OnClose="@OnClose"
OnContinueEditing="@(EventCallback.Factory.Create(this, HandleContinueEditing))"
OnSave="@HandleSave"
OnRestore="@HandleRestore"
OnRestore="@HandleRestoreBackups"
OnReverted="@HandleReverted"
OnCloseAndEndEdit="@HandleCloseAndEndEdit" />
}
@@ -36,7 +36,7 @@
}
[Parameter] public bool ShowModal { get; set; }
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public EventCallback OnClose { get; set; }
/// <summary>Invoked when the modal reverts a change so the parent can re-run validation (e.g. cross-option evaluator).</summary>
@@ -69,8 +69,8 @@
private async Task HandleSave()
{
if (Session.PendingChanges.Count == 0) return;
if (Session.HasBlockingValidationErrors())
if (Session.Draft.PendingChanges.Count == 0) return;
if (Session.Draft.HasBlockingValidationErrors())
{
NotificationService.ShowError("Fix validation errors before saving.");
return;
@@ -81,9 +81,18 @@
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await Session.ApplyAsync();
var result = await Session.Draft.ApplyAsync();
_saveResult = result;
if (result.Saved)
{
Session.Draft.AcceptAppliedChanges();
if (!result.IsValidateFailed && result.Restarted)
Session.Ui.ExitEditMode();
else
Session.Ui.EnterEditMode();
}
if (result.Saved && result.Restarted)
{
var message = result.UserMessage ?? "Config saved and dnsmasq restarted.";
@@ -107,17 +116,23 @@
await InvokeAsync(StateHasChanged);
}
private async Task HandleRestore(string backupPath)
private async Task HandleRestoreBackups(IReadOnlyList<DnsmasqManagedBackup> backups)
{
if (string.IsNullOrEmpty(backupPath)) return;
if (backups == null || backups.Count == 0) return;
_saveState = SaveUiState.RestoreInProgress;
_restoreResult = null;
await InvokeAsync(StateHasChanged);
var result = await SaveService.RestoreAsync(backupPath);
var result = await SaveService.RestoreAsync(backups);
_restoreResult = result;
if (result.Restored)
{
Session.Draft.AcceptAppliedChanges();
Session.Ui.ExitEditMode();
}
if (result.Restored && result.Restarted)
{
var message = result.UserMessage ?? "Backup restored and dnsmasq restarted.";
@@ -2,7 +2,6 @@
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Helpers.Config
<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">
@@ -25,13 +24,19 @@
<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.
<strong>Changes were saved, but validation failed.</strong> Restart was not attempted. You can restore the backups or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
@if (SaveResult.Backups.Count > 0)
{
<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 class="ec-save-modal-backups mb-2">
<span class="text-muted">Backups created:</span>
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
@foreach (var b in SaveResult.Backups)
{
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
}
</ul>
</p>
}
<div class="ec-save-modal-stderr mb-3">
@@ -42,11 +47,25 @@
}
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))
<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>Save failed.</strong> @SaveResult.UserMessage
@(SaveResult.Backups?.Count > 0 ? " You can restore the backups to revert any partial write, or continue editing." : "")
</div>
</div>
@if (SaveResult.Backups?.Count > 0)
{
<pre class="ec-save-modal-pre border rounded p-2 bg-light small mt-2 mb-0">@SaveResult.RestartStdErr</pre>
<p class="ec-save-modal-backups mb-2">
<span class="text-muted">Backups created:</span>
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
@foreach (var b in SaveResult.Backups)
{
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
}
</ul>
</p>
}
</div>
}
@@ -56,13 +75,19 @@
<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.
<strong>Changes were saved, but the restart command failed.</strong> You can restore the backups or keep editing.
</div>
</div>
@if (SaveResult.BackupPath != null)
@if (SaveResult.Backups.Count > 0)
{
<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 class="ec-save-modal-backups mb-2">
<span class="text-muted">Backups created:</span>
<ul class="small ec-save-modal-backup-list mb-0 mt-1">
@foreach (var b in SaveResult.Backups)
{
<li><code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.TargetPath)</code> → <code class="ec-save-modal-backup-code">@System.IO.Path.GetFileName(b.BackupPath)</code></li>
}
</ul>
</p>
}
<div class="ec-save-modal-stderr mb-3">
@@ -89,6 +114,10 @@
@if (ValidationErrors.Count > 0 || ValidationWarnings.Count > 0)
{
<div class="ec-save-modal-validation mb-3">
<p class="text-muted small mb-2">
Validation uses your effective configuration: values already on disk plus any pending edits listed below.
Reverting pending edits only clears issues that those edits introduced; messages can remain when they reflect saved settings.
</p>
@if (ValidationErrors.Count > 0)
{
<div class="alert alert-danger py-2 mb-2">
@@ -115,11 +144,20 @@
}
</div>
}
<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)
@if (PendingChanges.Count > 0)
{
<p class="ec-save-modal-target mb-3">
<span class="text-muted">Target file:</span> <code class="small">@ManagedFilePath</code>
<p class="text-muted mb-3">These changes will be written to your config. Remove any you don't want to keep.</p>
}
@if (TargetFileNames.Count > 0)
{
<p class="ec-save-modal-targets mb-3">
<span class="text-muted">Targets:</span>
<ul class="small mb-0 mt-1">
@foreach (var name in TargetFileNames)
{
<li><code class="small">@name</code></li>
}
</ul>
</p>
}
@if (PendingChanges.Count == 0)
@@ -133,80 +171,123 @@
<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, change.OptionName)</span>
<span class="text-muted mx-1" aria-hidden="true">→</span>
<span>@FormatValue(change.NewValue, change.OptionName)</span>
</span>
}
</div>
@if (isMultiValue)
{
var oldSeq = oldList ?? Array.Empty<string>();
var newSeq = newList ?? Array.Empty<string>();
var added = newSeq.Except(oldSeq).ToList();
var removed = oldSeq.Except(newSeq).ToList();
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
@if (orderOrDuplicatesChanged)
@if (c is PendingOptionChange change)
{
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)
{
<div class="text-muted">Order or duplicates changed:</div>
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
<div class="text-success">After: @string.Join(", ", newSeq)</div>
}
else
{
@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>
}
<span class="ec-save-modal-diff text-body">
<span class="text-muted text-decoration-line-through">@FormatValue(change.OldValue, change.OptionName)</span>
<span class="text-muted mx-1" aria-hidden="true">→</span>
<span>@FormatValue(change.NewValue, change.OptionName)</span>
</span>
}
</div>
}
@if (change.CurrentSourceFilePath != null)
{
<div class="ec-save-modal-override small text-muted mt-1">Overrides @System.IO.Path.GetFileName(change.CurrentSourceFilePath)</div>
}
@if (isMultiValue)
{
var oldSeq = oldList ?? Array.Empty<string>();
var newSeq = newList ?? Array.Empty<string>();
var added = newSeq.Except(oldSeq).ToList();
var removed = oldSeq.Except(newSeq).ToList();
var orderOrDuplicatesChanged = added.Count == 0 && removed.Count == 0 && !oldSeq.SequenceEqual(newSeq, StringComparer.Ordinal);
<div class="ec-save-modal-multidiff ps-3 mt-1 small">
@if (orderOrDuplicatesChanged)
{
<div class="text-muted">Order or duplicates changed:</div>
<div class="text-muted text-decoration-line-through">Before: @string.Join(", ", oldSeq)</div>
<div class="text-success">After: @string.Join(", ", newSeq)</div>
}
else
{
@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="() => RevertOption(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>
<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>
}
else if (c is PendingManagedHostsChange hostChange)
{
var hostLineDiffs = ManagedHostsPendingChangeDiff.Build(hostChange);
<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 flex-wrap gap-2">
<StatusBadge Kind="StatusBadgeKind.NeutralPill" Text="Managed hosts" Class="ec-save-modal-option me-2" />
<span class="ec-save-modal-diff text-muted">@hostChange.OldEntries.Count → @hostChange.NewEntries.Count entries</span>
</div>
<div class="small text-muted mt-1">@System.IO.Path.GetFileName(hostChange.ManagedHostsFilePath)</div>
@if (hostLineDiffs.Count > 0)
{
<div class="ec-save-modal-managed-hosts-diff small mt-2 ps-2 border-start border-secondary border-opacity-25">
@foreach (var d in hostLineDiffs)
{
@if (d.Kind == ManagedHostsLineDiffKind.Added)
{
<div class="text-success font-monospace text-break">+ @d.AfterLine</div>
}
else if (d.Kind == ManagedHostsLineDiffKind.Removed)
{
<div class="text-muted text-decoration-line-through font-monospace text-break">− @d.BeforeLine</div>
}
else if (d.Kind == ManagedHostsLineDiffKind.Modified)
{
<div class="text-muted text-decoration-line-through font-monospace text-break">~ @d.BeforeLine</div>
<div class="text-success font-monospace text-break ms-1">→ @d.AfterLine</div>
}
else if (d.Kind == ManagedHostsLineDiffKind.OrderOnly)
{
<div class="text-muted fst-italic">Line order changed (same entries).</div>
}
}
</div>
}
</div>
<button type="button" class="btn btn-sm btn-outline-danger flex-shrink-0" @onclick="RevertManagedHosts" title="Revert managed hosts change" aria-label="Revert managed hosts">
<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)
@if ((SaveState == EffectiveConfigSaveFlow.SaveUiState.ReloadFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.ValidateFailed || SaveState == EffectiveConfigSaveFlow.SaveUiState.WriteFailed) && SaveResult?.Backups?.Count > 0)
{
<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-primary" @onclick="RestoreAll"><i class="bi bi-arrow-counterclockwise me-1" aria-hidden="true"></i>Restore backups</button>
<button type="button" class="btn btn-outline-warning" @onclick="ContinueEditing"><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.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.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>
@@ -219,7 +300,7 @@
</div>
@code {
[Parameter] public IReadOnlyList<PendingEffectiveConfigChange> PendingChanges { get; set; } = null!;
[Parameter] public IReadOnlyList<PendingDnsmasqChange> PendingChanges { get; set; } = null!;
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
[Parameter] public IEffectiveConfigEditSession Session { get; set; } = null!;
[Parameter] public EffectiveConfigSaveFlow.SaveUiState SaveState { get; set; }
@@ -228,17 +309,30 @@
[Parameter] public EventCallback OnClose { get; set; }
[Parameter] public EventCallback OnContinueEditing { get; set; }
[Parameter] public EventCallback OnSave { get; set; }
[Parameter] public EventCallback<string> OnRestore { get; set; }
[Parameter] public EventCallback<IReadOnlyList<DnsmasqManagedBackup>> 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 IReadOnlyList<string> TargetFileNames => GetTargetFileNames();
private IReadOnlyList<FieldIssue> ValidationErrors => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
private bool HasBlockingValidationErrors => Session.HasBlockingValidationErrors();
private IReadOnlyList<string> GetTargetFileNames()
{
var list = new List<string>();
if (Status == null) return list;
var hasOptions = PendingChanges.OfType<PendingOptionChange>().Any();
if (hasOptions && !string.IsNullOrEmpty(Status.ManagedFilePath))
list.Add(System.IO.Path.GetFileName(Status.ManagedFilePath));
var hostsChange = PendingChanges.OfType<PendingManagedHostsChange>().FirstOrDefault();
if (hostsChange != null)
list.Add(System.IO.Path.GetFileName(hostsChange.ManagedHostsFilePath));
return list;
}
private IReadOnlyList<FieldIssue> ValidationErrors => Session.Draft.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Error).ToList();
private IReadOnlyList<FieldIssue> ValidationWarnings => Session.Draft.GetValidationSummary().Where(i => i.Severity == FieldIssueSeverity.Warning).ToList();
private bool HasBlockingValidationErrors => Session.Draft.HasBlockingValidationErrors();
private static string GetValidationFailedOutput(EffectiveConfigSaveResult result)
{
@@ -301,9 +395,15 @@
return s;
}
private async Task Revert(PendingEffectiveConfigChange change)
private async Task RevertOption(PendingOptionChange change)
{
Session.RevertChange(change.SectionId, change.OptionName);
Session.Draft.RevertChange(change.SectionId, change.OptionName);
await OnReverted.InvokeAsync();
}
private async Task RevertManagedHosts()
{
Session.Draft.RevertManagedHostsDraft();
await OnReverted.InvokeAsync();
}
@@ -330,9 +430,10 @@
await OnSave.InvokeAsync();
}
private async Task Restore(string backupPath)
private async Task RestoreAll()
{
await OnRestore.InvokeAsync(backupPath);
if (SaveResult == null || SaveResult.Backups.Count == 0) return;
await OnRestore.InvokeAsync(SaveResult.Backups);
}
private async Task CloseAndEndEdit()
@@ -39,6 +39,15 @@
font-size: 0.9375rem;
}
.ec-save-modal-managed-hosts-diff {
font-size: 0.8125rem;
line-height: 1.45;
}
.ec-save-modal-managed-hosts-diff > div + div {
margin-top: 0.2rem;
}
.ec-save-modal-override {
font-size: 0.8125rem;
}
@@ -11,15 +11,8 @@
}
else 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)
var readonlyTitle = Source?.GetReadOnlyTooltip();
if (IsInteractive && Ui != null)
{
<StatusBadge Kind="StatusBadgeKind.ReadOnly" Text="readonly" Clickable="true"
Id="@AnchorId" Title="@readonlyTitle"
@@ -88,10 +81,6 @@ else if (ShowEditableBadge)
[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; }
/// <summary>When true, option is disabled because dnsmasq build does not support it (e.g. no DNSSEC).</summary>
[Parameter] public bool IsCapabilityDisabled { get; set; }
/// <summary>Tooltip shown when <see cref="IsCapabilityDisabled"/> is true (e.g. "This dnsmasq build does not support DNSSEC; option not written.").</summary>
@@ -1,6 +1,5 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@if (Source is { IsReadOnly: true })
{
@@ -1,7 +1,7 @@
@namespace DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Extensions
@using DnsmasqWebUI.Extensions.Interop
@using Microsoft.AspNetCore.Components
@using Microsoft.JSInterop
@using System.Text.Json.Serialization
@@ -77,14 +77,8 @@
{
if (firstRender)
{
try
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
_moduleLoaded = true;
}
catch (InvalidOperationException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
_moduleLoaded = _jsModule != null;
}
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
@@ -122,15 +116,21 @@
public async ValueTask DisposeAsync()
{
if (_scrollListenerAdded && _jsModule != null)
try
{
if (_scrollListenerAdded && _jsModule != null)
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
}
}
finally
{
_scrollListenerAdded = false;
await _jsModule.InvokeVoidAsyncSafe("removeScrollCloseListener");
_dotNetRef?.Dispose();
_dotNetRef = null;
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
private async Task PositionUnderAnchorAsync()
@@ -24,9 +24,10 @@
}
else
{
@foreach (var e in _visibleEntries)
@for (var i = 0; i < _visibleEntries.Count; i++)
{
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
var e = _visibleEntries[i];
<tr class="@(e.IsComment ? "table-secondary" : "")" @key="@(i + ":" + (e.Id ?? ("line:" + e.LineNumber)))">
<td>@e.LineNumber</td>
<td>@e.Address</td>
<td>@string.Join(", ", e.Names)</td>
@@ -43,9 +44,10 @@
}
else
{
@foreach (var e in _visibleEntries)
@for (var i = 0; i < _visibleEntries.Count; i++)
{
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(e.Id ?? ("line:" + e.LineNumber))">
var e = _visibleEntries[i];
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "")" @key="@(i + ":" + (e.Id ?? ("line:" + e.LineNumber)))">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
<div class="table-card-row"><span class="table-card-label">Address</span> @e.Address</div>
@@ -54,15 +54,16 @@ public partial class SettingsModal : IAsyncDisposable
{
if (firstRender)
{
try
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/dialog.js");
if (_jsModule != null)
{
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/dialog.js");
_dotNetRef = DotNetObjectReference.Create(this);
_moduleLoaded = true;
}
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (prerender)"); }
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (circuit disconnected)"); }
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: JS import failed"); }
else
{
Logger.LogDebug("SettingsModal: JS module unavailable (prerender, disconnect, or import failure)");
}
}
if (IsVisible && _moduleLoaded && _jsModule != null)
@@ -163,9 +164,9 @@ public partial class SettingsModal : IAsyncDisposable
/// </summary>
public async ValueTask DisposeAsync()
{
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
_dotNetRef?.Dispose();
_dotNetRef = null;
await _jsModule.DisposeAsyncSafe();
_jsModule = null;
}
}
+192 -105
View File
@@ -2,12 +2,21 @@
@page "/leases"
@rendermode InteractiveServer
@namespace DnsmasqWebUI.Components.Pages
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.Dnsmasq.PageStates
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
@using DnsmasqWebUI.Infrastructure.Serialization.OptionHandlers.Abstractions
@inject IStatusClient StatusClient
@inject IDhcpHostsClient DhcpHostsClient
@inject ILeasesClient LeasesClient
@inject IReloadClient ReloadClient
@inject IClientSettingsService ClientSettingsService
@inject ISettingsModalService SettingsModalService
@inject IEffectiveConfigPageEditor PageEditor
@inject IEffectiveConfigEditSession Session
@inject IStructuredOptionValueHandlerRegistry HandlerRegistry
@inject IOptions<ApplicationOptions> AppOptions
@implements IDisposable
@@ -24,12 +33,38 @@
}
else
{
<section class="page-section">
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
var pageState = DhcpPageState.FromStatus(_status);
@* ---- Static hosts (dhcp-host= in managed file) ---- *@
<section class="page-section">
@if (!pageState.DhcpAvailable)
{
<div class="alert alert-warning">
<h4 class="alert-heading">DHCP not available</h4>
<p class="mb-0">@pageState.DhcpUnavailableReason</p>
</div>
<section class="page-section">
<h2 class="page-section-title">Advanced configuration</h2>
<p class="text-muted">DHCP-specific options are not available, but you can view the effective config below.</p>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
}
else
{
<section class="page-section">
<h2 class="page-section-title">Effective configuration</h2>
<p class="text-muted small">Use the editor below for full dnsmasq flexibility. The workflow UI appears underneath.</p>
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Dhcp" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
@if (pageState.ReadEthersEnabled)
{
<div class="alert alert-info">
<strong>read-ethers</strong> is enabled. Dnsmasq reads <code>/etc/ethers</code> and
treats its entries like <code>dhcp-host</code> mappings for DHCP clients.
</div>
}
@* ---- Static hosts (dhcp-host= in managed file) ---- *@
<section class="page-section">
<h2 class="page-section-title">Static hosts</h2>
@if (!_status.ManagedFilePathExists)
{
@@ -67,16 +102,17 @@ else
</tr>
</thead>
<tbody>
@foreach (var e in _visibleStaticEntries)
@foreach (var row in _visibleStaticHostRows)
{
var e = row.Entry;
var editable = IsEditable(e);
<tr class="@(e.IsComment ? "table-secondary" : "") @(editable ? "" : "table-light")" @key="e.Id">
<tr class="@(e.IsComment ? "table-secondary" : "") @(editable ? "" : "table-light")" @key="@(row.Index + ":" + row.ValueString)">
<td>@e.LineNumber</td>
@if (editable)
{
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" /></td>
<td><input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(row)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" /></td>
<td>
<input class="form-control form-control-sm" list="static-host-lease-datalist" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" style="min-width:140px" placeholder="MAC or pick from leases" />
<input class="form-control form-control-sm" list="static-host-lease-datalist" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" @onchange="() => SyncRowToSession(row)" style="min-width:140px" placeholder="MAC or pick from leases" />
@if (_leases != null && _leases.Count > 0)
{
<datalist id="static-host-lease-datalist">
@@ -87,10 +123,10 @@ else
</datalist>
}
</td>
<td><input class="form-control form-control-sm" @bind="e.Name" placeholder="Name" /></td>
<td><input class="form-control form-control-sm" @bind="e.Address" style="min-width:100px" placeholder="Address" /></td>
<td><input class="form-control form-control-sm" @bind="e.Name" @bind:after="() => SyncRowToSession(row)" placeholder="Name" /></td>
<td><input class="form-control form-control-sm" @bind="e.Address" @bind:after="() => SyncRowToSession(row)" style="min-width:100px" placeholder="Address" /></td>
<td>
<input class="form-control form-control-sm" list="lease-datalist" @bind="e.Lease" @bind:event="oninput" style="min-width:7rem" placeholder="e.g. infinite or seconds" />
<input class="form-control form-control-sm" list="lease-datalist" @bind="e.Lease" @bind:event="oninput" @bind:after="() => SyncRowToSession(row)" style="min-width:7rem" placeholder="e.g. infinite or seconds" />
<datalist id="lease-datalist">
@foreach (var p in LeaseDatalistOptions)
{
@@ -98,10 +134,10 @@ else
}
</datalist>
</td>
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" title="Add ignore flag so dnsmasq will not assign this client a lease" /></td>
<td><input class="form-control form-control-sm" @bind="e.Comment" /></td>
<td><input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(row)" 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" @bind:after="() => SyncRowToSession(row)" /></td>
<td class="text-muted small">—</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>
<td><button class="btn btn-sm btn-danger" @onclick="() => DeleteRow(row)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button></td>
}
else
{
@@ -121,10 +157,11 @@ else
</table>
</div>
<div class="d-md-none table-cards-mobile">
@foreach (var e in _visibleStaticEntries)
@foreach (var row in _visibleStaticHostRows)
{
var e = row.Entry;
var editable = IsEditable(e);
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "") @(editable ? "" : "border-start border-3 border-info")" @key="e.Id">
<div class="card mb-2 @(e.IsComment ? "table-secondary" : "") @(editable ? "" : "border-start border-3 border-info")" @key="@(row.Index + ":" + row.ValueString)">
<div class="card-body py-2">
<div class="table-card-row"><span class="table-card-label">#</span> @e.LineNumber</div>
@if (!string.IsNullOrEmpty(e.SourcePath))
@@ -139,7 +176,7 @@ else
<div class="table-card-control">
@if (editable)
{
<input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(e)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" />
<input type="checkbox" checked="@(!e.IsComment)" @onchange="() => ToggleValid(row)" title="Uncheck to comment out this line in config (dnsmasq will ignore it)" />
}
else
{
@@ -152,7 +189,7 @@ else
@if (editable)
{
<div class="flex-grow-1" style="min-width:0">
<input class="form-control form-control-sm" list="static-host-lease-datalist-mobile" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" placeholder="MAC or pick from leases" style="min-width:0; width:100%" />
<input class="form-control form-control-sm" list="static-host-lease-datalist-mobile" value="@string.Join(", ", e.MacAddresses)" @oninput="ev => OnMacInput(e, ev.Value?.ToString())" @onchange="() => SyncRowToSession(row)" placeholder="MAC or pick from leases" style="min-width:0; width:100%" />
@if (_leases != null && _leases.Count > 0)
{
<datalist id="static-host-lease-datalist-mobile">
@@ -171,17 +208,17 @@ else
</div>
<div class="table-card-row">
<span class="table-card-label">Name</span>
@if (editable) { <input class="form-control form-control-sm" @bind="e.Name" /> } else { <span>@e.Name</span> }
@if (editable) { <input class="form-control form-control-sm" @bind="e.Name" @bind:after="() => SyncRowToSession(row)" /> } else { <span>@e.Name</span> }
</div>
<div class="table-card-row">
<span class="table-card-label">Address</span>
@if (editable) { <input class="form-control form-control-sm" @bind="e.Address" /> } else { <span>@e.Address</span> }
@if (editable) { <input class="form-control form-control-sm" @bind="e.Address" @bind:after="() => SyncRowToSession(row)" /> } else { <span>@e.Address</span> }
</div>
<div class="table-card-row">
<span class="table-card-label">Lease</span>
@if (editable)
{
<input class="form-control form-control-sm" list="lease-datalist-mobile" @bind="e.Lease" @bind:event="oninput" style="min-width:0; width:100%" placeholder="e.g. infinite or seconds" />
<input class="form-control form-control-sm" list="lease-datalist-mobile" @bind="e.Lease" @bind:event="oninput" @bind:after="() => SyncRowToSession(row)" style="min-width:0; width:100%" placeholder="e.g. infinite or seconds" />
<datalist id="lease-datalist-mobile">
@foreach (var p in LeaseDatalistOptions)
{
@@ -197,17 +234,17 @@ else
<div class="table-card-row">
<span class="table-card-label">Ignore</span>
<div class="table-card-control">
@if (editable) { <input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(e)" /> } else { <span>@(e.Ignore ? "Yes" : "No")</span> }
@if (editable) { <input type="checkbox" checked="@e.Ignore" @onchange="() => ToggleIgnore(row)" /> } else { <span>@(e.Ignore ? "Yes" : "No")</span> }
</div>
</div>
<div class="table-card-row">
<span class="table-card-label">Comment</span>
@if (editable) { <input class="form-control form-control-sm" @bind="e.Comment" /> } else { <span>@e.Comment</span> }
@if (editable) { <input class="form-control form-control-sm" @bind="e.Comment" @bind:after="() => SyncRowToSession(row)" /> } else { <span>@e.Comment</span> }
</div>
@if (editable)
{
<div class="table-card-row mt-2">
<button class="btn btn-sm btn-danger" @onclick="() => Delete(e)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRow(row)"><i class="bi bi-trash me-1" aria-hidden="true"></i>Delete</button>
</div>
}
</div>
@@ -217,7 +254,6 @@ else
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<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"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload config</button>
@@ -226,42 +262,50 @@ else
}
</section>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="page-section">
<div class="d-flex align-items-center justify-content-between mb-2">
<h2 class="page-section-title mb-0">Leases</h2>
@if (_status.LeasesPathConfigured)
{
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Leases refresh interval settings" @onclick="OpenLeasesSettings" title="Configure leases refresh interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
}
</div>
@if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath))
{
<p class="text-muted small mb-2">Path: @_status.LeasesPath (read-only).</p>
}
@if (!_status.LeasesPathConfigured)
{
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
}
else if (!_status.LeasesPathExists)
{
<p class="text-warning">Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.</p>
}
else if (_leases == null)
{
<p class="text-warning">Could not read leases file.</p>
}
else if (_leases.Count == 0)
{
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
@* ---- Leases (dhcp-leasefile, read-only) ---- *@
<section class="page-section">
<div class="d-flex align-items-center justify-content-between mb-2">
<h2 class="page-section-title mb-0">Leases</h2>
@if (pageState.LeasesAvailable && _status.LeasesPathConfigured)
{
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Leases refresh interval settings" @onclick="OpenLeasesSettings" title="Configure leases refresh interval">
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
</button>
}
</div>
}
else
{
@if (!pageState.LeasesAvailable)
{
<div class="alert alert-info">
<p class="mb-0">@pageState.LeasesUnavailableReason</p>
</div>
}
else
{
@if (_status.LeasesPathConfigured && !string.IsNullOrEmpty(_status.LeasesPath))
{
<p class="text-muted small mb-2">Path: @_status.LeasesPath (read-only).</p>
}
@if (!_status.LeasesPathConfigured)
{
<p class="text-muted">Leases not configured (no dhcp-leasefile in config).</p>
}
else if (!_status.LeasesPathExists)
{
<p class="text-warning">Leases file not found: @_status.LeasesPath. DHCP may be disabled or path may be wrong.</p>
}
else if (_leases == null)
{
<p class="text-warning">Could not read leases file.</p>
}
else if (_leases.Count == 0)
{
<p class="text-muted">No leases yet. Leases appear here when DHCP clients obtain an address; use Refresh to update.</p>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div>
}
else
{
<div class="row g-2 align-items-center mb-2">
<div class="col-auto">
<label for="lease-filter-keyword" class="form-label mb-0 small text-muted">Search leases</label>
@@ -321,18 +365,21 @@ 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"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div>
}
</section>
<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"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Refresh</button>
</div>
}
}
</section>
}
}
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status;
private List<DhcpHostEntry> _entries = new();
private List<string> _staticValueStrings = new();
private IReadOnlyList<LeaseEntry>? _leases;
private string _filterKeyword = "";
private string _leaseFilterKeyword = "";
@@ -342,6 +389,17 @@ else
private int _leasesRefreshIntervalSeconds = 5;
private string? _message;
private bool _saving;
private IStructuredOptionValueHandler<DhcpHostEntry> _dhcpHostHandler = null!;
private EffectiveConfigFieldRef DhcpHostField => PageEditor.Field(EffectiveConfigSections.SectionDhcp, DnsmasqConfKeys.DhcpHost);
private List<(int Index, string ValueString, DhcpHostEntry Entry)> _staticHostRows =>
Enumerable.Range(0, Math.Min(_entries.Count, _staticValueStrings.Count))
.Select(i => (i, _staticValueStrings[i], _entries[i]))
.ToList();
private IEnumerable<(int Index, string ValueString, DhcpHostEntry Entry)> _visibleStaticHostRows =>
_staticHostRows.Where(x => !x.Entry.IsDeleted && StaticEntryMatchesKeyword(x.Entry, _filterKeyword));
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
_entries.Where(x => !x.IsDeleted && StaticEntryMatchesKeyword(x, _filterKeyword));
@@ -375,6 +433,15 @@ else
{
SetMac(e, value);
TryFillFromLeaseByMac(e, value);
SyncRowForEntry(e);
}
private void SyncRowForEntry(DhcpHostEntry e)
{
var idx = _entries.IndexOf(e);
if (idx < 0 || idx >= _staticValueStrings.Count) return;
var row = (idx, _staticValueStrings[idx], e);
SyncRowToSession(row);
}
private void TryFillFromLeaseByMac(DhcpHostEntry e, string? value)
@@ -406,7 +473,15 @@ else
protected override void OnInitialized()
{
_dhcpHostHandler = HandlerRegistry.GetRequired<DhcpHostEntry>(DnsmasqConfKeys.DhcpHost);
SettingsModalService.SettingsChanged += OnSettingsChanged;
Session.Changed += OnSessionChanged;
}
private void OnSessionChanged()
{
RefreshStaticHostsFromSession();
_ = InvokeAsync(StateHasChanged);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
@@ -416,6 +491,20 @@ else
StateHasChanged();
}
private void RefreshStaticHostsFromSession()
{
if (_status == null) return;
var values = PageEditor.GetEffectiveMultiValues(_status, DhcpHostField).ToList();
_staticValueStrings = values;
_entries = values
.Select((v, i) => _dhcpHostHandler.TryParseValue(v, i + 1, out var parsed) && parsed != null
? parsed
: new DhcpHostEntry { LineNumber = i + 1, IsComment = true })
.ToList();
foreach (var e in _entries)
e.IsEditable = true;
}
private async Task RefreshStatusAsync()
{
try
@@ -435,8 +524,7 @@ else
await ClientSettingsService.LoadSettingsAsync();
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Value;
_status = await StatusClient.GetStatusAsync();
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
RefreshStaticHostsFromSession();
await LoadLeases(showLoading: true);
if (_status != null && _status.LeasesPathConfigured && _leasesRefreshTimer == null)
StartLeasesRefreshTimer();
@@ -495,7 +583,7 @@ else
private void Add()
{
var nextAddress = GetNextAvailableAddress();
_entries.Add(new DhcpHostEntry
var entry = new DhcpHostEntry
{
Id = "new:" + Guid.NewGuid().ToString("N"),
LineNumber = 0,
@@ -504,7 +592,13 @@ else
Name = "*",
Address = nextAddress,
IsEditable = true
});
};
if (_status != null)
{
PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler.SerializeValue(entry));
PageEditor.Activate(DhcpHostField);
}
RefreshStaticHostsFromSession();
StateHasChanged();
}
@@ -552,21 +646,35 @@ else
return $"{(u >> 24) & 0xff}.{(u >> 16) & 0xff}.{(u >> 8) & 0xff}.{u & 0xff}";
}
private void Delete(DhcpHostEntry e)
private void DeleteRow((int Index, string ValueString, DhcpHostEntry Entry) row)
{
e.IsDeleted = true;
if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return;
var newList = _staticValueStrings.ToList();
newList.RemoveAt(row.Index);
PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList);
RefreshStaticHostsFromSession();
StateHasChanged();
}
private void ToggleValid(DhcpHostEntry e)
private void ToggleValid((int Index, string ValueString, DhcpHostEntry Entry) row)
{
e.IsComment = !e.IsComment;
StateHasChanged();
row.Entry.IsComment = !row.Entry.IsComment;
SyncRowToSession(row);
}
private void ToggleIgnore(DhcpHostEntry e)
private void ToggleIgnore((int Index, string ValueString, DhcpHostEntry Entry) row)
{
e.Ignore = !e.Ignore;
row.Entry.Ignore = !row.Entry.Ignore;
SyncRowToSession(row);
}
private void SyncRowToSession((int Index, string ValueString, DhcpHostEntry Entry) row)
{
if (_status == null || row.Index < 0 || row.Index >= _staticValueStrings.Count) return;
var newList = _staticValueStrings.ToList();
newList[row.Index] = _dhcpHostHandler!.SerializeValue(row.Entry);
PageEditor.ReplaceMultiValues(_status, DhcpHostField, newList);
RefreshStaticHostsFromSession();
StateHasChanged();
}
@@ -588,7 +696,8 @@ else
private void AddStaticFromLeaseCore(LeaseEntry lease, bool ignore)
{
_entries.Add(new DhcpHostEntry
if (_status == null) return;
var entry = new DhcpHostEntry
{
Id = "new:" + Guid.NewGuid().ToString("N"),
LineNumber = 0,
@@ -601,36 +710,13 @@ else
Lease = "infinite",
Extra = new List<string>(),
Comment = null
});
};
PageEditor.AppendMultiValue(_status, DhcpHostField, _dhcpHostHandler!.SerializeValue(entry));
PageEditor.Activate(DhcpHostField);
RefreshStaticHostsFromSession();
StateHasChanged();
}
private async Task Save()
{
_saving = true;
_error = null;
_message = null;
try
{
var result = await DhcpHostsClient.SaveDhcpHostsAsync(_entries);
if (result.Reload.Success)
_message = "Saved and dnsmasq reloaded.";
else if (!string.IsNullOrEmpty(result.Reload.StdErr))
_error = "Saved but dnsmasq reload failed: " + result.Reload.StdErr;
else
_message = "Saved.";
await Load();
}
catch (Exception ex)
{
_error = ex.Message;
}
finally
{
_saving = false;
}
}
private async Task Reload()
{
_saving = true;
@@ -656,6 +742,7 @@ else
public void Dispose()
{
Session.Changed -= OnSessionChanged;
SettingsModalService.SettingsChanged -= OnSettingsChanged;
_leasesRefreshTimer?.Dispose();
_leasesRefreshTimer = null;
@@ -1,7 +1,11 @@
@page "/dns-records"
@rendermode InteractiveServer
@namespace DnsmasqWebUI.Components.Pages
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
@inject IStatusClient StatusClient
@inject IEffectiveConfigPageEditor PageEditor
@inject IOptions<ApplicationOptions> AppOptions
<PageTitle>DNS records | @AppTitle</PageTitle>
@@ -20,6 +24,30 @@ else
<section class="page-section">
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.DnsRecords" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
<section class="page-section">
<h2 class="page-section-title">Quick add</h2>
<div class="d-flex flex-wrap gap-3 align-items-end">
<div class="d-flex flex-wrap gap-2 align-items-end">
<input class="form-control form-control-sm" style="width:12rem" placeholder="Alias (e.g. www.example.com)" @bind="_cnameAlias" />
<input class="form-control form-control-sm" style="width:12rem" placeholder="Target (e.g. example.com)" @bind="_cnameTarget" />
<button type="button" class="btn btn-sm btn-primary" @onclick="AddCname">Add CNAME</button>
</div>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input class="form-control form-control-sm" style="width:10rem" placeholder="Host name" @bind="_hostRecordName" />
<input class="form-control form-control-sm" style="width:10rem" placeholder="IP address" @bind="_hostRecordIp" />
<button type="button" class="btn btn-sm btn-primary" @onclick="AddHostRecord">Add A/AAAA record</button>
</div>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input class="form-control form-control-sm" style="width:10rem" placeholder="Name" @bind="_txtName" />
<input class="form-control form-control-sm" style="width:12rem" placeholder="Text value" @bind="_txtValue" />
<button type="button" class="btn btn-sm btn-primary" @onclick="AddTxtRecord">Add TXT record</button>
</div>
</div>
@if (_recordError != null)
{
<p class="text-danger small mt-2 mb-0">@_recordError</p>
}
</section>
<div class="page-chips" role="group" aria-label="Record type">
<span class="page-chip active">All</span>
<span class="page-chip">CNAME</span>
@@ -34,6 +62,80 @@ else
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status;
private string _cnameAlias = "";
private string _cnameTarget = "";
private string _hostRecordName = "";
private string _hostRecordIp = "";
private string _txtName = "";
private string _txtValue = "";
private string? _recordError;
private static EffectiveConfigFieldRef CnameField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.Cname);
private static EffectiveConfigFieldRef HostRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.HostRecord);
private static EffectiveConfigFieldRef TxtRecordField(string sectionId) => EffectiveConfigFieldRef.For(sectionId, DnsmasqConfKeys.TxtRecord);
private void AddCname()
{
_recordError = null;
if (string.IsNullOrWhiteSpace(_cnameAlias) || string.IsNullOrWhiteSpace(_cnameTarget))
{
PageEditor.SetFieldIssues(CnameField(EffectiveConfigSections.SectionDnsRecords), [new FieldIssue(CnameField(EffectiveConfigSections.SectionDnsRecords).FieldKey, "Alias and target are required.", FieldIssueSeverity.Error, null)]);
_recordError = "Alias and target are required.";
return;
}
var field = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.Cname);
PageEditor.ClearFieldIssues(field);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var next = current.Append($"{_cnameAlias.Trim()},{_cnameTarget.Trim()}").ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_cnameAlias = "";
_cnameTarget = "";
StateHasChanged();
}
private void AddHostRecord()
{
_recordError = null;
var hostField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.HostRecord);
if (string.IsNullOrWhiteSpace(_hostRecordName) || string.IsNullOrWhiteSpace(_hostRecordIp))
{
PageEditor.SetFieldIssues(hostField, [new FieldIssue(hostField.FieldKey, "Host name and IP are required.", FieldIssueSeverity.Error, null)]);
_recordError = "Host name and IP are required.";
return;
}
var field = hostField;
PageEditor.ClearFieldIssues(field);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var next = current.Append($"{_hostRecordName.Trim()},{_hostRecordIp.Trim()}").ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_hostRecordName = "";
_hostRecordIp = "";
StateHasChanged();
}
private void AddTxtRecord()
{
_recordError = null;
var txtField = PageEditor.Field(EffectiveConfigSections.SectionDnsRecords, DnsmasqConfKeys.TxtRecord);
if (string.IsNullOrWhiteSpace(_txtName))
{
PageEditor.SetFieldIssues(txtField, [new FieldIssue(txtField.FieldKey, "Name is required.", FieldIssueSeverity.Error, null)]);
_recordError = "Name is required.";
return;
}
var field = txtField;
PageEditor.ClearFieldIssues(field);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var value = string.IsNullOrWhiteSpace(_txtValue) ? _txtName.Trim() : $"{_txtName.Trim()},\"{_txtValue.Trim()}\"";
var next = current.Append(value).ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_txtName = "";
_txtValue = "";
StateHasChanged();
}
protected override async Task OnInitializedAsync()
{
@@ -1,7 +1,11 @@
@page "/filters"
@rendermode InteractiveServer
@namespace DnsmasqWebUI.Components.Pages
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
@inject IStatusClient StatusClient
@inject IEffectiveConfigPageEditor PageEditor
@inject IOptions<ApplicationOptions> AppOptions
<PageTitle>Filters | @AppTitle</PageTitle>
@@ -20,6 +24,28 @@ else
<section class="page-section">
<EffectiveConfigSection Status="_status" Context="EffectiveConfigContext.Filters" ShowSearchBox="true" OnSaveCompleted="RefreshStatusAsync" />
</section>
<section class="page-section">
<h2 class="page-section-title">Quick add</h2>
<div class="d-flex flex-wrap gap-3 align-items-end">
<div class="d-flex flex-wrap gap-2 align-items-end">
<input class="form-control form-control-sm" style="width:14rem" placeholder="Domain to block (e.g. ads.example.com)" @bind="_blockDomain" />
<button type="button" class="btn btn-sm btn-primary" @onclick="BlockDomain">Block domain</button>
</div>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input class="form-control form-control-sm" style="width:12rem" placeholder="Domain (e.g. internal.local)" @bind="_splitDomain" />
<input class="form-control form-control-sm" style="width:10rem" placeholder="Upstream (e.g. 10.0.0.1)" @bind="_splitUpstream" />
<button type="button" class="btn btn-sm btn-primary" @onclick="AddSplitDnsRoute">Split DNS route</button>
</div>
<div class="d-flex flex-wrap gap-2 align-items-end">
<input class="form-control form-control-sm" style="width:14rem" placeholder="Domain (local only, e.g. lan)" @bind="_localDomain" />
<button type="button" class="btn btn-sm btn-primary" @onclick="AddLocalOnlyDomain">Local-only domain</button>
</div>
</div>
@if (_filterError != null)
{
<p class="text-danger small mt-2 mb-0">@_filterError</p>
}
</section>
<div class="page-chips" role="group" aria-label="Filter policy category">
<span class="page-chip active">All</span>
<span class="page-chip">Blocking / sinkhole</span>
@@ -33,6 +59,70 @@ else
@code {
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
private DnsmasqServiceStatus? _status;
private string _blockDomain = "";
private string _splitDomain = "";
private string _splitUpstream = "";
private string _localDomain = "";
private string? _filterError;
private void BlockDomain()
{
_filterError = null;
var domain = _blockDomain?.Trim() ?? "";
if (string.IsNullOrEmpty(domain))
{
_filterError = "Enter a domain to block.";
return;
}
var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Address);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var rule = $"/{domain}/#";
var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_blockDomain = "";
StateHasChanged();
}
private void AddSplitDnsRoute()
{
_filterError = null;
var domain = _splitDomain?.Trim() ?? "";
var upstream = _splitUpstream?.Trim() ?? "";
if (string.IsNullOrEmpty(domain) || string.IsNullOrEmpty(upstream))
{
_filterError = "Domain and upstream are required.";
return;
}
var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Server);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var rule = $"/{domain}/{upstream}";
var next = current.Append(rule).ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_splitDomain = "";
_splitUpstream = "";
StateHasChanged();
}
private void AddLocalOnlyDomain()
{
_filterError = null;
var domain = _localDomain?.Trim() ?? "";
if (string.IsNullOrEmpty(domain))
{
_filterError = "Enter a domain.";
return;
}
var field = PageEditor.Field(EffectiveConfigSections.SectionResolver, DnsmasqConfKeys.Local);
var current = PageEditor.GetEffectiveMultiValues(_status!, field);
var rule = $"/{domain}/";
var next = current.Contains(rule, StringComparer.OrdinalIgnoreCase) ? current.ToList() : current.Append(rule).ToList();
PageEditor.ReplaceMultiValues(_status!, field, next);
PageEditor.Activate(field);
_localDomain = "";
StateHasChanged();
}
protected override async Task OnInitializedAsync()
{
File diff suppressed because it is too large Load Diff
@@ -28,6 +28,10 @@
vertical-align: middle;
}
.hosts-page {
display: block;
}
@media (max-width: 640.98px) {
::deep .table {
font-size: 0.875rem;
@@ -38,3 +42,512 @@
padding: 0.5rem;
}
}
/* Toolbar */
.hosts-controls-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0.875rem 1rem;
background: linear-gradient(180deg, #fbfcfe 0%, #f6f8fb 100%);
border: 1px solid #e7ebf0;
border-radius: 14px;
box-shadow: 0 10px 24px -18px rgba(15, 23, 42, 0.35);
}
/* Search + file filter on one row; search grows, select keeps a stable width. */
.hosts-toolbar-fields {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
gap: 0.5rem;
min-width: 0;
}
.hosts-toolbar-search {
flex: 1 1 12rem;
min-width: 0;
width: auto;
max-width: none;
}
/* Flex slot for file filter; inner GroupedSelectDropdown fills width (matches pre-extract layout). */
.hosts-file-filter-wrap {
position: relative;
flex: 0 1 22rem;
min-width: min(13.5rem, 100%);
max-width: min(22rem, 100%);
width: auto;
}
.hosts-file-filter-wrap ::deep .grouped-select {
display: block;
width: 100%;
min-width: 0;
max-width: none;
}
.hosts-file-filter-wrap ::deep .hosts-file-filter-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
width: 100%;
padding: 0.4rem 0.55rem;
margin: 0;
font-size: 0.8125rem;
line-height: 1.35;
text-align: left;
color: #1e293b;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
}
.hosts-file-filter-wrap ::deep .hosts-file-filter-option:hover {
background: rgba(15, 23, 42, 0.06);
}
.hosts-file-filter-wrap ::deep .hosts-file-filter-option--active {
background: rgba(13, 110, 253, 0.12);
font-weight: 600;
}
.hosts-file-filter-wrap ::deep .hosts-file-filter-option--active:hover {
background: rgba(13, 110, 253, 0.18);
}
.hosts-file-filter-wrap ::deep .hosts-file-filter-option-main {
flex: 1 1 0%;
min-width: 0;
}
/* Hosts-specific menu rows show the full path, but truncate from the left so the filename stays visible. */
.hosts-file-filter-wrap ::deep .hosts-file-filter-option-path {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
unicode-bidi: plaintext;
}
.hosts-file-filter-wrap ::deep .hosts-file-filter-count {
flex-shrink: 0;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.3;
color: #475569;
background: rgba(15, 23, 42, 0.06);
border-radius: 999px;
}
.hosts-file-filter-wrap ::deep .hosts-file-filter-option--active .hosts-file-filter-count {
color: #0d47a1;
background: rgba(13, 110, 253, 0.2);
}
.hosts-toolbar-select {
min-width: 9rem;
max-width: 14rem;
}
.hosts-summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: 0.75rem;
}
.hosts-summary-stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0.85rem 0.95rem;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #e7ebf0;
border-radius: 14px;
box-shadow: 0 8px 20px -18px rgba(15, 23, 42, 0.45);
}
.hosts-summary-stat__value {
font-size: 1.15rem;
font-weight: 700;
line-height: 1.1;
color: #0f172a;
}
.hosts-summary-stat__label {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #64748b;
}
/* Group header row (colspan, not rowspan) */
::deep .hosts-table .hosts-comment-col,
::deep .hosts-table .hosts-comment-col-cell {
max-width: 14rem;
width: 18%;
}
::deep .hosts-table .hosts-comment-empty {
opacity: 0.45;
}
::deep .hosts-table .hosts-row-inline-comment {
white-space: normal;
word-break: break-word;
}
::deep .hosts-table .hosts-effective-names-th {
white-space: nowrap;
}
::deep .hosts-table .hosts-effective-names-help {
vertical-align: middle;
text-decoration: none;
line-height: 1;
}
::deep .hosts-table .hosts-effective-names-help:hover,
::deep .hosts-table .hosts-effective-names-help:focus-visible {
color: var(--bs-primary) !important;
}
::deep .hosts-table .hosts-group-header-cell {
background: linear-gradient(180deg, #f8fafc 0%, #f6f8fb 100%);
border-top: 1px solid #dbe3ec;
border-bottom: 1px solid #dbe3ec;
font-weight: 600;
vertical-align: middle;
padding-top: 0.6rem;
padding-bottom: 0.6rem;
}
.hosts-group-header__content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
width: 100%;
}
.hosts-group-header__title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
color: #0f172a;
min-width: 0;
}
.hosts-group-header__path-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 0.75rem;
width: 100%;
min-width: 0;
}
/* Fixed column for actions avoids path/subtitle reflow when buttons show or hide. */
.hosts-group-header__path-row--with-managed-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--hosts-managed-actions-col, 13.5rem);
align-items: center;
column-gap: 0.5rem;
}
.hosts-group-header__path-row--with-managed-actions .hosts-group-header__path {
flex: unset;
min-width: 0;
}
.hosts-group-header__path-row--with-managed-actions .hosts-group-header__managed-actions {
margin-left: 0;
justify-self: end;
}
.hosts-group-header__path {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1 1 auto;
font-weight: 500;
}
.hosts-group-header__managed-actions {
flex: 0 0 auto;
align-self: center;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0.28rem;
min-height: 1.65rem;
max-width: 100%;
padding: 0.18rem;
border: 1px solid rgba(148, 163, 184, 0.26);
border-radius: 999px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.94) 100%);
box-shadow: 0 8px 18px -16px rgba(15, 23, 42, 0.4);
backdrop-filter: blur(10px);
transition: opacity 160ms ease, border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
overflow: hidden;
}
.hosts-group-header__managed-actions--visible {
opacity: 1;
}
.hosts-group-header__managed-actions--hidden {
opacity: 0;
border-color: transparent;
box-shadow: none;
background: transparent;
pointer-events: none;
}
.hosts-managed-action-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
min-height: 1.28rem;
padding: 0.22rem 0.52rem;
border: 1px solid transparent;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.01em;
line-height: 1;
box-shadow: none;
transition: transform 120ms ease, box-shadow 120ms ease, background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.hosts-managed-action-btn i {
font-size: 0.78rem;
}
.hosts-managed-action-btn:hover,
.hosts-managed-action-btn:focus-visible {
transform: translateY(-0.5px);
box-shadow: 0 6px 14px -12px rgba(15, 23, 42, 0.5);
}
.hosts-managed-action-btn:active {
transform: translateY(0);
box-shadow: none;
}
.hosts-managed-action-btn--save {
color: #fff;
background: linear-gradient(180deg, #0d6efd 0%, #0a58ca 100%);
border-color: rgba(10, 88, 202, 0.8);
}
.hosts-managed-action-btn--save:hover,
.hosts-managed-action-btn--save:focus-visible {
color: #fff;
background: linear-gradient(180deg, #0b5ed7 0%, #084298 100%);
border-color: rgba(8, 66, 152, 0.92);
}
.hosts-managed-action-btn--cancel {
color: #334155;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-color: rgba(148, 163, 184, 0.38);
}
.hosts-managed-action-btn--cancel:hover,
.hosts-managed-action-btn--cancel:focus-visible {
color: #0f172a;
background: linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
border-color: rgba(100, 116, 139, 0.42);
}
.hosts-managed-action-btn--edit {
color: #0f172a;
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-color: rgba(148, 163, 184, 0.4);
}
.hosts-managed-action-btn--edit:hover,
.hosts-managed-action-btn--edit:focus-visible {
color: #0f172a;
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
border-color: rgba(100, 116, 139, 0.42);
}
.hosts-managed-action-btn--remove {
color: #b42318;
background: linear-gradient(180deg, rgba(254, 242, 242, 0.98) 0%, rgba(254, 226, 226, 0.98) 100%);
border-color: rgba(248, 113, 113, 0.38);
}
.hosts-managed-action-btn--remove:hover,
.hosts-managed-action-btn--remove:focus-visible {
color: #991b1b;
background: linear-gradient(180deg, rgba(254, 226, 226, 0.98) 0%, rgba(254, 202, 202, 0.98) 100%);
border-color: rgba(239, 68, 68, 0.42);
}
.hosts-group-header__badge {
flex-shrink: 0;
}
.hosts-group-header__subtitle {
display: inline-block;
line-height: 1.2;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: min(38rem, 50vw);
}
::deep .hosts-row--inactive:not(.hosts-row--selected) {
opacity: 0.72;
}
::deep .hosts-row--editable {
cursor: pointer;
}
::deep .hosts-row--editable:hover {
background-color: rgba(13, 110, 253, 0.06);
}
/* One accent bar on the row (first cell only); same fill on all cells so borders stay even */
::deep .hosts-table .hosts-row--selected td {
background-color: rgba(var(--bs-primary-rgb), 0.07);
}
::deep .hosts-table .hosts-row--selected td:first-child {
box-shadow: inset 3px 0 0 var(--bs-primary);
}
::deep .hosts-table .hosts-row--selected.hosts-row--editable:hover td {
background-color: rgba(var(--bs-primary-rgb), 0.11);
}
::deep .hosts-table .hosts-row--empty td.hosts-empty-group-message {
background: #fafbfc;
font-style: italic;
}
::deep .hosts-table .hosts-row--add-slot td {
padding: 0;
vertical-align: middle;
background: #fafbfc;
}
::deep .hosts-table .hosts-row--add-slot--after-rows td {
border-top: 1px dashed #dbe3ec;
}
.hosts-add-slot-btn {
font-weight: 500;
line-height: 1.35;
background: transparent;
box-shadow: none;
}
.hosts-add-slot-btn:hover,
.hosts-add-slot-btn:focus-visible {
background: rgba(13, 110, 253, 0.08);
color: var(--bs-primary);
}
.hosts-add-slot-btn:focus-visible {
outline: 0;
box-shadow: inset 0 0 0 2px rgba(13, 110, 253, 0.35);
z-index: 1;
position: relative;
}
.hosts-add-slot__action {
font-weight: 600;
color: #475569;
}
.hosts-add-slot-btn:hover .hosts-add-slot__action,
.hosts-add-slot-btn:focus-visible .hosts-add-slot__action {
color: var(--bs-primary);
}
@media (max-width: 640.98px) {
.hosts-group-header__path-row--with-managed-actions {
--hosts-managed-actions-col: 12rem;
}
.hosts-group-header__managed-actions {
gap: 0.22rem;
min-height: 1.55rem;
padding: 0.15rem;
}
.hosts-managed-action-btn {
padding: 0.2rem 0.45rem;
font-size: 0.72rem;
}
.hosts-managed-action-btn i {
font-size: 0.74rem;
}
}
@media (prefers-reduced-motion: reduce) {
.hosts-group-header__managed-actions,
.hosts-managed-action-btn {
transition: none;
animation: none;
}
}
::deep .hosts-row code {
font-size: 0.93em;
color: #0f172a;
background: #f8fafc;
padding: 0.15rem 0.35rem;
border-radius: 6px;
}
::deep .hosts-row--editing td {
background: #f0f7ff;
}
@media (max-width: 991.98px) {
.hosts-controls-bar {
padding: 0.75rem;
border-radius: 12px;
}
.hosts-toolbar-fields {
flex-wrap: wrap;
}
.hosts-toolbar-search,
.hosts-file-filter-wrap,
.hosts-toolbar-select {
min-width: 0;
max-width: none;
flex: 1 1 100%;
width: 100%;
}
.hosts-group-header__subtitle {
max-width: 100%;
margin-left: 0;
}
}
@@ -0,0 +1,187 @@
@typeparam TValue
@using DnsmasqWebUI.Models.Ui
<div class="grouped-select @(_open ? "grouped-select--open" : null)"
@onkeydown="OnKeyDown">
@if (_open)
{
<div class="grouped-select__backdrop" @onclick="Close" aria-hidden="true"></div>
}
@{
var triggerSummaryProvider = Builder as IGroupedSelectTriggerSummary<TValue>;
var triggerSummary = triggerSummaryProvider?.GetTriggerSummary(Value);
var summaryText = triggerSummary?.AccessibleFullText ?? Builder.GetSummaryText(Value);
var triggerKindClass = triggerSummary?.Kind switch
{
GroupedSelectTriggerSummaryKind.SingleSource => "grouped-select__trigger--filtered",
GroupedSelectTriggerSummaryKind.UnknownSource => "grouped-select__trigger--unknown",
_ => null
};
}
<button type="button"
id="@TriggerId"
class="grouped-select__trigger @TriggerClass @triggerKindClass"
title="@($"{Builder.TriggerTitle} — {summaryText}")"
aria-label="@($"{Builder.TriggerAriaLabel}. Current: {summaryText}")"
aria-haspopup="listbox"
aria-expanded="@(_open ? "true" : "false")"
aria-controls="@MenuId"
@onclick="Toggle"
@onclick:stopPropagation="true">
@if (triggerSummary is not null)
{
<span class="grouped-select__trigger-label grouped-select__trigger-label--rich">
@if (!string.IsNullOrEmpty(triggerSummary.CategoryPrefix))
{
<span class="grouped-select__trigger-prefix">@triggerSummary.CategoryPrefix</span>
<span class="grouped-select__trigger-sep" aria-hidden="true">·</span>
}
<span class="grouped-select__trigger-primary text-truncate">@triggerSummary.PrimaryLabel</span>
@if (!string.IsNullOrEmpty(triggerSummary.SecondaryMeta))
{
@if (triggerSummary.Kind == GroupedSelectTriggerSummaryKind.SingleSource)
{
<span class="grouped-select__trigger-count" aria-hidden="true">@triggerSummary.SecondaryMeta</span>
}
else
{
<span class="grouped-select__trigger-meta" aria-hidden="true">· @triggerSummary.SecondaryMeta</span>
}
}
</span>
}
else
{
<span class="grouped-select__trigger-label text-truncate">@Builder.GetSummaryText(Value)</span>
}
<i class="bi bi-chevron-down grouped-select__chevron flex-shrink-0" aria-hidden="true"></i>
</button>
@if (_open)
{
var model = Builder.Build();
<div id="@MenuId"
class="grouped-select__panel shadow"
role="listbox"
aria-label="@Builder.MenuAriaLabel"
@onclick:stopPropagation="true">
@if (model.AllOption is not null)
{
var allSelected = IsSelected(model.AllOption.Value);
@if (AllOptionTemplate is not null)
{
@AllOptionTemplate(new GroupedSelectAllOptionContext<TValue>(
model.AllOption,
allSelected,
EventCallback.Factory.Create(this, () => Select(model.AllOption!.Value))))
}
else
{
var option = model.AllOption;
<button type="button"
class="grouped-select__option grouped-select__option--all @(allSelected ? "grouped-select__option--active" : null)"
role="option"
aria-selected="@(allSelected ? "true" : "false")"
@onclick="async () => await Select(option.Value)">
<span class="grouped-select__option-main">@option.Label</span>
@if (option.Count is not null)
{
<span class="grouped-select__count">@option.Count</span>
}
</button>
}
}
@foreach (var grp in model.Sections.OrderBy(s => s.Order))
{
<div class="grouped-select__group grouped-select__group--@(grp.Kind ?? "other")">
@if (GroupHeaderTemplate is not null)
{
@GroupHeaderTemplate(grp)
}
else
{
<div class="grouped-select__group-header" role="presentation">@grp.Label</div>
}
@foreach (var option in grp.Options)
{
<div @key="option.Value">
@if (OptionTemplate is not null)
{
var active = IsSelected(option.Value);
@OptionTemplate(new GroupedSelectOptionSelectContext<TValue>(
option,
active,
EventCallback.Factory.Create(this, () => Select(option.Value))))
}
else
{
var active = IsSelected(option.Value);
<button type="button"
class="grouped-select__option @(active ? "grouped-select__option--active" : null)"
role="option"
aria-selected="@(active ? "true" : "false")"
title="@(option.Title ?? option.Label)"
@onclick="async () => await Select(option.Value)">
<span class="grouped-select__option-main"
title="@(option.Title ?? option.Label)">@option.Label</span>
@if (option.Count is not null)
{
<span class="grouped-select__count">@option.Count</span>
}
</button>
}
</div>
}
</div>
}
</div>
}
</div>
@code {
[Parameter, EditorRequired] public IGroupedSelectBuilder<TValue> Builder { get; set; } = default!;
[Parameter] public TValue? Value { get; set; }
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }
[Parameter] public string TriggerId { get; set; } = "grouped-select-trigger";
[Parameter] public string MenuId { get; set; } = "grouped-select-menu";
[Parameter] public string TriggerClass { get; set; } = "";
[Parameter] public RenderFragment<GroupedSelectSection<TValue>>? GroupHeaderTemplate { get; set; }
[Parameter] public RenderFragment<GroupedSelectOptionSelectContext<TValue>>? OptionTemplate { get; set; }
[Parameter] public RenderFragment<GroupedSelectAllOptionContext<TValue>>? AllOptionTemplate { get; set; }
private bool _open;
public bool CloseMenu()
{
if (!_open)
return false;
_open = false;
StateHasChanged();
return true;
}
private void Toggle() => _open = !_open;
private void Close()
{
_open = false;
}
private async Task Select(TValue? value)
{
_open = false;
await ValueChanged.InvokeAsync(value);
}
private bool IsSelected(TValue? candidate) =>
EqualityComparer<TValue?>.Default.Equals(Value, candidate);
private Task OnKeyDown(KeyboardEventArgs e)
{
if (_open && e.Key == "Escape")
{
_open = false;
StateHasChanged();
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,259 @@
/* Sizing is owned by the parent (e.g. Hosts .hosts-file-filter-wrap); default fills container width. */
.grouped-select {
position: relative;
width: 100%;
box-sizing: border-box;
}
.grouped-select--open {
z-index: 1080;
}
.grouped-select__backdrop {
position: fixed;
inset: 0;
z-index: 1070;
background: transparent;
}
.grouped-select__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.45rem;
width: 100%;
min-height: calc(1.5em + 0.75rem);
padding: 0.375rem 0.65rem;
font-size: 0.8125rem;
line-height: 1.4;
text-align: left;
color: var(--bs-body-color);
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid #d8dee9;
border-radius: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
transition: border-color 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
.grouped-select__trigger:hover {
border-color: #b8c4d4;
background: linear-gradient(180deg, #ffffff 0%, #f1f5f9 100%);
}
.grouped-select__trigger--filtered {
border-color: #93b9f0;
background: linear-gradient(180deg, #f6f9ff 0%, #eef4fc 100%);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.grouped-select__trigger--filtered:hover {
border-color: #7aa8e8;
}
.grouped-select__trigger--unknown {
border-color: #e9c46c;
background: linear-gradient(180deg, #fffdf8 0%, #fff6e5 100%);
}
.grouped-select--open .grouped-select__trigger {
border-color: #86b7fe;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.85);
}
.grouped-select__trigger-label {
min-width: 0;
flex: 1 1 auto;
}
.grouped-select__trigger-label--rich {
display: flex;
align-items: center;
gap: 0.28rem;
min-width: 0;
}
.grouped-select__trigger-prefix {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
color: #64748b;
}
.grouped-select__trigger--filtered .grouped-select__trigger-prefix {
color: #2563eb;
}
.grouped-select__trigger-sep {
flex-shrink: 0;
opacity: 0.45;
font-weight: 500;
color: #64748b;
}
.grouped-select__trigger-primary {
flex: 1 1 auto;
min-width: 0;
font-weight: 600;
color: #0f172a;
}
.grouped-select__trigger-meta {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
font-variant-numeric: tabular-nums;
}
.grouped-select__trigger-count {
flex-shrink: 0;
min-width: 1.35rem;
padding: 0.12rem 0.4rem;
font-size: 0.68rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.2;
text-align: center;
color: #1e40af;
background: rgba(37, 99, 235, 0.12);
border-radius: 999px;
}
.grouped-select__chevron {
font-size: 0.8rem;
opacity: 0.55;
transition: transform 0.15s ease, opacity 0.12s ease;
}
.grouped-select__trigger:hover .grouped-select__chevron {
opacity: 0.85;
}
.grouped-select--open .grouped-select__chevron {
transform: rotate(180deg);
}
.grouped-select__panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
left: auto;
min-width: 100%;
width: max-content;
max-width: min(26rem, 92vw);
max-height: min(22rem, 55vh);
overflow-x: hidden;
overflow-y: auto;
z-index: 1085;
padding: 0.35rem;
background: var(--bs-body-bg, #fff);
border: 1px solid var(--bs-border-color, #e2e8f0);
border-radius: 10px;
}
.grouped-select__option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
width: 100%;
padding: 0.4rem 0.55rem;
margin: 0;
font-size: 0.8125rem;
line-height: 1.35;
text-align: left;
color: #1e293b;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
}
.grouped-select__option:hover {
background: rgba(15, 23, 42, 0.06);
}
.grouped-select__option--active {
background: rgba(13, 110, 253, 0.12);
font-weight: 600;
}
.grouped-select__option--active:hover {
background: rgba(13, 110, 253, 0.18);
}
.grouped-select__option--all {
margin-bottom: 0.25rem;
border: 1px solid #e7ebf0;
background: linear-gradient(180deg, #fbfcfe 0%, #f4f6f9 100%);
}
.grouped-select__option-main {
flex: 1 1 0%;
min-width: 0;
}
.grouped-select__count {
flex-shrink: 0;
padding: 0.1rem 0.45rem;
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1.3;
color: #475569;
background: rgba(15, 23, 42, 0.06);
border-radius: 999px;
}
.grouped-select__option--active .grouped-select__count {
color: #0d47a1;
background: rgba(13, 110, 253, 0.2);
}
.grouped-select__group {
margin-top: 0.35rem;
padding: 0.3rem 0.35rem 0.45rem;
border-radius: 8px;
border: 1px solid rgba(15, 23, 42, 0.08);
}
.grouped-select__group:first-of-type {
margin-top: 0;
}
.grouped-select__group--managed {
background: linear-gradient(180deg, #e8f4fd 0%, #dceef9 100%);
border-color: #b8d4ee;
}
.grouped-select__group--system {
background: linear-gradient(180deg, #f1f5f9 0%, #e8edf3 100%);
border-color: #cbd5e1;
}
.grouped-select__group--addn {
background: linear-gradient(180deg, #fff8e6 0%, #fff0cc 100%);
border-color: #e9d48a;
}
.grouped-select__group--hostsdir {
background: linear-gradient(180deg, #ecfdf3 0%, #dff6e8 100%);
border-color: #a7e3c0;
}
.grouped-select__group--other {
background: #f8fafc;
}
.grouped-select__group-header {
padding: 0.2rem 0.35rem 0.45rem;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #334155;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
margin-bottom: 0.15rem;
}
+5 -1
View File
@@ -18,6 +18,11 @@
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Leases.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Dnsmasq.Reload.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Editing.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Rendering.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Metadata
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Readonly
@using DnsmasqWebUI.Infrastructure.Services.Logs.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.UI.Settings.Abstractions
@using DnsmasqWebUI.Infrastructure.Services.Updates.Abstractions
@@ -31,7 +36,6 @@
@using DnsmasqWebUI.Components.EffectiveConfig
@using DnsmasqWebUI.Components.Hosts
@using DnsmasqWebUI.Infrastructure
@using DnsmasqWebUI.Infrastructure.Helpers.Config
@using DnsmasqWebUI.Models
@using DnsmasqWebUI.Models.Dnsmasq
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
+1 -29
View File
@@ -1,7 +1,5 @@
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;
using Microsoft.Extensions.Logging;
@@ -12,13 +10,11 @@ namespace DnsmasqWebUI.Controllers;
public class DhcpController : ControllerBase
{
private readonly IDnsmasqConfigService _configService;
private readonly IReloadService _reloadService;
private readonly ILogger<DhcpController> _logger;
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService, ILogger<DhcpController> logger)
public DhcpController(IDnsmasqConfigService configService, ILogger<DhcpController> logger)
{
_configService = configService;
_reloadService = reloadService;
_logger = logger;
}
@@ -37,28 +33,4 @@ public class DhcpController : ControllerBase
return StatusCode(500, new { error = ex.Message });
}
}
[HttpPut("hosts")]
public async Task<ActionResult<SaveWithReloadResult>> PutHosts([FromBody] List<DhcpHostEntry>? entries, CancellationToken ct)
{
if (entries == null)
return BadRequest(new { error = "Body required" });
try
{
await _configService.WriteDhcpHostsAsync(entries, ct);
var reload = await _reloadService.ReloadAsync(ct);
_logger.LogInformation("DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
return Ok(new SaveWithReloadResult(true, reload));
}
catch (ArgumentException ex)
{
_logger.LogWarning(ex, "DHCP hosts put validation failed");
return BadRequest(new { error = ex.Message });
}
catch (Exception ex)
{
_logger.LogError(ex, "Put DHCP hosts failed");
return StatusCode(500, new { error = ex.Message });
}
}
}

Some files were not shown because too many files have changed in this diff Show More