diff --git a/README.md b/README.md index bbb6212..9c9eb5e 100644 --- a/README.md +++ b/README.md @@ -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= 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 @@ -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. diff --git a/agents.md b/agents.md index b0c632f..0606c0c 100644 --- a/agents.md +++ b/agents.md @@ -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= 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: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 3443f1c..b29cf4c 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -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). # diff --git a/scripts/prepare-test-mount.sh b/scripts/prepare-test-mount.sh index 2993837..9b6b838 100755 --- a/scripts/prepare-test-mount.sh +++ b/scripts/prepare-test-mount.sh @@ -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)." diff --git a/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/EffectiveConfigMultiDisplayContextTests.cs b/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/EffectiveConfigMultiDisplayContextTests.cs new file mode 100644 index 0000000..ed734fe --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Models/EffectiveConfig/EffectiveConfigMultiDisplayContextTests.cs @@ -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 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 + { + 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 + { + new("a.example", ro), + new("b.example", null) + }; + var d = DomainDescriptor(baseline); + var pending = new List { "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 { new("a.example", null) }; + var d = DomainDescriptor(baseline); + var pending = new List { "b.example" }; + var draft = new List { "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 + { + 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 + { + 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 + { + 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 { 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 baseline, + IReadOnlyList? pending, + IReadOnlyList? draft) => + EffectiveConfigMultiDisplayContext.From( + d, baseline, pending, pending != null, draft, + isEditMode: false, isActive: false, fieldKey: "k"); +} diff --git a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEffectiveNamesDomainRulesTests.cs b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEffectiveNamesDomainRulesTests.cs index b3a57f2..a2a53e3 100644 --- a/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEffectiveNamesDomainRulesTests.cs +++ b/src/DnsmasqWebUI.Tests/Models/Hosts/HostsEffectiveNamesDomainRulesTests.cs @@ -52,4 +52,42 @@ public class HostsEffectiveNamesDomainRulesTests 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); + } } diff --git a/src/DnsmasqWebUI.Tests/Serialization/Parsers/EffectiveConfig/DomainRuleFieldsTests.cs b/src/DnsmasqWebUI.Tests/Serialization/Parsers/EffectiveConfig/DomainRuleFieldsTests.cs new file mode 100644 index 0000000..0e8936c --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Serialization/Parsers/EffectiveConfig/DomainRuleFieldsTests.cs @@ -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()); + } +} diff --git a/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistryMultiTests.cs b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistryMultiTests.cs new file mode 100644 index 0000000..20d5960 --- /dev/null +++ b/src/DnsmasqWebUI.Tests/Services/EffectiveConfig/EffectiveConfigRenderFragmentRegistryMultiTests.cs @@ -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>.Empty); + + Assert.NotNull(fragment); + } + + [Fact] + public void DomainMultiValueDisplay_InheritsMultiBase() + { + Assert.Equal(typeof(EffectiveConfigMultiValueDisplayBase), typeof(DomainMultiValueDisplay).BaseType); + } + + /// Custom domain UI must still use RegisterSemanticMultiDescriptor wiring so per-item validation is not dropped. + [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(), null)); + Assert.NotNull(descriptor.Validator.ValidateItem("not a valid domain name!", Array.Empty(), null)); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor.cs index 7f5d446..7706d7f 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsFiltersModal.razor.cs @@ -52,15 +52,12 @@ public partial class AppLogsFiltersModal : IAsyncDisposable { if (firstRender) { - try + _jsModule = await JSRuntime.InvokeAsyncSafe("import", default, "./js/dialog.js"); + if (_jsModule != null) { - _jsModule = await JSRuntime.InvokeAsync("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; } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsSection.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsSection.razor.cs index 4032232..bbacdd2 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/AppLogs/AppLogsSection.razor.cs @@ -62,13 +62,12 @@ public partial class AppLogsSection : IAsyncDisposable _hubConnection.On("AppLogsUpdate", OnAppLogsUpdate); - try + _logsJs = await JSRuntime.InvokeAsyncSafe("import", default, "./js/logs.js"); + if (_logsJs == null) { - _logsJs = await JSRuntime.InvokeAsync("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() diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor.cs b/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor.cs index 442c62e..c87c822 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor.cs +++ b/src/DnsmasqWebUI/Components/Dnsmasq/Logs/LogsSection.razor.cs @@ -63,13 +63,12 @@ public partial class LogsSection : IAsyncDisposable _hubConnection.On("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate); - try + _logsJs = await JSRuntime.InvokeAsyncSafe("import", default, "./js/logs.js"); + if (_logsJs == null) { - _logsJs = await JSRuntime.InvokeAsync("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() diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor index bc9e02a..67f2384 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/CopyableCodeLine.razor @@ -1,4 +1,5 @@ @namespace DnsmasqWebUI.Components.EffectiveConfig +@using DnsmasqWebUI.Extensions.Interop @inject IJSRuntime JSRuntime
@@ -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 }); } } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigMultiValueDisplayBase.cs b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigMultiValueDisplayBase.cs new file mode 100644 index 0000000..7d33cd7 --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Common/EffectiveConfigMultiValueDisplayBase.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Components; +using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig; + +namespace DnsmasqWebUI.Components.EffectiveConfig; + +/// +/// Base for custom effective-config multi-value displays; mirrors for singles. +/// +public abstract class EffectiveConfigMultiValueDisplayBase : ComponentBase +{ + [Parameter] + public EffectiveConfigMultiDisplayContext Context { get; set; } = null!; + + [Parameter] + public EventCallback> OnValuesChanged { get; set; } + + protected IReadOnlyList EffectiveItems => Context.EffectiveItems; + + protected IReadOnlyList EffectiveValues => Context.EffectiveValues; + + protected Task NotifyValuesChanged(IReadOnlyList values) => OnValuesChanged.InvokeAsync(values); +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor index d8ec304..28408dd 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Composition/EffectiveConfigSection.razor @@ -163,15 +163,19 @@ { if (Session.Draft.PendingChanges.Count > 0) { - var module = await JSRuntime.InvokeAsync("import", "./js/dialog.js"); + var module = await JSRuntime.InvokeAsyncSafe("import", default, "./js/dialog.js"); + if (module == null) + return; + try { - var confirmed = await module.InvokeAsync("confirmMessage", "Discard unsaved changes and exit edit mode?"); - if (!confirmed) return; + var confirmed = await module.InvokeAsyncSafe("confirmMessage", "Discard unsaved changes and exit edit mode?"); + if (confirmed != true) + return; } finally { - await module.DisposeAsync(); + await module.DisposeAsyncSafe(); } } Session.Draft.DiscardAllDraft(); diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor new file mode 100644 index 0000000..f6c3e3f --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor @@ -0,0 +1,493 @@ +@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 + +
+ @* ul: avoid
    markers (scoped CSS does not inherit .ec-multi-list-add-item list-style rules from the generic multi editor). *@ +
      + @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; + +
    • + @if (_editingIndex == rowIndex && !itemReadonly) + { +
      +

      Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with expand-hosts) how simple names are qualified in DNS.

      + +
      + + +
      + @if (_draftUseRaw) + { + + } + else + { +
      + + +
      Use # to take the search domain from resolv.conf.
      +
      +
      + + +
      + @if (_draftScopeSelect == "range") + { +
      + + +
      Single IP, CIDR, or start,end pair per dnsmasq docs.
      +
      +
      + + +
      + } + else if (_draftScopeSelect == "interface") + { +
      + + +
      + } + else if (_draftScopeSelect == "unconditional") + { +
      + + +
      + } + } +
      + + +
      +
      + } + else + { +
      + @DisplayLine(rowValue) + @if (!string.Equals(DomainRuleFields.FormatSummary(rowValue), rowValue, StringComparison.Ordinal)) + { + @rowValue + } + + + @if (!itemReadonly) + { + + } + +
      + } +
    • + } + } + else if (!AllowAddManagedValue) + { +
    • + (none) +
    • + } + + @if (AllowAddManagedValue) + { +
    • + @if (_addingNew) + { +
      +

      Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with expand-hosts) how simple names are qualified in DNS.

      + +
      + + +
      + @if (_draftUseRaw) + { + + } + else + { +
      + + +
      +
      + + +
      + @if (_draftScopeSelect == "range") + { +
      + + +
      +
      + + +
      + } + else if (_draftScopeSelect == "interface") + { +
      + + +
      + } + else if (_draftScopeSelect == "unconditional") + { +
      + + +
      + } + } +
      + + +
      +
      + } + else + { + + } +
    • + } +
    +
