mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e86a2b6bd9 | ||
|
|
002e9a5db5 | ||
|
|
02a840799b | ||
|
|
2e713f0e3c | ||
|
|
9627926a6a | ||
|
|
eae29de045 | ||
|
|
f0b4ea97a4 | ||
|
|
1bbab1c8c3 | ||
|
|
5702844cea | ||
|
|
84c58ccec7 | ||
|
|
a3dc5ac45c | ||
|
|
31c0f65c86 | ||
|
|
c7e7844ce7 | ||
|
|
333566554c | ||
|
|
1b3a09996f | ||
|
|
3ca935e551 | ||
|
|
e33402467f |
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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).
|
||||
#
|
||||
|
||||
@@ -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
-1
@@ -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.
|
||||
+149
@@ -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);
|
||||
}
|
||||
}
|
||||
+106
@@ -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());
|
||||
}
|
||||
}
|
||||
+126
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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.
|
||||
+4
-2
@@ -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]
|
||||
+3
-3
@@ -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)
|
||||
+4
-2
@@ -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.
|
||||
+3
-3
@@ -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>
|
||||
+3
-3
@@ -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>
|
||||
+3
-3
@@ -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>
|
||||
+5
-3
@@ -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>
|
||||
+3
-3
@@ -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>
|
||||
+3
-3
@@ -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>
|
||||
+3
-3
@@ -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.
|
||||
+23
-29
@@ -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));
|
||||
}
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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>
|
||||
+3
-3
@@ -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>
|
||||
+3
-3
@@ -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>
|
||||
+2
-2
@@ -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
|
||||
{
|
||||
+4
-2
@@ -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
|
||||
+15
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
+51
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
+173
-2
@@ -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()
|
||||
{
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+226
@@ -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);
|
||||
}
|
||||
+46
@@ -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));
|
||||
}
|
||||
}
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+82
@@ -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"));
|
||||
}
|
||||
}
|
||||
+3
-5
@@ -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)
|
||||
+2
-3
@@ -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]
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
+119
@@ -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;
|
||||
}
|
||||
}
|
||||
+216
@@ -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;
|
||||
}
|
||||
}
|
||||
+14
-10
@@ -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
-1
@@ -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).
|
||||
+50
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
-4
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
+22
@@ -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);
|
||||
}
|
||||
+1
-1
@@ -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() ?? "";
|
||||
}
|
||||
|
||||
+57
-25
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+34
-7
@@ -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;
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+579
@@ -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.";
|
||||
}
|
||||
+150
@@ -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;
|
||||
}
|
||||
+56
@@ -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
|
||||
|
||||
|
||||
+47
-71
@@ -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();
|
||||
|
||||
+14
-8
@@ -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; }
|
||||
}
|
||||
|
||||
+41
-41
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+48
-40
@@ -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.";
|
||||
|
||||
+184
-83
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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
Reference in New Issue
Block a user