+ +@code { + [Parameter] public IReadOnlyList? 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> 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 _displayItems = new(); + + private bool _draftUseRaw; + private string _draftRawLine = ""; + private string _draftDomain = ""; + private string _draftScopeSelect = "unconditional"; + private string _draftMiddle = ""; + private bool _draftAddLocal; + + private IReadOnlyList _currentValues => _displayItems.Select(x => x.Value).ToList(); + + protected override void OnParametersSet() + { + _displayItems = (SourceItems ?? Array.Empty()).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 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(); + } +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor.css b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor.css new file mode 100644 index 0000000..0afbd81 --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainEffectiveConfigListEditor.razor.css @@ -0,0 +1,79 @@ +.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); +} + +.ec-domain-rule-editor .ec-field-value-list, +.ec-domain-rule-list { + list-style: none; + margin-bottom: 0; + padding-left: 0; +} + +.ec-domain-rule-list > li { + list-style: none; +} + +.ec-domain-rule-intro { + font-size: 0.8rem; + color: var(--bs-secondary-color, #6c757d); + margin-bottom: 0.35rem; + line-height: 1.35; +} + +.ec-domain-rule-row { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 2rem; +} + +.ec-domain-rule-row.ec-domain-rule-row-editing { + 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-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; +} + +.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; +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainMultiValueDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainMultiValueDisplay.razor new file mode 100644 index 0000000..3b3f63f --- /dev/null +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/CustomDisplays/DomainMultiValueDisplay.razor @@ -0,0 +1,88 @@ +@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) +{ + +} +else +{ +
+
    + @if (Context.EffectiveItems.Count > 0) + { + @foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index))) + { + var anchorId = $"{_anchorBase}-{index}"; +
  1. + +
  2. + } + } + @if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0) + { +
  3. + +
  4. + } + else if (Context.EffectiveItems.Count == 0) + { +
  5. + @if (Context.ShowEditableBadge) + { + (none) + + } + else + { + (none) + } +
  6. + } +
+
+} + +@code { + private readonly string _anchorBase = "ec-domain-" + Guid.NewGuid().ToString("N")[..8]; + + private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => + Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor; +} diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor index 190756c..da50663 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigFieldDisplay.razor @@ -45,30 +45,32 @@ else if (Descriptor.IsMultiValue) { var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create>(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. *@ -
  • + @* 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; +
  • @Descriptor.OptionName: - + - @customMulti(Descriptor) + @customMulti(_multiContext) - + @if (customMultiActiveEditor) + { + + }
  • } - else + else if (_multiContext is not null) { - @* Multi-value state is computed in OnParametersSet and in HandleMultiValueChanged; we only read cached fields here. *@ - } } @@ -122,14 +124,7 @@ else private bool _wasActive; private ExitAction _exitAction = ExitAction.None; - /// Cached for multi-value: items to pass to the row (with sources preserved for draft merge). - private IReadOnlyList? _multiValueItems; - /// Cached for multi-value: true when there is an uncommitted draft list. - private bool _multiValueHasDraft; - /// Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode). - private ConfigValueSource? _multiValueRowSource; - /// Cached for multi-value: whether row should show ok/cancel (session pending or local draft). - private bool _multiValueHasPendingOrDraft; + private EffectiveConfigMultiDisplayContext? _multiContext; protected override void OnParametersSet() { @@ -146,7 +141,15 @@ else _hasPendingChange = pending != null; _effectiveValue = pending != null ? pending.NewValue : descriptorValue; if (Descriptor.IsMultiValue) - ComputeMultiValueState(Descriptor.GetItems(), pending?.NewValue as IReadOnlyList, _draftValue as IReadOnlyList); + _multiContext = EffectiveConfigMultiDisplayContext.From( + Descriptor, + Descriptor.GetItems(), + pending?.NewValue as IReadOnlyList, + _hasPendingChange, + _draftValue as IReadOnlyList, + 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). @@ -161,45 +164,6 @@ else _wasActive = IsActive; } - private void ComputeMultiValueState(IReadOnlyList? descriptorItems, IReadOnlyList? pendingList, IReadOnlyList? draftList) - { - var descriptorValues = descriptorItems?.Select(i => i.Value).ToList() ?? new List(); - _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 BuildItemsWithSource(IReadOnlyList? descriptorItems, IReadOnlyList values) - { - var pool = descriptorItems?.ToList() ?? new List(); - var list = new List(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; @@ -279,7 +243,20 @@ else _draftValue = values.ToList(); _hasDraftValue = true; if (Descriptor.IsMultiValue) - ComputeMultiValueState(Descriptor.GetItems(), _effectiveValue as IReadOnlyList, _draftValue as IReadOnlyList); + { + var pending = Session?.Draft.PendingChanges.OfType().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, + pending != null, + _draftValue as IReadOnlyList, + IsEditMode, + IsActive, + FieldKey); + } StateHasChanged(); } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor index c7dae81..25ef899 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueItem.razor @@ -3,7 +3,7 @@ @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig - @Value + @(DisplayValue ?? Value) @code { + /// Raw config line for badges / popovers. [Parameter] public string Value { get; set; } = ""; + /// When set, shown instead of in the value span (e.g. friendly 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; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor index ad9eff4..e10878f 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueListEditor.razor @@ -4,7 +4,6 @@ @using Microsoft.AspNetCore.Components.Web @using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation @using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions -@inject IJSRuntime JSRuntime @inject INotificationService NotificationService
    diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor index ed85836..fac8ec6 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Fields/EffectiveConfigMultiValueRow.razor @@ -3,32 +3,32 @@ @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
  • - @Label: - @if (IsActiveEditor) + @Context.Descriptor.OptionName: + @if (Context.IsActiveEditor) { - } else {
      - @if (Items?.Count > 0) + @if (Context.EffectiveItems.Count > 0) { - @foreach (var (item, index) in Items!.Select((item, index) => (item, index))) + @foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index))) { var anchorId = ReadonlyBadgeAnchorIdBase != null ? $"{ReadonlyBadgeAnchorIdBase}-{index}" @@ -37,44 +37,44 @@ + FieldKey="@Context.FieldKey" /> } } - @if (ShowEditableBadge && (Items?.Count ?? 0) > 0) + @if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0) {
    1. + HasPendingChange="@Context.HasPendingOrDraft" + IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" + CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
    2. } - else if ((Items?.Count ?? 0) == 0) + else if (Context.EffectiveItems.Count == 0) {
    3. - @if (ShowEditableBadge) + @if (Context.ShowEditableBadge) { (none) + HasPendingChange="@Context.HasPendingOrDraft" + IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" + CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" /> } else { @@ -88,23 +88,16 @@
    4. @code { - [Parameter] public string Label { get; set; } = ""; - [Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; } - [Parameter] public IReadOnlyList? Items { get; set; } - [Parameter] public EventCallback> 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> 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; } diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/ConfigOptionHelpModal.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/ConfigOptionHelpModal.razor index 991dccc..44be411 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/ConfigOptionHelpModal.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Modals/ConfigOptionHelpModal.razor @@ -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("import", "./js/config-option-help.js"); - _moduleLoaded = true; - } - catch (InvalidOperationException) { /* prerender */ } - catch (JSDisconnectedException) { /* circuit disconnected */ } - catch (JSException) { /* import failed */ } + _jsModule = await JSRuntime.InvokeAsyncSafe("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() diff --git a/src/DnsmasqWebUI/Components/EffectiveConfig/Popovers/ReadonlyEditPopover.razor b/src/DnsmasqWebUI/Components/EffectiveConfig/Popovers/ReadonlyEditPopover.razor index f0cac58..b52250d 100644 --- a/src/DnsmasqWebUI/Components/EffectiveConfig/Popovers/ReadonlyEditPopover.razor +++ b/src/DnsmasqWebUI/Components/EffectiveConfig/Popovers/ReadonlyEditPopover.razor @@ -1,6 +1,7 @@ @namespace DnsmasqWebUI.Components.EffectiveConfig @using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Extensions +@using DnsmasqWebUI.Extensions.Interop @using Microsoft.AspNetCore.Components @using Microsoft.JSInterop @using System.Text.Json.Serialization @@ -76,14 +77,8 @@ { if (firstRender) { - try - { - _jsModule = await JSRuntime.InvokeAsync("import", "./js/config-option-help.js"); - _moduleLoaded = true; - } - catch (InvalidOperationException) { } - catch (JSDisconnectedException) { } - catch (JSException) { } + _jsModule = await JSRuntime.InvokeAsyncSafe("import", default, "./js/config-option-help.js"); + _moduleLoaded = _jsModule != null; } if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null) @@ -121,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() diff --git a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs index 17135b3..7b42661 100644 --- a/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs +++ b/src/DnsmasqWebUI/Components/Modals/SettingsModal.razor.cs @@ -54,15 +54,16 @@ public partial class SettingsModal : IAsyncDisposable { if (firstRender) { - try + _jsModule = await JSRuntime.InvokeAsyncSafe("import", default, "./js/dialog.js"); + if (_jsModule != null) { - _jsModule = await JSRuntime.InvokeAsync("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 /// public async ValueTask DisposeAsync() { - await _jsModule.DisposeAsyncSafe(); - _jsModule = null; _dotNetRef?.Dispose(); _dotNetRef = null; + await _jsModule.DisposeAsyncSafe(); + _jsModule = null; } } diff --git a/src/DnsmasqWebUI/Components/Pages/Hosts.razor b/src/DnsmasqWebUI/Components/Pages/Hosts.razor index 7a7d577..6b9e7d0 100644 --- a/src/DnsmasqWebUI/Components/Pages/Hosts.razor +++ b/src/DnsmasqWebUI/Components/Pages/Hosts.razor @@ -10,9 +10,10 @@ @using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig @using DnsmasqWebUI.Components.EffectiveConfig @using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions +@using DnsmasqWebUI.Extensions.Interop @using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop -@implements IDisposable +@implements IAsyncDisposable @inject IStatusClient StatusClient @inject IHostsClient HostsClient @inject IEffectiveConfigEditSession Session @@ -89,7 +90,8 @@ else

      No rows match the current search.

      }
      -
      +
      @@ -153,33 +155,37 @@ else @if (IsManagedEditableGroup(group)) { -
      +
      @if (IsInlineEditing) { } - else + else if (ManagedSelectionCount == 1) { + } + + @if (!IsInlineEditing && ManagedSelectionCount > 0) + { + } -
      }
      @@ -217,6 +223,7 @@ else @bind="_editAddress" @bind:event="oninput" @onkeydown="OnEditFieldKeyDown" + @onkeydown:stopPropagation="true" autofocus /> @if (showEffectiveNames) { - + } } @@ -255,7 +267,7 @@ else @if (showEffectiveNames) { -
      Hosts entries grouped for scanning and editing
      @@ -226,11 +233,15 @@ else aria-invalid="@(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "true" : null)" @bind="_editNames" @bind:event="oninput" - @onkeydown="OnEditFieldKeyDown" /> + @onkeydown="OnEditFieldKeyDown" + @onkeydown:stopPropagation="true" /> @string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, effectiveDomainValues, _editAddress)) + @string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, effectiveDomainValues, _editAddress)) + + @onkeydown="OnEditFieldKeyDown" + @onkeydown:stopPropagation="true" />
      @string.Join(", ", row.Names) + @if (effectiveNames.Count != row.Names.Count) { @string.Join(", ", effectiveNames) @@ -312,6 +324,27 @@ else } + @if (_showRemoveHostsModal) + { + + + } + @if (_showInvalidHostEditModal) {