mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Hosts UX, effective names, JS interop, domain UI
- Hosts: delete confirmation modal, shift range select, keyboard shortcuts, effective-name suffix tooltips, conditional modern header actions - Align EffectiveNames on rows with scoped domain= + pending preview; unified hosts API uses full DomainValues from config snapshot - Harden Blazor JS interop (safe helpers, IAsyncDisposable, disposal order) - Effective config: domain multi-value display/editor, parsers, tests - Misc: docs/testdata/docker script updates
This commit is contained in:
@@ -399,7 +399,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
|||||||
|
|
||||||
### scripts/prepare-test-mount.sh
|
### 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] [--]`
|
**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`. |
|
| `--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`. |
|
| `--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. |
|
| `--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. |
|
| `--prepare-only` | Only prepare the mount; do not run docker compose. |
|
||||||
| `--build` | Pass `--build` to docker compose (rebuild images). Default: use existing images. |
|
| `--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. |
|
| `--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:**
|
**Examples:**
|
||||||
|
|
||||||
```bash
|
```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
|
./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
|
./scripts/prepare-test-mount.sh --build
|
||||||
|
|
||||||
# Force a fresh image build with no Docker build cache
|
# 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.:
|
# 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
|
# 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)
|
# Fresh managed config/hosts after a rebuild (fixtures still synced; leases kept)
|
||||||
./scripts/prepare-test-mount.sh
|
./scripts/prepare-test-mount.sh --reset-managed --build
|
||||||
|
|
||||||
# Stop the harness
|
# Stop the harness
|
||||||
./scripts/prepare-test-mount.sh --stop
|
./scripts/prepare-test-mount.sh --stop
|
||||||
@@ -534,7 +535,7 @@ Use `appsettings.json` or launch settings to set `Dnsmasq__MainConfigPath` and o
|
|||||||
```bash
|
```bash
|
||||||
./scripts/prepare-test-mount.sh
|
./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.
|
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:
|
This will:
|
||||||
|
|
||||||
1. Sync `testdata/` to `testdata-mount/` (preserving existing contents unless you pass `--clear`)
|
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. Remove any `*dnsmasq-webui*.conf` in the mount so dnsmasq starts clean
|
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 needed
|
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 --build`
|
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`
|
- **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`
|
- **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`
|
- **Use distro package:** `./scripts/prepare-test-mount.sh --dnsmasq-version distro --build`
|
||||||
- **Prepare mount only (no start):** `./scripts/prepare-test-mount.sh --prepare-only`
|
- **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]`
|
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.
|
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`)
|
- `--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.
|
- `--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`.
|
- `--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.
|
- `--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:
|
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.
|
# 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.
|
# 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).
|
# 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).
|
# 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.
|
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
|
||||||
#
|
#
|
||||||
# What this script does:
|
# What this script does:
|
||||||
# Start (default): sync source -> mount (preserving existing unless --clear), clean up previous test data,
|
# Start (default): sync source -> mount (preserving existing unless --clear); optional --reset-managed
|
||||||
# then docker compose up -d [--build] [--force-recreate].
|
# removes app-written files in the mount. Then docker compose up -d [--build] [--force-recreate].
|
||||||
# --stop: docker compose down (stop and remove containers/networks).
|
# --stop: docker compose down (stop and remove containers/networks).
|
||||||
# --tidy: docker compose down, then clear the mount directory for a clean next run.
|
# --tidy: docker compose down, then clear the mount directory for a clean next run.
|
||||||
set -e
|
set -e
|
||||||
@@ -21,6 +21,7 @@ BUILD=false
|
|||||||
NO_CACHE_BUILD=false
|
NO_CACHE_BUILD=false
|
||||||
RECREATE=false
|
RECREATE=false
|
||||||
CLEAR=false
|
CLEAR=false
|
||||||
|
RESET_MANAGED=false
|
||||||
STOP=false
|
STOP=false
|
||||||
TIDY=false
|
TIDY=false
|
||||||
MINIMAL_CONF=false
|
MINIMAL_CONF=false
|
||||||
@@ -33,7 +34,8 @@ usage() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "Steps (when starting):"
|
echo "Steps (when starting):"
|
||||||
echo " 1. Optionally clear mount dir (only with --clear), then sync source -> mount."
|
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 " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
|
||||||
echo ""
|
echo ""
|
||||||
echo "Path options:"
|
echo "Path options:"
|
||||||
@@ -47,6 +49,8 @@ usage() {
|
|||||||
echo "Mount behaviour:"
|
echo "Mount behaviour:"
|
||||||
echo " (default) Preserve mount dir; sync source over existing contents."
|
echo " (default) Preserve mount dir; sync source over existing contents."
|
||||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
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 ""
|
||||||
echo "Compose behaviour:"
|
echo "Compose behaviour:"
|
||||||
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
|
echo " --minimal-conf Use minimal dnsmasq config (dnsmasq-test-minimal.conf) so effective"
|
||||||
@@ -86,6 +90,9 @@ usage() {
|
|||||||
echo " $0 --clear"
|
echo " $0 --clear"
|
||||||
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
||||||
echo ""
|
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 " $0 --prepare-only"
|
||||||
echo " Only sync testdata -> testdata-mount; no containers."
|
echo " Only sync testdata -> testdata-mount; no containers."
|
||||||
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
|
echo " Then run: docker compose -f $COMPOSE_FILE up -d [--build]"
|
||||||
@@ -161,6 +168,10 @@ while [ $# -gt 0 ]; do
|
|||||||
CLEAR=true
|
CLEAR=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--reset-managed)
|
||||||
|
RESET_MANAGED=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--prepare-only)
|
--prepare-only)
|
||||||
PREPARE_ONLY=true
|
PREPARE_ONLY=true
|
||||||
shift
|
shift
|
||||||
@@ -267,15 +278,22 @@ if [ "$CLEAR" = true ]; then
|
|||||||
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
|
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
|
||||||
fi
|
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
|
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
|
else
|
||||||
cp -r "$SOURCE_DIR/." "$MOUNT_DIR/"
|
(cd "$SOURCE_DIR" && tar cf - --exclude=leases --exclude=zz-dnsmasq-webui.conf --exclude=zz-dnsmasq-webui.hosts .) | (cd "$MOUNT_DIR" && tar xf -)
|
||||||
rm -f "$MOUNT_DIR/leases"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up previous test data (e.g. managed config) so the harness starts clean (app will create zz-dnsmasq-webui.conf on startup).
|
if [ "$RESET_MANAGED" = true ]; then
|
||||||
find "$MOUNT_DIR" -name '*dnsmasq-webui*.conf' -type f -delete 2>/dev/null || true
|
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)."
|
echo "Mount directory ready: $MOUNT_DIR (source: $SOURCE_DIR)."
|
||||||
|
|
||||||
|
|||||||
+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");
|
||||||
|
}
|
||||||
@@ -52,4 +52,42 @@ public class HostsEffectiveNamesDomainRulesTests
|
|||||||
|
|
||||||
Assert.Equal(new[] { "node", "node.home.lan" }, result);
|
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,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());
|
||||||
|
}
|
||||||
|
}
|
||||||
+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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,15 +52,12 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (firstRender)
|
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);
|
_dotNetRef = DotNetObjectReference.Create(this);
|
||||||
_moduleLoaded = true;
|
_moduleLoaded = true;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException) { }
|
|
||||||
catch (JSDisconnectedException) { }
|
|
||||||
catch (JSException) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsVisible && _moduleLoaded && _jsModule != null)
|
if (IsVisible && _moduleLoaded && _jsModule != null)
|
||||||
@@ -146,6 +143,8 @@ public partial class AppLogsFiltersModal : IAsyncDisposable
|
|||||||
_cts.Cancel();
|
_cts.Cancel();
|
||||||
_cts.Dispose();
|
_cts.Dispose();
|
||||||
_dotNetRef?.Dispose();
|
_dotNetRef?.Dispose();
|
||||||
|
_dotNetRef = null;
|
||||||
await _jsModule.DisposeAsyncSafe();
|
await _jsModule.DisposeAsyncSafe();
|
||||||
|
_jsModule = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,13 +62,12 @@ public partial class AppLogsSection : IAsyncDisposable
|
|||||||
|
|
||||||
_hubConnection.On<LogsUpdatePayload>("AppLogsUpdate", OnAppLogsUpdate);
|
_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);
|
await _hubConnection.StartAsync(_cts.Token);
|
||||||
RestartPollTimer();
|
RestartPollTimer();
|
||||||
@@ -169,6 +168,7 @@ public partial class AppLogsSection : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||||
|
catch (JSDisconnectedException) { /* Circuit gone before InvokeAsync completed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetJustUpdated()
|
private void SetJustUpdated()
|
||||||
|
|||||||
@@ -63,13 +63,12 @@ public partial class LogsSection : IAsyncDisposable
|
|||||||
|
|
||||||
_hubConnection.On<LogsUpdatePayload>("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate);
|
_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);
|
await _hubConnection.StartAsync(_cts.Token);
|
||||||
RestartPollTimer();
|
RestartPollTimer();
|
||||||
@@ -135,6 +134,7 @@ public partial class LogsSection : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||||
|
catch (JSDisconnectedException) { /* Circuit gone before InvokeAsync completed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void SetJustUpdated()
|
private void SetJustUpdated()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Extensions.Interop
|
||||||
@inject IJSRuntime JSRuntime
|
@inject IJSRuntime JSRuntime
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-1 @CssClass">
|
<div class="d-flex align-items-center gap-1 @CssClass">
|
||||||
@@ -15,13 +16,7 @@
|
|||||||
private async Task CopyAsync()
|
private async Task CopyAsync()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(Text)) return;
|
if (string.IsNullOrEmpty(Text)) return;
|
||||||
try
|
// window.copyToClipboard from wwwroot/js/copy-to-clipboard.js; swallow disconnect / clipboard failures.
|
||||||
{
|
await JSRuntime.InvokeVoidAsyncSafe("copyToClipboard", args: new object?[] { Text });
|
||||||
await JSRuntime.InvokeVoidAsync("copyToClipboard", Text);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Clipboard not available or failed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
+8
-4
@@ -163,15 +163,19 @@
|
|||||||
{
|
{
|
||||||
if (Session.Draft.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
|
try
|
||||||
{
|
{
|
||||||
var confirmed = await module.InvokeAsync<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
|
var confirmed = await module.InvokeAsyncSafe<bool>("confirmMessage", "Discard unsaved changes and exit edit mode?");
|
||||||
if (!confirmed) return;
|
if (confirmed != true)
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
await module.DisposeAsync();
|
await module.DisposeAsyncSafe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Session.Draft.DiscardAllDraft();
|
Session.Draft.DiscardAllDraft();
|
||||||
|
|||||||
+493
@@ -0,0 +1,493 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||||
|
@inject INotificationService NotificationService
|
||||||
|
|
||||||
|
<div class="ec-field-value ec-field-value-multi ec-domain-rule-editor" @onclick:stopPropagation="true">
|
||||||
|
@* ul: avoid <ol> markers (scoped CSS does not inherit .ec-multi-list-add-item list-style rules from the generic multi editor). *@
|
||||||
|
<ul 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">
|
||||||
|
@if (_editingIndex == rowIndex && !itemReadonly)
|
||||||
|
{
|
||||||
|
<div class="ec-domain-rule-form">
|
||||||
|
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
|
||||||
|
<label class="form-label">Edit as raw line</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-raw")"
|
||||||
|
checked="@_draftUseRaw"
|
||||||
|
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
|
||||||
|
<label class="form-check-label" for="@($"{anchorId}-raw")">Edit as single line (advanced)</label>
|
||||||
|
</div>
|
||||||
|
@if (_draftUseRaw)
|
||||||
|
{
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" rows="2"
|
||||||
|
@bind="_draftRawLine" @bind:event="oninput"
|
||||||
|
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
|
||||||
|
}
|
||||||
|
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">Use <code>#</code> to take the search domain from resolv.conf.</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">
|
||||||
|
<input type="checkbox" class="form-check-input" id="@($"{anchorId}-loc2")"
|
||||||
|
@bind="_draftAddLocal" />
|
||||||
|
<label class="form-check-label" for="@($"{anchorId}-loc2")">
|
||||||
|
Append <code>,local</code> (same as a lone <code>--local</code> for this name; uncommon)
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
</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">
|
||||||
|
<p class="ec-domain-rule-intro">Sets the domain suffix dnsmasq uses: DHCP domain option, which FQDNs hosts may claim, and (with <code>expand-hosts</code>) how simple names are qualified in DNS.</p>
|
||||||
|
<label class="form-label">Edit as raw line</label>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="domain-add-raw"
|
||||||
|
checked="@_draftUseRaw"
|
||||||
|
@onchange="@(e => OnDraftUseRawChanged(e.Value is true))" />
|
||||||
|
<label class="form-check-label" for="domain-add-raw">Edit as single line (advanced)</label>
|
||||||
|
</div>
|
||||||
|
@if (_draftUseRaw)
|
||||||
|
{
|
||||||
|
<textarea class="form-control form-control-sm font-monospace" rows="2"
|
||||||
|
@bind="_draftRawLine" @bind:event="oninput"
|
||||||
|
placeholder="domain[,subnet|IP|interface[,local]]"></textarea>
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
@bind="_draftMiddle" @bind:event="oninput" />
|
||||||
|
</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"
|
||||||
|
@bind="_draftMiddle" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_draftScopeSelect == "unconditional")
|
||||||
|
{
|
||||||
|
<div class="form-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> (uncommon)
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</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 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
.ec-domain-rule-editor {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
border: 1px solid var(--bs-border-color, #dee2e6);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-body-bg, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-editor .ec-field-value-list,
|
||||||
|
.ec-domain-rule-list {
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-list > li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-intro {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-row.ec-domain-rule-row-editing {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 36rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-form .form-label {
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-raw {
|
||||||
|
font-family: var(--bs-font-monospace);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ec-domain-rule-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
@namespace DnsmasqWebUI.Components.EffectiveConfig.CustomDisplays
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq
|
||||||
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig
|
||||||
|
@inherits EffectiveConfigMultiValueDisplayBase
|
||||||
|
|
||||||
|
@if (Context.IsActiveEditor)
|
||||||
|
{
|
||||||
|
<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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
|
||||||
|
{
|
||||||
|
<li class="ec-multi-list-entry-point">
|
||||||
|
<OptionStateBadge
|
||||||
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
|
ShowEditableBadge="true"
|
||||||
|
EditableAsButton="true"
|
||||||
|
FieldKey="@Context.FieldKey"
|
||||||
|
IsActiveEditor="false"
|
||||||
|
IsInteractive="true"
|
||||||
|
HasPendingChange="@Context.HasPendingOrDraft"
|
||||||
|
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||||
|
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (Context.EffectiveItems.Count == 0)
|
||||||
|
{
|
||||||
|
<li class="ec-multi-list-empty-hint">
|
||||||
|
@if (Context.ShowEditableBadge)
|
||||||
|
{
|
||||||
|
<span class="text-muted">(none)</span>
|
||||||
|
<OptionStateBadge
|
||||||
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
|
ShowEditableBadge="true"
|
||||||
|
EditableAsButton="true"
|
||||||
|
FieldKey="@Context.FieldKey"
|
||||||
|
IsActiveEditor="false"
|
||||||
|
IsInteractive="true"
|
||||||
|
HasPendingChange="@Context.HasPendingOrDraft"
|
||||||
|
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||||
|
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<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;
|
||||||
|
}
|
||||||
+40
-63
@@ -45,30 +45,32 @@
|
|||||||
else if (Descriptor.IsMultiValue)
|
else if (Descriptor.IsMultiValue)
|
||||||
{
|
{
|
||||||
var customMulti = RenderFragmentRegistry.BuildMultiFieldComponentFragment(Descriptor.SectionId, Descriptor.OptionName, EventCallback.Factory.Create<IReadOnlyList<string>>(this, HandleMultiValueChanged));
|
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. *@
|
@* 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. *@
|
||||||
<li class="ec-field @(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled ? "ec-field-editing" : "")">
|
var customMultiActiveEditor = IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled;
|
||||||
|
<li class="ec-field @(customMultiActiveEditor ? "ec-field-editing" : "")">
|
||||||
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
<OptionHelpLabel AnchorId="@_anchorId" HelpKey="@_optionHelpKey" OptionLabel="@Descriptor.OptionName" Title="@(_labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||||
<span class="ec-field-value">
|
<span class="ec-field-value">
|
||||||
<CascadingValue Value="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)">
|
<CascadingValue Value="@customMultiActiveEditor">
|
||||||
<CascadingValue Value="@IsEditMode">
|
<CascadingValue Value="@IsEditMode">
|
||||||
@customMulti(Descriptor)
|
@customMulti(_multiContext)
|
||||||
</CascadingValue>
|
</CascadingValue>
|
||||||
</CascadingValue>
|
</CascadingValue>
|
||||||
</span>
|
</span>
|
||||||
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiValueRowSource" OptionName="@Descriptor.OptionName" Value="@_multiValueItems"
|
@if (customMultiActiveEditor)
|
||||||
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)"
|
{
|
||||||
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
<OptionStateBadge AnchorId="@_readonlyBadgeAnchorId" Source="@_multiContext.RowSource" OptionName="@Descriptor.OptionName" Value="@_multiContext.EffectiveItems"
|
||||||
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
IsInteractive="@(IsEditMode && !Descriptor.IsCapabilityDisabled)" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiContext.HasPendingOrDraft" EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="true"
|
||||||
|
IsCapabilityDisabled="@Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Descriptor.CapabilityDisabledReason"
|
||||||
|
OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||||
|
}
|
||||||
</li>
|
</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 Context="@_multiContext" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
||||||
<EffectiveConfigMultiValueRow Descriptor="@Descriptor" Label="@Descriptor.OptionName" Items="@_multiValueItems" LabelTooltip="@_labelTooltip" OptionHelpKey="@_optionHelpKey" OptionHelpAnchorId="@_anchorId"
|
ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
||||||
IsActiveEditor="@(IsEditMode && IsActive && !Descriptor.IsCapabilityDisabled)" IsEditMode="@IsEditMode" ReadonlyBadgeAnchorIdBase="@_readonlyBadgeAnchorId"
|
|
||||||
Source="@_multiValueRowSource" ShowEditableBadge="@(!Descriptor.IsCapabilityDisabled)" HasPendingChange="@_multiValueHasPendingOrDraft" FieldKey="@FieldKey"
|
|
||||||
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
ItemsChanged="@HandleMultiValueChanged" OnConfirmRequested="@HandleConfirmEdit" OnCancelRequested="@HandleCancelEdit" OnRevertRequested="@HandleRevertEdit" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,14 +124,7 @@ else
|
|||||||
private bool _wasActive;
|
private bool _wasActive;
|
||||||
private ExitAction _exitAction = ExitAction.None;
|
private ExitAction _exitAction = ExitAction.None;
|
||||||
|
|
||||||
/// <summary>Cached for multi-value: items to pass to the row (with sources preserved for draft merge).</summary>
|
private EffectiveConfigMultiDisplayContext? _multiContext;
|
||||||
private IReadOnlyList<ValueWithSource>? _multiValueItems;
|
|
||||||
/// <summary>Cached for multi-value: true when there is an uncommitted draft list.</summary>
|
|
||||||
private bool _multiValueHasDraft;
|
|
||||||
/// <summary>Cached for multi-value: row-level source for badge (readonly when all items readonly and not edit mode).</summary>
|
|
||||||
private ConfigValueSource? _multiValueRowSource;
|
|
||||||
/// <summary>Cached for multi-value: whether row should show ok/cancel (session pending or local draft).</summary>
|
|
||||||
private bool _multiValueHasPendingOrDraft;
|
|
||||||
|
|
||||||
protected override void OnParametersSet()
|
protected override void OnParametersSet()
|
||||||
{
|
{
|
||||||
@@ -146,7 +141,15 @@ else
|
|||||||
_hasPendingChange = pending != null;
|
_hasPendingChange = pending != null;
|
||||||
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
_effectiveValue = pending != null ? pending.NewValue : descriptorValue;
|
||||||
if (Descriptor.IsMultiValue)
|
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)
|
if (!_wasActive && IsActive)
|
||||||
{
|
{
|
||||||
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
|
// Use pending.OldValue when present so "revert to original" clears the pending change (e.g. do-0x20-encode: Disabled → Enable → Disable).
|
||||||
@@ -161,45 +164,6 @@ else
|
|||||||
_wasActive = IsActive;
|
_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)
|
private void HandleValueChanged(object? value)
|
||||||
{
|
{
|
||||||
_draftValue = value;
|
_draftValue = value;
|
||||||
@@ -279,7 +243,20 @@ else
|
|||||||
_draftValue = values.ToList();
|
_draftValue = values.ToList();
|
||||||
_hasDraftValue = true;
|
_hasDraftValue = true;
|
||||||
if (Descriptor.IsMultiValue)
|
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();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -3,7 +3,7 @@
|
|||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
<span class="ec-field-value-item" title="@Source?.GetReadOnlyTooltip()">
|
<span class="ec-field-value-item" title="@Source?.GetReadOnlyTooltip()">
|
||||||
@Value
|
@(DisplayValue ?? Value)
|
||||||
<OptionStateBadge
|
<OptionStateBadge
|
||||||
AnchorId="@(ReadonlyBadgeAnchorId ?? "")"
|
AnchorId="@(ReadonlyBadgeAnchorId ?? "")"
|
||||||
Source="@Source"
|
Source="@Source"
|
||||||
@@ -16,7 +16,10 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
/// <summary>Raw config line for badges / popovers.</summary>
|
||||||
[Parameter] public string Value { get; set; } = "";
|
[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 ConfigValueSource? Source { get; set; }
|
||||||
[Parameter] public string OptionName { get; set; } = "";
|
[Parameter] public string OptionName { get; set; } = "";
|
||||||
[Parameter] public bool IsEditMode { get; set; }
|
[Parameter] public bool IsEditMode { get; set; }
|
||||||
|
|||||||
-1
@@ -4,7 +4,6 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
@using DnsmasqWebUI.Infrastructure.Services.EffectiveConfig.Validation
|
||||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||||
@inject IJSRuntime JSRuntime
|
|
||||||
@inject INotificationService NotificationService
|
@inject INotificationService NotificationService
|
||||||
|
|
||||||
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
<div class="ec-field-value ec-field-value-multi ec-multi-list-editor" @onclick:stopPropagation="true">
|
||||||
|
|||||||
+35
-42
@@ -3,32 +3,32 @@
|
|||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
|
|
||||||
<li class="ec-field">
|
<li class="ec-field">
|
||||||
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Label" Title="@LabelTooltip"><strong>@Label:</strong></OptionHelpLabel>
|
<OptionHelpLabel AnchorId="@(OptionHelpAnchorId ?? "")" HelpKey="@OptionHelpKey" OptionLabel="@Context.Descriptor.OptionName" Title="@LabelTooltip"><strong>@Context.Descriptor.OptionName:</strong></OptionHelpLabel>
|
||||||
@if (IsActiveEditor)
|
@if (Context.IsActiveEditor)
|
||||||
{
|
{
|
||||||
<EffectiveConfigMultiValueListEditor
|
<EffectiveConfigMultiValueListEditor
|
||||||
SourceItems="@(Items ?? Array.Empty<ValueWithSource>())"
|
SourceItems="@(Context.EffectiveItems)"
|
||||||
OptionName="@Label"
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
ReadonlyBadgeAnchorIdBase="@(ReadonlyBadgeAnchorIdBase ?? "")"
|
||||||
IsEditMode="@IsEditMode"
|
IsEditMode="@Context.IsEditMode"
|
||||||
AllowAddManagedValue="@IsEditMode"
|
AllowAddManagedValue="@Context.IsEditMode"
|
||||||
FieldKey="@FieldKey"
|
FieldKey="@Context.FieldKey"
|
||||||
Behavior="@(_multiDesc?.Behavior)"
|
Behavior="@(MultiDesc?.Behavior)"
|
||||||
Validator="@(_multiDesc?.Validator)"
|
Validator="@(MultiDesc?.Validator)"
|
||||||
OnValuesChanged="@ItemsChanged" />
|
OnValuesChanged="@ItemsChanged" />
|
||||||
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Source" OptionName="@Label" Value="@Items"
|
<OptionStateBadge AnchorId="@(ReadonlyBadgeAnchorIdBase ?? "")" Source="@Context.RowSource" OptionName="@Context.Descriptor.OptionName" Value="@Context.EffectiveItems"
|
||||||
IsInteractive="@IsEditMode" ShowEditableBadge="@ShowEditableBadge" HasPendingChange="@HasPendingChange"
|
IsInteractive="@Context.IsEditMode" ShowEditableBadge="@Context.ShowEditableBadge" HasPendingChange="@Context.HasPendingOrDraft"
|
||||||
EditableAsButton="true" FieldKey="@FieldKey" IsActiveEditor="@IsActiveEditor"
|
EditableAsButton="true" FieldKey="@Context.FieldKey" IsActiveEditor="@Context.IsActiveEditor"
|
||||||
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)" CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason"
|
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled" CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason"
|
||||||
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
OnConfirmRequested="@OnConfirmRequested" OnCancelRequested="@OnCancelRequested" OnRevertRequested="@OnRevertRequested" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="ec-field-value ec-field-value-multi">
|
<div class="ec-field-value ec-field-value-multi">
|
||||||
<ol class="ec-field-value-list">
|
<ol class="ec-field-value-list">
|
||||||
@if (Items?.Count > 0)
|
@if (Context.EffectiveItems.Count > 0)
|
||||||
{
|
{
|
||||||
@foreach (var (item, index) in Items!.Select((item, index) => (item, index)))
|
@foreach (var (item, index) in Context.EffectiveItems.Select((item, index) => (item, index)))
|
||||||
{
|
{
|
||||||
var anchorId = ReadonlyBadgeAnchorIdBase != null
|
var anchorId = ReadonlyBadgeAnchorIdBase != null
|
||||||
? $"{ReadonlyBadgeAnchorIdBase}-{index}"
|
? $"{ReadonlyBadgeAnchorIdBase}-{index}"
|
||||||
@@ -37,44 +37,44 @@
|
|||||||
<EffectiveConfigMultiValueItem
|
<EffectiveConfigMultiValueItem
|
||||||
Value="@item.Value"
|
Value="@item.Value"
|
||||||
Source="@item.Source"
|
Source="@item.Source"
|
||||||
OptionName="@Label"
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
IsEditMode="@IsEditMode"
|
IsEditMode="@Context.IsEditMode"
|
||||||
ReadonlyBadgeAnchorId="@anchorId"
|
ReadonlyBadgeAnchorId="@anchorId"
|
||||||
FieldKey="@FieldKey" />
|
FieldKey="@Context.FieldKey" />
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (ShowEditableBadge && (Items?.Count ?? 0) > 0)
|
@if (Context.ShowEditableBadge && Context.EffectiveItems.Count > 0)
|
||||||
{
|
{
|
||||||
<li class="ec-multi-list-entry-point">
|
<li class="ec-multi-list-entry-point">
|
||||||
<OptionStateBadge
|
<OptionStateBadge
|
||||||
OptionName="@Label"
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
ShowEditableBadge="true"
|
ShowEditableBadge="true"
|
||||||
EditableAsButton="true"
|
EditableAsButton="true"
|
||||||
FieldKey="@FieldKey"
|
FieldKey="@Context.FieldKey"
|
||||||
IsActiveEditor="false"
|
IsActiveEditor="false"
|
||||||
IsInteractive="true"
|
IsInteractive="true"
|
||||||
HasPendingChange="@HasPendingChange"
|
HasPendingChange="@Context.HasPendingOrDraft"
|
||||||
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
|
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||||
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
|
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
else if ((Items?.Count ?? 0) == 0)
|
else if (Context.EffectiveItems.Count == 0)
|
||||||
{
|
{
|
||||||
<li class="ec-multi-list-empty-hint">
|
<li class="ec-multi-list-empty-hint">
|
||||||
@if (ShowEditableBadge)
|
@if (Context.ShowEditableBadge)
|
||||||
{
|
{
|
||||||
<span class="text-muted">(none)</span>
|
<span class="text-muted">(none)</span>
|
||||||
<OptionStateBadge
|
<OptionStateBadge
|
||||||
OptionName="@Label"
|
OptionName="@Context.Descriptor.OptionName"
|
||||||
ShowEditableBadge="true"
|
ShowEditableBadge="true"
|
||||||
EditableAsButton="true"
|
EditableAsButton="true"
|
||||||
FieldKey="@FieldKey"
|
FieldKey="@Context.FieldKey"
|
||||||
IsActiveEditor="false"
|
IsActiveEditor="false"
|
||||||
IsInteractive="true"
|
IsInteractive="true"
|
||||||
HasPendingChange="@HasPendingChange"
|
HasPendingChange="@Context.HasPendingOrDraft"
|
||||||
IsCapabilityDisabled="@(Descriptor?.IsCapabilityDisabled == true)"
|
IsCapabilityDisabled="@Context.Descriptor.IsCapabilityDisabled"
|
||||||
CapabilityDisabledReason="@Descriptor?.CapabilityDisabledReason" />
|
CapabilityDisabledReason="@Context.Descriptor.CapabilityDisabledReason" />
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -88,23 +88,16 @@
|
|||||||
</li>
|
</li>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
[Parameter] public string Label { get; set; } = "";
|
[Parameter] public EffectiveConfigMultiDisplayContext Context { get; set; } = null!;
|
||||||
[Parameter] public EffectiveConfigFieldDescriptor? Descriptor { get; set; }
|
|
||||||
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
|
|
||||||
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
|
||||||
[Parameter] public string? LabelTooltip { get; set; }
|
[Parameter] public string? LabelTooltip { get; set; }
|
||||||
[Parameter] public string? OptionHelpKey { get; set; }
|
[Parameter] public string? OptionHelpKey { get; set; }
|
||||||
[Parameter] public string? OptionHelpAnchorId { 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 string? ReadonlyBadgeAnchorIdBase { get; set; }
|
||||||
[Parameter] public ConfigValueSource? Source { get; set; }
|
[Parameter] public EventCallback<IReadOnlyList<string>> ItemsChanged { get; set; }
|
||||||
[Parameter] public bool ShowEditableBadge { get; set; }
|
|
||||||
[Parameter] public bool HasPendingChange { get; set; }
|
|
||||||
[Parameter] public string FieldKey { get; set; } = "";
|
|
||||||
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
[Parameter] public EventCallback OnConfirmRequested { get; set; }
|
||||||
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
[Parameter] public EventCallback OnCancelRequested { get; set; }
|
||||||
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
[Parameter] public EventCallback OnRevertRequested { get; set; }
|
||||||
|
|
||||||
private EffectiveMultiValueConfigFieldDescriptor? _multiDesc => Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
private EffectiveMultiValueConfigFieldDescriptor? MultiDesc =>
|
||||||
|
Context.Descriptor as EffectiveMultiValueConfigFieldDescriptor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
@using DnsmasqWebUI.Extensions.DependencyInjection
|
@using DnsmasqWebUI.Extensions.DependencyInjection
|
||||||
|
@using DnsmasqWebUI.Extensions.Interop
|
||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using System.Text.Json.Serialization
|
@using System.Text.Json.Serialization
|
||||||
@@ -57,14 +58,8 @@
|
|||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
try
|
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
|
||||||
{
|
_moduleLoaded = _jsModule != null;
|
||||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
|
||||||
_moduleLoaded = true;
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException) { /* prerender */ }
|
|
||||||
catch (JSDisconnectedException) { /* circuit disconnected */ }
|
|
||||||
catch (JSException) { /* import failed */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||||
@@ -93,15 +88,21 @@
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
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?.Dispose();
|
||||||
_dotNetRef = null;
|
_dotNetRef = null;
|
||||||
|
await _jsModule.DisposeAsyncSafe();
|
||||||
|
_jsModule = null;
|
||||||
}
|
}
|
||||||
await _jsModule.DisposeAsyncSafe();
|
|
||||||
_jsModule = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
@namespace DnsmasqWebUI.Components.EffectiveConfig
|
||||||
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
@using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig
|
||||||
@using DnsmasqWebUI.Extensions
|
@using DnsmasqWebUI.Extensions
|
||||||
|
@using DnsmasqWebUI.Extensions.Interop
|
||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using System.Text.Json.Serialization
|
@using System.Text.Json.Serialization
|
||||||
@@ -76,14 +77,8 @@
|
|||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
try
|
_jsModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>("import", default, "./js/config-option-help.js");
|
||||||
{
|
_moduleLoaded = _jsModule != null;
|
||||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/config-option-help.js");
|
|
||||||
_moduleLoaded = true;
|
|
||||||
}
|
|
||||||
catch (InvalidOperationException) { }
|
|
||||||
catch (JSDisconnectedException) { }
|
|
||||||
catch (JSException) { }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
if (Visible && !_scrollListenerAdded && _moduleLoaded && _jsModule != null)
|
||||||
@@ -121,15 +116,21 @@
|
|||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
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?.Dispose();
|
||||||
_dotNetRef = null;
|
_dotNetRef = null;
|
||||||
|
await _jsModule.DisposeAsyncSafe();
|
||||||
|
_jsModule = null;
|
||||||
}
|
}
|
||||||
await _jsModule.DisposeAsyncSafe();
|
|
||||||
_jsModule = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task PositionUnderAnchorAsync()
|
private async Task PositionUnderAnchorAsync()
|
||||||
|
|||||||
@@ -54,15 +54,16 @@ public partial class SettingsModal : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
if (firstRender)
|
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);
|
_dotNetRef = DotNetObjectReference.Create(this);
|
||||||
_moduleLoaded = true;
|
_moduleLoaded = true;
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (prerender)"); }
|
else
|
||||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (circuit disconnected)"); }
|
{
|
||||||
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: JS import failed"); }
|
Logger.LogDebug("SettingsModal: JS module unavailable (prerender, disconnect, or import failure)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsVisible && _moduleLoaded && _jsModule != null)
|
if (IsVisible && _moduleLoaded && _jsModule != null)
|
||||||
@@ -163,9 +164,9 @@ public partial class SettingsModal : IAsyncDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _jsModule.DisposeAsyncSafe();
|
|
||||||
_jsModule = null;
|
|
||||||
_dotNetRef?.Dispose();
|
_dotNetRef?.Dispose();
|
||||||
_dotNetRef = null;
|
_dotNetRef = null;
|
||||||
|
await _jsModule.DisposeAsyncSafe();
|
||||||
|
_jsModule = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,10 @@
|
|||||||
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig
|
@using DnsmasqWebUI.Infrastructure.Serialization.Parsers.DnsmasqConfig
|
||||||
@using DnsmasqWebUI.Components.EffectiveConfig
|
@using DnsmasqWebUI.Components.EffectiveConfig
|
||||||
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
@using DnsmasqWebUI.Infrastructure.Services.UI.Notifications.Abstractions
|
||||||
|
@using DnsmasqWebUI.Extensions.Interop
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
@using Microsoft.AspNetCore.Components.Web
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@implements IDisposable
|
@implements IAsyncDisposable
|
||||||
@inject IStatusClient StatusClient
|
@inject IStatusClient StatusClient
|
||||||
@inject IHostsClient HostsClient
|
@inject IHostsClient HostsClient
|
||||||
@inject IEffectiveConfigEditSession Session
|
@inject IEffectiveConfigEditSession Session
|
||||||
@@ -89,7 +90,8 @@ else
|
|||||||
<p class="text-muted">No rows match the current search.</p>
|
<p class="text-muted">No rows match the current search.</p>
|
||||||
}
|
}
|
||||||
<div class="app-table-scroll-shell" data-table-scroll-cue>
|
<div class="app-table-scroll-shell" data-table-scroll-cue>
|
||||||
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="Hosts entries table" tabindex="0">
|
<div class="app-table-scroll-region" data-table-scroll-region role="region" aria-label="Hosts entries table" tabindex="0"
|
||||||
|
@onkeydown="OnHostsTableRegionKeyDown">
|
||||||
<table class="table table-bordered table-hover app-data-table hosts-table mb-0">
|
<table class="table table-bordered table-hover app-data-table hosts-table mb-0">
|
||||||
<caption class="visually-hidden">Hosts entries grouped for scanning and editing</caption>
|
<caption class="visually-hidden">Hosts entries grouped for scanning and editing</caption>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -153,33 +155,37 @@ else
|
|||||||
</div>
|
</div>
|
||||||
@if (IsManagedEditableGroup(group))
|
@if (IsManagedEditableGroup(group))
|
||||||
{
|
{
|
||||||
<div class="hosts-group-header__managed-actions btn-group btn-group-sm" role="group" aria-label="@(IsInlineEditing ? "Save or finish editing this row" : "Managed hosts row actions")">
|
<div class="hosts-group-header__managed-actions @(IsInlineEditing || ManagedSelectionCount > 0 ? "hosts-group-header__managed-actions--visible" : "hosts-group-header__managed-actions--hidden")"
|
||||||
|
role="group"
|
||||||
|
aria-label="@(IsInlineEditing ? "Save or finish editing this row" : "Managed hosts row actions")">
|
||||||
@if (IsInlineEditing)
|
@if (IsInlineEditing)
|
||||||
{
|
{
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-primary"
|
class="btn hosts-managed-action-btn hosts-managed-action-btn--save"
|
||||||
title="Save this row"
|
title="Save this row"
|
||||||
@onclick="OnManagedHeaderSaveAsync">
|
@onclick="OnManagedHeaderSaveAsync">
|
||||||
<i class="bi bi-check-lg" aria-hidden="true"></i><span class="ms-1 d-none d-sm-inline">Save</span>
|
<i class="bi bi-check-lg" aria-hidden="true"></i><span>Save</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
else
|
else if (ManagedSelectionCount == 1)
|
||||||
{
|
{
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn hosts-managed-action-btn hosts-managed-action-btn--edit"
|
||||||
disabled="@(ManagedSelectionCount != 1)"
|
|
||||||
title="@EditButtonTitle"
|
title="@EditButtonTitle"
|
||||||
@onclick="OnManagedHeaderEditAsync">
|
@onclick="OnManagedHeaderEditAsync">
|
||||||
<i class="bi bi-pencil" aria-hidden="true"></i><span class="ms-1 d-none d-sm-inline">Edit</span>
|
<i class="bi bi-pencil" aria-hidden="true"></i><span>Edit</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!IsInlineEditing && ManagedSelectionCount > 0)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="btn hosts-managed-action-btn hosts-managed-action-btn--remove"
|
||||||
|
title="Remove selected host row(s) (asks for confirmation). With the table focused, Delete does the same."
|
||||||
|
@onclick="OnManagedHeaderRemoveClickAsync">
|
||||||
|
<i class="bi bi-trash" aria-hidden="true"></i><span>Remove</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button type="button"
|
|
||||||
class="btn btn-outline-danger"
|
|
||||||
disabled="@(IsInlineEditing || ManagedSelectionCount == 0)"
|
|
||||||
title="@(IsInlineEditing ? "Finish the current row edit first." : ManagedSelectionCount == 0 ? "Select one or more rows to remove." : "Remove selected host row(s)")"
|
|
||||||
@onclick="OnManagedHeaderRemoveAsync">
|
|
||||||
<i class="bi bi-trash" aria-hidden="true"></i><span class="ms-1 d-none d-sm-inline">Remove</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -217,6 +223,7 @@ else
|
|||||||
@bind="_editAddress"
|
@bind="_editAddress"
|
||||||
@bind:event="oninput"
|
@bind:event="oninput"
|
||||||
@onkeydown="OnEditFieldKeyDown"
|
@onkeydown="OnEditFieldKeyDown"
|
||||||
|
@onkeydown:stopPropagation="true"
|
||||||
autofocus />
|
autofocus />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -226,11 +233,15 @@ else
|
|||||||
aria-invalid="@(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "true" : null)"
|
aria-invalid="@(_showHostFieldValidation && CurrentHostnameFieldErrors.Count > 0 ? "true" : null)"
|
||||||
@bind="_editNames"
|
@bind="_editNames"
|
||||||
@bind:event="oninput"
|
@bind:event="oninput"
|
||||||
@onkeydown="OnEditFieldKeyDown" />
|
@onkeydown="OnEditFieldKeyDown"
|
||||||
|
@onkeydown:stopPropagation="true" />
|
||||||
</td>
|
</td>
|
||||||
@if (showEffectiveNames)
|
@if (showEffectiveNames)
|
||||||
{
|
{
|
||||||
<td class="text-muted small">@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, effectiveDomainValues, _editAddress))</td>
|
<td class="text-muted small"
|
||||||
|
title="@HostsEffectiveNames.ExplainSuffixSource(pageState.ExpandHostsEnabled, effectiveDomainValues, _editAddress, ParseNamesList(_editNames))">
|
||||||
|
@string.Join(", ", HostsEffectiveNames.Expand(ParseNamesList(_editNames), pageState.ExpandHostsEnabled, effectiveDomainValues, _editAddress))
|
||||||
|
</td>
|
||||||
}
|
}
|
||||||
<td>
|
<td>
|
||||||
<input class="form-control form-control-sm"
|
<input class="form-control form-control-sm"
|
||||||
@@ -238,7 +249,8 @@ else
|
|||||||
title="Saved after # on the same line as this entry (inline hosts comment)."
|
title="Saved after # on the same line as this entry (inline hosts comment)."
|
||||||
@bind="_editInlineComment"
|
@bind="_editInlineComment"
|
||||||
@bind:event="oninput"
|
@bind:event="oninput"
|
||||||
@onkeydown="OnEditFieldKeyDown" />
|
@onkeydown="OnEditFieldKeyDown"
|
||||||
|
@onkeydown:stopPropagation="true" />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -255,7 +267,7 @@ else
|
|||||||
<td>@string.Join(", ", row.Names)</td>
|
<td>@string.Join(", ", row.Names)</td>
|
||||||
@if (showEffectiveNames)
|
@if (showEffectiveNames)
|
||||||
{
|
{
|
||||||
<td>
|
<td title="@HostsEffectiveNames.ExplainSuffixSource(pageState.ExpandHostsEnabled, effectiveDomainValues, row.Address, row.Names)">
|
||||||
@if (effectiveNames.Count != row.Names.Count)
|
@if (effectiveNames.Count != row.Names.Count)
|
||||||
{
|
{
|
||||||
<span class="text-primary">@string.Join(", ", effectiveNames)</span>
|
<span class="text-primary">@string.Join(", ", effectiveNames)</span>
|
||||||
@@ -312,6 +324,27 @@ else
|
|||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (_showRemoveHostsModal)
|
||||||
|
{
|
||||||
|
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="hosts-remove-title" aria-modal="true">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="hosts-remove-title">Remove host entries</h5>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="mb-0">Remove <strong>@_removeModalSelectionCount</strong> selected host row@(_removeModalSelectionCount == 1 ? "" : "s") from the managed hosts draft? This is not saved to disk until you save effective configuration.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" @onclick="CancelRemoveHostsModal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" @onclick="ConfirmRemoveHostsAsync">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-backdrop fade show"></div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (_showInvalidHostEditModal)
|
@if (_showInvalidHostEditModal)
|
||||||
{
|
{
|
||||||
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="hosts-invalid-edit-title" aria-modal="true">
|
<div class="modal fade show" style="display: block;" tabindex="-1" role="dialog" aria-labelledby="hosts-invalid-edit-title" aria-modal="true">
|
||||||
@@ -383,6 +416,9 @@ else
|
|||||||
private ElementReference _editingRowRef;
|
private ElementReference _editingRowRef;
|
||||||
private ElementReference _editAddressInputRef;
|
private ElementReference _editAddressInputRef;
|
||||||
private IJSObjectReference? _hostsTableScrollCueModule;
|
private IJSObjectReference? _hostsTableScrollCueModule;
|
||||||
|
private bool _showRemoveHostsModal;
|
||||||
|
private int _removeModalSelectionCount;
|
||||||
|
private string? _selectionAnchorRowId;
|
||||||
private bool _showInvalidHostEditModal;
|
private bool _showInvalidHostEditModal;
|
||||||
private bool _refocusEditAfterRender;
|
private bool _refocusEditAfterRender;
|
||||||
/// <summary>When false, invalid state is hidden until the first blur, Escape, or Enter.</summary>
|
/// <summary>When false, invalid state is hidden until the first blur, Escape, or Enter.</summary>
|
||||||
@@ -437,7 +473,7 @@ else
|
|||||||
private string EditButtonTitle =>
|
private string EditButtonTitle =>
|
||||||
ManagedSelectionCount == 0 ? "Select a row to edit."
|
ManagedSelectionCount == 0 ? "Select a row to edit."
|
||||||
: ManagedSelectionCount > 1 ? "Select exactly one row to edit."
|
: ManagedSelectionCount > 1 ? "Select exactly one row to edit."
|
||||||
: "Edit selected row";
|
: "Edit selected row (or press Enter when the table area is focused)";
|
||||||
|
|
||||||
private bool _editingRowIsNew =>
|
private bool _editingRowIsNew =>
|
||||||
!string.IsNullOrEmpty(_editingRowId) && _editingRowId.StartsWith("new:", StringComparison.Ordinal);
|
!string.IsNullOrEmpty(_editingRowId) && _editingRowId.StartsWith("new:", StringComparison.Ordinal);
|
||||||
@@ -519,6 +555,8 @@ else
|
|||||||
_unifiedRows.Where(r => r.IsEditable).Select(r => r.Id),
|
_unifiedRows.Where(r => r.IsEditable).Select(r => r.Id),
|
||||||
StringComparer.Ordinal);
|
StringComparer.Ordinal);
|
||||||
_selectedManagedRowIds.RemoveWhere(id => !editableIds.Contains(id));
|
_selectedManagedRowIds.RemoveWhere(id => !editableIds.Contains(id));
|
||||||
|
if (!string.IsNullOrEmpty(_selectionAnchorRowId) && !_selectedManagedRowIds.Contains(_selectionAnchorRowId))
|
||||||
|
_selectionAnchorRowId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnEffectiveNamesHelpClick() =>
|
private void OnEffectiveNamesHelpClick() =>
|
||||||
@@ -541,7 +579,7 @@ else
|
|||||||
|
|
||||||
private string RowTitle(HostsPageRow row) =>
|
private string RowTitle(HostsPageRow row) =>
|
||||||
row.IsEditable
|
row.IsEditable
|
||||||
? "Click to select only this row, or click again to deselect. Double-click to edit. Ctrl or ⌘+click toggles rows in the selection. Add a row below; the header Edit control becomes Save while you edit; Remove deletes selected rows."
|
? "Click to select only this row, or click again to deselect. Shift+click selects a range in display order. Double-click to edit. Ctrl or ⌘+click toggles rows. Focus the table (Tab to the scroll area) and press Enter to edit the single selected row, or Delete to remove the selection (with confirmation). Header Remove also asks for confirmation."
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
private string ManagedAddSlotEmptyLead =>
|
private string ManagedAddSlotEmptyLead =>
|
||||||
@@ -568,24 +606,82 @@ else
|
|||||||
if (e.Detail > 1)
|
if (e.Detail > 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (e.ShiftKey)
|
||||||
|
{
|
||||||
|
ApplyShiftManagedSelection(row);
|
||||||
|
StateHasChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.CtrlKey || e.MetaKey)
|
if (e.CtrlKey || e.MetaKey)
|
||||||
{
|
{
|
||||||
if (!_selectedManagedRowIds.Add(row.Id))
|
if (!_selectedManagedRowIds.Add(row.Id))
|
||||||
_selectedManagedRowIds.Remove(row.Id);
|
_selectedManagedRowIds.Remove(row.Id);
|
||||||
|
_selectionAnchorRowId = row.Id;
|
||||||
}
|
}
|
||||||
else if (_selectedManagedRowIds.Contains(row.Id))
|
else if (_selectedManagedRowIds.Contains(row.Id))
|
||||||
{
|
{
|
||||||
_selectedManagedRowIds.Remove(row.Id);
|
_selectedManagedRowIds.Remove(row.Id);
|
||||||
|
_selectionAnchorRowId = _selectedManagedRowIds.Count == 1 ? _selectedManagedRowIds.First() : null;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_selectedManagedRowIds.Clear();
|
_selectedManagedRowIds.Clear();
|
||||||
_selectedManagedRowIds.Add(row.Id);
|
_selectedManagedRowIds.Add(row.Id);
|
||||||
|
_selectionAnchorRowId = row.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<string> GetManagedEditableRowOrder()
|
||||||
|
{
|
||||||
|
var ids = new List<string>();
|
||||||
|
foreach (var g in VisibleGroups)
|
||||||
|
{
|
||||||
|
if (!IsManagedEditableGroup(g))
|
||||||
|
continue;
|
||||||
|
foreach (var r in g.Rows)
|
||||||
|
{
|
||||||
|
if (r.IsEditable)
|
||||||
|
ids.Add(r.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyShiftManagedSelection(HostsPageRow row)
|
||||||
|
{
|
||||||
|
var order = GetManagedEditableRowOrder();
|
||||||
|
var idxRow = order.IndexOf(row.Id);
|
||||||
|
if (idxRow < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(_selectionAnchorRowId))
|
||||||
|
{
|
||||||
|
_selectedManagedRowIds.Clear();
|
||||||
|
_selectedManagedRowIds.Add(row.Id);
|
||||||
|
_selectionAnchorRowId = row.Id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idxAnchor = order.IndexOf(_selectionAnchorRowId);
|
||||||
|
if (idxAnchor < 0)
|
||||||
|
{
|
||||||
|
_selectedManagedRowIds.Clear();
|
||||||
|
_selectedManagedRowIds.Add(row.Id);
|
||||||
|
_selectionAnchorRowId = row.Id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lo = Math.Min(idxAnchor, idxRow);
|
||||||
|
var hi = Math.Max(idxAnchor, idxRow);
|
||||||
|
_selectedManagedRowIds.Clear();
|
||||||
|
for (var i = lo; i <= hi; i++)
|
||||||
|
_selectedManagedRowIds.Add(order[i]);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task OnManagedRowDoubleClickAsync(HostsPageRow row)
|
private async Task OnManagedRowDoubleClickAsync(HostsPageRow row)
|
||||||
{
|
{
|
||||||
DeactivateEffectiveConfigFieldIfActive();
|
DeactivateEffectiveConfigFieldIfActive();
|
||||||
@@ -619,9 +715,35 @@ else
|
|||||||
await BeginEditingManagedRowAsync(row);
|
await BeginEditingManagedRowAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnManagedHeaderRemoveAsync()
|
private Task OnManagedHeaderRemoveClickAsync()
|
||||||
{
|
{
|
||||||
DeactivateEffectiveConfigFieldIfActive();
|
DeactivateEffectiveConfigFieldIfActive();
|
||||||
|
return OpenRemoveHostsModalAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Task OpenRemoveHostsModalAsync()
|
||||||
|
{
|
||||||
|
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath) || IsInlineEditing || ManagedSelectionCount == 0)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
_removeModalSelectionCount = ManagedSelectionCount;
|
||||||
|
_showRemoveHostsModal = true;
|
||||||
|
StateHasChanged();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelRemoveHostsModal()
|
||||||
|
{
|
||||||
|
_showRemoveHostsModal = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmRemoveHostsAsync()
|
||||||
|
{
|
||||||
|
_showRemoveHostsModal = false;
|
||||||
|
await ExecuteManagedRemoveAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteManagedRemoveAsync()
|
||||||
|
{
|
||||||
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath) || IsInlineEditing)
|
if (_status == null || string.IsNullOrWhiteSpace(_status.ManagedHostsFilePath) || IsInlineEditing)
|
||||||
return;
|
return;
|
||||||
if (_selectedManagedRowIds.Count == 0)
|
if (_selectedManagedRowIds.Count == 0)
|
||||||
@@ -646,9 +768,39 @@ else
|
|||||||
RebuildManagedOverlay();
|
RebuildManagedOverlay();
|
||||||
InvalidateVisibleGroupsCache();
|
InvalidateVisibleGroupsCache();
|
||||||
_selectedManagedRowIds.Clear();
|
_selectedManagedRowIds.Clear();
|
||||||
|
_selectionAnchorRowId = null;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task OnHostsTableRegionKeyDown(KeyboardEventArgs e)
|
||||||
|
{
|
||||||
|
if (_showRemoveHostsModal || _showInvalidHostEditModal)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (IsInlineEditing)
|
||||||
|
{
|
||||||
|
// Scroll region is focusable (tabindex=0); after clicking outside inputs, Escape must still exit edit.
|
||||||
|
if (e.Key == "Escape")
|
||||||
|
await OnEscapeFromInlineEditAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Key == "Delete")
|
||||||
|
{
|
||||||
|
if (ManagedSelectionCount == 0)
|
||||||
|
return;
|
||||||
|
DeactivateEffectiveConfigFieldIfActive();
|
||||||
|
await OpenRemoveHostsModalAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Key == "Enter")
|
||||||
|
{
|
||||||
|
DeactivateEffectiveConfigFieldIfActive();
|
||||||
|
await OnManagedHeaderEditAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task BeginEditingManagedRowAsync(HostsPageRow row)
|
private async Task BeginEditingManagedRowAsync(HostsPageRow row)
|
||||||
{
|
{
|
||||||
DeactivateEffectiveConfigFieldIfActive();
|
DeactivateEffectiveConfigFieldIfActive();
|
||||||
@@ -684,6 +836,7 @@ else
|
|||||||
_editInlineComment = entry.InlineComment ?? "";
|
_editInlineComment = entry.InlineComment ?? "";
|
||||||
_selectedManagedRowIds.Clear();
|
_selectedManagedRowIds.Clear();
|
||||||
_selectedManagedRowIds.Add(row.Id);
|
_selectedManagedRowIds.Add(row.Id);
|
||||||
|
_selectionAnchorRowId = row.Id;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,6 +954,14 @@ else
|
|||||||
if (e.Key != "Escape")
|
if (e.Key != "Escape")
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
await OnEscapeFromInlineEditAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnEscapeFromInlineEditAsync()
|
||||||
|
{
|
||||||
|
if (_showInvalidHostEditModal || string.IsNullOrEmpty(_editingRowId))
|
||||||
|
return;
|
||||||
|
|
||||||
if (IsCurrentEditValid)
|
if (IsCurrentEditValid)
|
||||||
await CommitAndEndInlineEditAsync();
|
await CommitAndEndInlineEditAsync();
|
||||||
else
|
else
|
||||||
@@ -834,14 +995,16 @@ else
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var outside = true;
|
var outside = true;
|
||||||
try
|
if (_hostsTableScrollCueModule != null)
|
||||||
{
|
{
|
||||||
if (_hostsTableScrollCueModule != null)
|
try
|
||||||
|
{
|
||||||
outside = await _hostsTableScrollCueModule.InvokeAsync<bool>("isFocusOutsideElement", _editingRowRef);
|
outside = await _hostsTableScrollCueModule.InvokeAsync<bool>("isFocusOutsideElement", _editingRowRef);
|
||||||
}
|
}
|
||||||
catch (JSDisconnectedException)
|
catch (Exception ex) when (ex is JSDisconnectedException or JSException or ObjectDisposedException or TaskCanceledException)
|
||||||
{
|
{
|
||||||
outside = true;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (outside)
|
if (outside)
|
||||||
@@ -955,20 +1118,16 @@ else
|
|||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
_hostsTableScrollCueModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
|
_hostsTableScrollCueModule = await JSRuntime.InvokeAsyncSafe<IJSObjectReference>(
|
||||||
"import",
|
"import",
|
||||||
|
default,
|
||||||
"/js/modules/table-scroll-cue.js");
|
"/js/modules/table-scroll-cue.js");
|
||||||
await _hostsTableScrollCueModule.InvokeVoidAsync("initTableScrollCue");
|
if (_hostsTableScrollCueModule != null)
|
||||||
|
await _hostsTableScrollCueModule.InvokeVoidAsyncSafe("initTableScrollCue");
|
||||||
}
|
}
|
||||||
else if (_hostsTableScrollCueModule != null)
|
else if (_hostsTableScrollCueModule != null)
|
||||||
{
|
{
|
||||||
try
|
await _hostsTableScrollCueModule.InvokeVoidAsyncSafe("updateTableScrollCue");
|
||||||
{
|
|
||||||
await _hostsTableScrollCueModule.InvokeVoidAsync("updateTableScrollCue");
|
|
||||||
}
|
|
||||||
catch (JSDisconnectedException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_refocusEditAfterRender)
|
if (_refocusEditAfterRender)
|
||||||
@@ -980,9 +1139,8 @@ else
|
|||||||
{
|
{
|
||||||
await _editAddressInputRef.FocusAsync();
|
await _editAddressInputRef.FocusAsync();
|
||||||
}
|
}
|
||||||
catch (JSDisconnectedException)
|
catch (JSDisconnectedException) { }
|
||||||
{
|
catch (JSException) { }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1023,9 +1181,8 @@ else
|
|||||||
if (_status?.EffectiveConfig != null && !string.IsNullOrEmpty(_status.ManagedHostsFilePath))
|
if (_status?.EffectiveConfig != null && !string.IsNullOrEmpty(_status.ManagedHostsFilePath))
|
||||||
{
|
{
|
||||||
var expandHosts = _status.EffectiveConfig.ExpandHosts;
|
var expandHosts = _status.EffectiveConfig.ExpandHosts;
|
||||||
var domain = _status.EffectiveConfig.DomainValues?.FirstOrDefault();
|
|
||||||
var noHosts = _status.NoHosts;
|
var noHosts = _status.NoHosts;
|
||||||
var fromApi = await HostsClient.GetUnifiedRowsAsync(expandHosts, domain, noHosts, _status.ManagedHostsFilePath);
|
var fromApi = await HostsClient.GetUnifiedRowsAsync(expandHosts, noHosts, _status.ManagedHostsFilePath);
|
||||||
_restUnifiedRows = fromApi.Where(r => r.SourceKind != HostsRowSourceKind.Managed).ToList();
|
_restUnifiedRows = fromApi.Where(r => r.SourceKind != HostsRowSourceKind.Managed).ToList();
|
||||||
ReconcileManagedHostsDraftAfterLoad();
|
ReconcileManagedHostsDraftAfterLoad();
|
||||||
RebuildManagedOverlay();
|
RebuildManagedOverlay();
|
||||||
@@ -1038,6 +1195,8 @@ else
|
|||||||
|
|
||||||
InvalidateVisibleGroupsCache();
|
InvalidateVisibleGroupsCache();
|
||||||
_selectedManagedRowIds.Clear();
|
_selectedManagedRowIds.Clear();
|
||||||
|
_selectionAnchorRowId = null;
|
||||||
|
_showRemoveHostsModal = false;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1051,14 +1210,14 @@ else
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var expandHosts = _status.EffectiveConfig.ExpandHosts;
|
var expandHosts = _status.EffectiveConfig.ExpandHosts;
|
||||||
var domain = _status.EffectiveConfig.DomainValues?.FirstOrDefault();
|
var domainValues = GetEffectiveDomainValuesForPreview();
|
||||||
var path = _status.ManagedHostsFilePath;
|
var path = _status.ManagedHostsFilePath;
|
||||||
var managedRows = new List<HostsPageRow>();
|
var managedRows = new List<HostsPageRow>();
|
||||||
|
|
||||||
foreach (var e in CurrentManagedEntries.Where(x => !x.IsPassthrough))
|
foreach (var e in CurrentManagedEntries.Where(x => !x.IsPassthrough))
|
||||||
{
|
{
|
||||||
var names = e.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
|
var names = e.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||||
var effective = HostsEffectiveNames.Expand(names, expandHosts, domain);
|
var effective = HostsEffectiveNames.Expand(names, expandHosts, domainValues, e.Address ?? "");
|
||||||
var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id;
|
var id = string.IsNullOrEmpty(e.Id) ? $"managed:{e.LineNumber}" : e.Id;
|
||||||
managedRows.Add(new HostsPageRow(
|
managedRows.Add(new HostsPageRow(
|
||||||
id,
|
id,
|
||||||
@@ -1095,26 +1254,26 @@ else
|
|||||||
_showInvalidHostEditModal = false;
|
_showInvalidHostEditModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
Session.Changed -= OnSessionChanged;
|
Session.Changed -= OnSessionChanged;
|
||||||
_ = CleanupHostsTableScrollCueAsync();
|
await CleanupHostsTableScrollCueAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task CleanupHostsTableScrollCueAsync()
|
private async Task CleanupHostsTableScrollCueAsync()
|
||||||
{
|
{
|
||||||
if (_hostsTableScrollCueModule == null)
|
var module = _hostsTableScrollCueModule;
|
||||||
|
_hostsTableScrollCueModule = null;
|
||||||
|
if (module == null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _hostsTableScrollCueModule.InvokeVoidAsync("disposeTableScrollCue");
|
await module.InvokeVoidAsyncSafe("disposeTableScrollCue");
|
||||||
await _hostsTableScrollCueModule.DisposeAsync();
|
|
||||||
}
|
}
|
||||||
catch (JSDisconnectedException)
|
finally
|
||||||
{
|
{
|
||||||
|
await module.DisposeAsyncSafe();
|
||||||
}
|
}
|
||||||
|
|
||||||
_hostsTableScrollCueModule = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,119 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
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 12px 24px -20px rgba(15, 23, 42, 0.45);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
transform-origin: top right;
|
||||||
|
transition: opacity 180ms ease, transform 180ms ease, max-width 200ms ease, padding 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-group-header__managed-actions--visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
max-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-group-header__managed-actions--hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.96);
|
||||||
|
max-width: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-managed-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
padding: 0.38rem 0.78rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: transform 140ms ease, box-shadow 140ms ease, background-color 140ms ease, border-color 140ms ease, color 140ms ease;
|
||||||
|
animation: hosts-managed-action-enter 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-managed-action-btn i {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-managed-action-btn:hover,
|
||||||
|
.hosts-managed-action-btn:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 18px -14px rgba(15, 23, 42, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hosts-managed-action-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hosts-group-header__badge {
|
.hosts-group-header__badge {
|
||||||
@@ -268,6 +381,26 @@
|
|||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640.98px) {
|
||||||
|
.hosts-group-header__managed-actions {
|
||||||
|
gap: 0.3rem;
|
||||||
|
min-height: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hosts-managed-action-btn {
|
||||||
|
padding: 0.34rem 0.68rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hosts-group-header__managed-actions,
|
||||||
|
.hosts-managed-action-btn {
|
||||||
|
transition: none;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
::deep .hosts-row code {
|
::deep .hosts-row code {
|
||||||
font-size: 0.93em;
|
font-size: 0.93em;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
|||||||
@@ -64,7 +64,6 @@ public class HostsController : ControllerBase
|
|||||||
[HttpGet("unified")]
|
[HttpGet("unified")]
|
||||||
public async Task<ActionResult<IReadOnlyList<HostsPageRow>>> GetUnified(
|
public async Task<ActionResult<IReadOnlyList<HostsPageRow>>> GetUnified(
|
||||||
[FromQuery] bool expandHosts,
|
[FromQuery] bool expandHosts,
|
||||||
[FromQuery] string? domain,
|
|
||||||
[FromQuery] bool noHosts,
|
[FromQuery] bool noHosts,
|
||||||
[FromQuery] string? managedHostsPath,
|
[FromQuery] string? managedHostsPath,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -72,7 +71,7 @@ public class HostsController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Get unified hosts rows");
|
_logger.LogDebug("Get unified hosts rows");
|
||||||
var rows = await _hostsCache.GetUnifiedRowsAsync(expandHosts, domain, noHosts, managedHostsPath, ct);
|
var rows = await _hostsCache.GetUnifiedRowsAsync(expandHosts, noHosts, managedHostsPath, ct);
|
||||||
return Ok(rows);
|
return Ok(rows);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace DnsmasqWebUI.Extensions.Interop;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Safe JS module interop helpers that swallow disconnect/cancel/dispose exceptions.
|
/// Safe JS module interop helpers that swallow disconnect/cancel/dispose exceptions.
|
||||||
/// Use for best-effort or teardown calls (dispose, cleanup, hub callbacks, error toasts).
|
/// Use for best-effort or teardown calls (dispose, cleanup, hub callbacks, error toasts).
|
||||||
|
/// Swallows <see cref="JSException"/> when the invoked JS throws.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class JSObjectReferenceExtensions
|
public static class JSObjectReferenceExtensions
|
||||||
{
|
{
|
||||||
@@ -25,11 +26,13 @@ public static class JSObjectReferenceExtensions
|
|||||||
catch (JSDisconnectedException) { return false; }
|
catch (JSDisconnectedException) { return false; }
|
||||||
catch (TaskCanceledException) { return false; }
|
catch (TaskCanceledException) { return false; }
|
||||||
catch (ObjectDisposedException) { return false; }
|
catch (ObjectDisposedException) { return false; }
|
||||||
|
catch (JSException) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Invokes a method on the module that returns a value, without throwing on circuit disconnect, cancel, or dispose.
|
/// Invokes a method on the module that returns a value, without throwing on circuit disconnect, cancel, or dispose.
|
||||||
/// Returns default when the call fails.
|
/// Returns default when the call fails. For value types (e.g. <see cref="bool"/>), failure and a legitimate default
|
||||||
|
/// from JS are indistinguishable—use raw <see cref="IJSObjectReference.InvokeAsync{TValue}"/> with try/catch when that matters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async ValueTask<T?> InvokeAsyncSafe<T>(
|
public static async ValueTask<T?> InvokeAsyncSafe<T>(
|
||||||
this IJSObjectReference module,
|
this IJSObjectReference module,
|
||||||
@@ -43,6 +46,7 @@ public static class JSObjectReferenceExtensions
|
|||||||
catch (JSDisconnectedException) { return default; }
|
catch (JSDisconnectedException) { return default; }
|
||||||
catch (TaskCanceledException) { return default; }
|
catch (TaskCanceledException) { return default; }
|
||||||
catch (ObjectDisposedException) { return default; }
|
catch (ObjectDisposedException) { return default; }
|
||||||
|
catch (JSException) { return default; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace DnsmasqWebUI.Extensions.Interop;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Safe JS interop helpers that swallow disconnect/cancel/dispose exceptions.
|
/// Safe JS interop helpers that swallow disconnect/cancel/dispose exceptions.
|
||||||
/// Use for best-effort or teardown calls; use raw InvokeVoidAsync/InvokeAsync when failure must propagate.
|
/// Use for best-effort or teardown calls; use raw InvokeVoidAsync/InvokeAsync when failure must propagate.
|
||||||
|
/// Also swallows <see cref="JSException"/> (JS-side throw) so callers match module *Safe behavior.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class JSRuntimeExtensions
|
public static class JSRuntimeExtensions
|
||||||
{
|
{
|
||||||
@@ -30,11 +31,12 @@ public static class JSRuntimeExtensions
|
|||||||
catch (JSDisconnectedException) { return false; }
|
catch (JSDisconnectedException) { return false; }
|
||||||
catch (TaskCanceledException) { return false; }
|
catch (TaskCanceledException) { return false; }
|
||||||
catch (ObjectDisposedException) { return false; }
|
catch (ObjectDisposedException) { return false; }
|
||||||
|
catch (JSException) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Invokes a JS function that returns a value, without throwing on circuit disconnect, cancel, or dispose.
|
/// Invokes a JS function that returns a value, without throwing on circuit disconnect, cancel, or dispose.
|
||||||
/// Returns default when the call fails (e.g. circuit gone).
|
/// Returns default when the call fails (e.g. circuit gone). For value types, failure is indistinguishable from JS returning default—use try/catch when that matters.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static async ValueTask<T?> InvokeAsyncSafe<T>(
|
public static async ValueTask<T?> InvokeAsyncSafe<T>(
|
||||||
this IJSRuntime js,
|
this IJSRuntime js,
|
||||||
@@ -52,5 +54,6 @@ public static class JSRuntimeExtensions
|
|||||||
catch (JSDisconnectedException) { return default; }
|
catch (JSDisconnectedException) { return default; }
|
||||||
catch (TaskCanceledException) { return default; }
|
catch (TaskCanceledException) { return default; }
|
||||||
catch (ObjectDisposedException) { return default; }
|
catch (ObjectDisposedException) { return default; }
|
||||||
|
catch (JSException) { return default; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ public interface IHostsClient
|
|||||||
/// <summary>Gets unified hosts rows (managed + read-only) from GET api/hosts/unified.</summary>
|
/// <summary>Gets unified hosts rows (managed + read-only) from GET api/hosts/unified.</summary>
|
||||||
Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
||||||
bool expandHosts,
|
bool expandHosts,
|
||||||
string? domain,
|
|
||||||
bool noHosts,
|
bool noHosts,
|
||||||
string? managedHostsPath,
|
string? managedHostsPath,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|||||||
@@ -25,14 +25,11 @@ public sealed class HostsClient : IHostsClient
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
public async Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
||||||
bool expandHosts,
|
bool expandHosts,
|
||||||
string? domain,
|
|
||||||
bool noHosts,
|
bool noHosts,
|
||||||
string? managedHostsPath,
|
string? managedHostsPath,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var queryParams = $"?expandHosts={expandHosts}&noHosts={noHosts}";
|
var queryParams = $"?expandHosts={expandHosts}&noHosts={noHosts}";
|
||||||
if (!string.IsNullOrEmpty(domain))
|
|
||||||
queryParams += $"&domain={Uri.EscapeDataString(domain)}";
|
|
||||||
if (!string.IsNullOrEmpty(managedHostsPath))
|
if (!string.IsNullOrEmpty(managedHostsPath))
|
||||||
queryParams += $"&managedHostsPath={Uri.EscapeDataString(managedHostsPath)}";
|
queryParams += $"&managedHostsPath={Uri.EscapeDataString(managedHostsPath)}";
|
||||||
var list = await _http.GetFromJsonAsync<List<HostsPageRow>>($"api/hosts/unified{queryParams}", ApiJsonOptions.ClientOptions, ct);
|
var list = await _http.GetFromJsonAsync<List<HostsPageRow>>($"api/hosts/unified{queryParams}", ApiJsonOptions.ClientOptions, ct);
|
||||||
|
|||||||
+132
@@ -0,0 +1,132 @@
|
|||||||
|
using System.Net;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Infrastructure.Serialization.Parsers.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parsed <c>--domain=</c> value: <c>domain</c>, <c>domain,range</c>, <c>domain,range,local</c>, or <c>domain,interface</c>.
|
||||||
|
/// See wwwroot/option-help/domain.html and <c>DomainSemanticHandler</c>.
|
||||||
|
/// </summary>
|
||||||
|
public enum DomainRuleScope
|
||||||
|
{
|
||||||
|
/// <summary>Domain only, or <c>domain,local</c> (keyword).</summary>
|
||||||
|
Unconditional,
|
||||||
|
/// <summary>Second token is IP, CIDR, or similar (not interface name).</summary>
|
||||||
|
AddressRangeOrIp,
|
||||||
|
/// <summary>Second token is a network interface name.</summary>
|
||||||
|
InterfaceName,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Editable decomposition of one <c>domain</c> config line. Use <see cref="Parse"/> / <see cref="ToConfigLine"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DomainRuleFields(
|
||||||
|
string DomainPart,
|
||||||
|
DomainRuleScope Scope,
|
||||||
|
string MiddlePart,
|
||||||
|
bool AddLocalForRange,
|
||||||
|
string? RawLineFallback = null)
|
||||||
|
{
|
||||||
|
public bool IsRaw => RawLineFallback != null;
|
||||||
|
|
||||||
|
public static DomainRuleFields Parse(string? raw)
|
||||||
|
{
|
||||||
|
var s = (raw ?? "").Trim();
|
||||||
|
if (s.Length == 0)
|
||||||
|
return new("", DomainRuleScope.Unconditional, "", false, null);
|
||||||
|
|
||||||
|
var tokens = s.Split(',', StringSplitOptions.TrimEntries);
|
||||||
|
if (tokens.Length > 3)
|
||||||
|
return Raw(s);
|
||||||
|
if (tokens.Length == 3 && !string.Equals(tokens[2], "local", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Raw(s);
|
||||||
|
if (tokens.Length == 0 || string.IsNullOrEmpty(tokens[0]))
|
||||||
|
return Raw(s);
|
||||||
|
|
||||||
|
var domain = tokens[0];
|
||||||
|
if (tokens.Length == 1)
|
||||||
|
return new(domain, DomainRuleScope.Unconditional, "", false, null);
|
||||||
|
|
||||||
|
var second = tokens[1];
|
||||||
|
if (second.Length == 0)
|
||||||
|
return Raw(s);
|
||||||
|
|
||||||
|
if (tokens.Length == 2)
|
||||||
|
{
|
||||||
|
if (string.Equals(second, "local", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new(domain, DomainRuleScope.Unconditional, "", true, null);
|
||||||
|
if (IsLikelyInterface(second))
|
||||||
|
return new(domain, DomainRuleScope.InterfaceName, second, false, null);
|
||||||
|
return new(domain, DomainRuleScope.AddressRangeOrIp, second, false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(domain, DomainRuleScope.AddressRangeOrIp, second, true, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLikelyInterface(string second)
|
||||||
|
{
|
||||||
|
if (second.Contains('/') || IPAddress.TryParse(second, out _))
|
||||||
|
return false;
|
||||||
|
return DnsmasqRelaySyntax.IsInterfaceName(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DomainRuleFields Raw(string line) =>
|
||||||
|
new("", DomainRuleScope.Unconditional, "", false, line);
|
||||||
|
|
||||||
|
/// <summary>Structured editor "raw line" mode; <see cref="ToConfigLine"/> returns the line as-is (after trim).</summary>
|
||||||
|
public static DomainRuleFields FromRawConfigLine(string? line) =>
|
||||||
|
new("", DomainRuleScope.Unconditional, "", false, (line ?? "").Trim());
|
||||||
|
|
||||||
|
public string ToConfigLine()
|
||||||
|
{
|
||||||
|
if (IsRaw)
|
||||||
|
return (RawLineFallback ?? "").Trim();
|
||||||
|
|
||||||
|
var d = DomainPart.Trim();
|
||||||
|
if (d.Length == 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
return Scope switch
|
||||||
|
{
|
||||||
|
DomainRuleScope.Unconditional => AddLocalForRange ? $"{d},local" : d,
|
||||||
|
DomainRuleScope.InterfaceName =>
|
||||||
|
string.IsNullOrWhiteSpace(MiddlePart) ? d : $"{d},{MiddlePart.Trim()}",
|
||||||
|
DomainRuleScope.AddressRangeOrIp => BuildRangeLine(d),
|
||||||
|
_ => d,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildRangeLine(string d)
|
||||||
|
{
|
||||||
|
var m = MiddlePart.Trim();
|
||||||
|
if (m.Length == 0)
|
||||||
|
return AddLocalForRange ? $"{d},local" : d;
|
||||||
|
return AddLocalForRange ? $"{d},{m},local" : $"{d},{m}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Compact description for read-only UI (not the literal config line).</summary>
|
||||||
|
public static string FormatSummary(string? configLine)
|
||||||
|
{
|
||||||
|
var s = (configLine ?? "").Trim();
|
||||||
|
if (s.Length == 0)
|
||||||
|
return "(empty)";
|
||||||
|
|
||||||
|
var f = Parse(s);
|
||||||
|
if (f.IsRaw)
|
||||||
|
return s;
|
||||||
|
|
||||||
|
var dom = f.DomainPart == "#" ? "# (resolv.conf search)" : f.DomainPart;
|
||||||
|
if (f.Scope == DomainRuleScope.Unconditional)
|
||||||
|
return f.AddLocalForRange ? $"{dom} · local DNS" : dom;
|
||||||
|
|
||||||
|
if (f.Scope == DomainRuleScope.InterfaceName)
|
||||||
|
{
|
||||||
|
var iface = f.MiddlePart.Trim();
|
||||||
|
return iface.Length > 0 ? $"{dom} · interface {iface}" : dom;
|
||||||
|
}
|
||||||
|
|
||||||
|
var range = f.MiddlePart.Trim();
|
||||||
|
if (range.Length == 0)
|
||||||
|
return dom;
|
||||||
|
return f.AddLocalForRange ? $"{dom} · {range} · local DNS" : $"{dom} · {range}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,11 @@ public interface IHostsCache : IApplicationSingleton
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds unified page rows from the snapshot, preserving source awareness.
|
/// Builds unified page rows from the snapshot, preserving source awareness.
|
||||||
|
/// Effective-name expansion uses the config snapshot <c>domain=</c> list (including scoped rules)
|
||||||
|
/// per row address, matching dnsmasq expand-hosts behavior.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
||||||
bool expandHosts,
|
bool expandHosts,
|
||||||
string? domain,
|
|
||||||
bool noHosts,
|
bool noHosts,
|
||||||
string? managedHostsPath,
|
string? managedHostsPath,
|
||||||
CancellationToken ct = default);
|
CancellationToken ct = default);
|
||||||
|
|||||||
@@ -293,7 +293,6 @@ public sealed class HostsCache : IHostsCache, IDisposable
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
public async Task<IReadOnlyList<HostsPageRow>> GetUnifiedRowsAsync(
|
||||||
bool expandHosts,
|
bool expandHosts,
|
||||||
string? domain,
|
|
||||||
bool noHosts,
|
bool noHosts,
|
||||||
string? managedHostsPath,
|
string? managedHostsPath,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
@@ -302,8 +301,9 @@ public sealed class HostsCache : IHostsCache, IDisposable
|
|||||||
var rows = new List<HostsPageRow>();
|
var rows = new List<HostsPageRow>();
|
||||||
var managedPathFull = !string.IsNullOrEmpty(managedHostsPath) ? Path.GetFullPath(managedHostsPath) : null;
|
var managedPathFull = !string.IsNullOrEmpty(managedHostsPath) ? Path.GetFullPath(managedHostsPath) : null;
|
||||||
|
|
||||||
// Get config to determine hostsdir path
|
// Get config to determine hostsdir path and full domain= list (scoped expansion per address).
|
||||||
var configSnapshot = await _configSetCache.GetSnapshotAsync(ct);
|
var configSnapshot = await _configSetCache.GetSnapshotAsync(ct);
|
||||||
|
var domainValues = configSnapshot.Config?.DomainValues;
|
||||||
var hostsdirPath = configSnapshot.Config?.HostsdirPath;
|
var hostsdirPath = configSnapshot.Config?.HostsdirPath;
|
||||||
var hostsdirPathFull = !string.IsNullOrEmpty(hostsdirPath) ? Path.GetFullPath(hostsdirPath) : null;
|
var hostsdirPathFull = !string.IsNullOrEmpty(hostsdirPath) ? Path.GetFullPath(hostsdirPath) : null;
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ public sealed class HostsCache : IHostsCache, IDisposable
|
|||||||
{
|
{
|
||||||
if (entry.IsPassthrough) continue;
|
if (entry.IsPassthrough) continue;
|
||||||
var names = entry.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
|
var names = entry.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||||
var effectiveNames = HostsEffectiveNames.Expand(names, expandHosts, domain);
|
var effectiveNames = HostsEffectiveNames.Expand(names, expandHosts, domainValues, entry.Address ?? "");
|
||||||
rows.Add(new HostsPageRow(
|
rows.Add(new HostsPageRow(
|
||||||
Id: entry.Id ?? $"managed:{entry.LineNumber}",
|
Id: entry.Id ?? $"managed:{entry.LineNumber}",
|
||||||
SourceKind: HostsRowSourceKind.Managed,
|
SourceKind: HostsRowSourceKind.Managed,
|
||||||
@@ -341,7 +341,7 @@ public sealed class HostsCache : IHostsCache, IDisposable
|
|||||||
{
|
{
|
||||||
if (entry.IsPassthrough) continue;
|
if (entry.IsPassthrough) continue;
|
||||||
var names = entry.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
|
var names = entry.Names ?? (IReadOnlyList<string>)Array.Empty<string>();
|
||||||
var effectiveNames = HostsEffectiveNames.Expand(names, expandHosts, domain);
|
var effectiveNames = HostsEffectiveNames.Expand(names, expandHosts, domainValues, entry.Address ?? "");
|
||||||
rows.Add(new HostsPageRow(
|
rows.Add(new HostsPageRow(
|
||||||
Id: $"{sourceKind}:{file.Path}:{entry.LineNumber}",
|
Id: $"{sourceKind}:{file.Path}:{entry.LineNumber}",
|
||||||
SourceKind: sourceKind,
|
SourceKind: sourceKind,
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ public static class DnsmasqOptionTooltips
|
|||||||
[DnsmasqConfKeys.Do0x20Encode] = "Use 0x20 encoding in queries to detect DNS poisoning (Enabled), disable it (Disabled), or default (not set).",
|
[DnsmasqConfKeys.Do0x20Encode] = "Use 0x20 encoding in queries to detect DNS poisoning (Enabled), disable it (Disabled), or default (not set).",
|
||||||
|
|
||||||
// --- DNS records ---
|
// --- DNS records ---
|
||||||
[DnsmasqConfKeys.Domain] = "Local domain(s); optional IP. Can repeat (e.g. domain=home,192.168.1.1).",
|
[DnsmasqConfKeys.Domain] = "Domain suffix for DHCP, allowed host FQDNs, and expand-hosts; optional subnet, interface, or ,local. Can repeat.",
|
||||||
[DnsmasqConfKeys.Cname] = "CNAME records (alias, target). Can repeat.",
|
[DnsmasqConfKeys.Cname] = "CNAME records (alias, target). Can repeat.",
|
||||||
[DnsmasqConfKeys.MxHost] = "MX records (domain, host, priority). Can repeat.",
|
[DnsmasqConfKeys.MxHost] = "MX records (domain, host, priority). Can repeat.",
|
||||||
[DnsmasqConfKeys.MxTarget] = "Default target for MX records (used with localmx).",
|
[DnsmasqConfKeys.MxTarget] = "Default target for MX records (used with localmx).",
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ public interface IEffectiveConfigRenderFragmentRegistry : IApplicationSingleton
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a fragment that renders the custom multi-value component for this field, or null to use the generic list editor.
|
/// Builds a fragment that renders the custom multi-value component for this field, or null to use the generic list editor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
RenderFragment<EffectiveConfigFieldDescriptor>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> onValuesChanged);
|
RenderFragment<EffectiveConfigMultiDisplayContext>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> onValuesChanged);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a factory that creates a multi-value descriptor (e.g. <see cref="EffectiveMultiValueConfigFieldDescriptor"/>), or null to use the plain descriptor.
|
/// Returns a factory that creates a multi-value descriptor (e.g. <see cref="EffectiveMultiValueConfigFieldDescriptor"/>), or null to use the plain descriptor.
|
||||||
|
|||||||
+10
-6
@@ -192,8 +192,9 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
|
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigSections.SectionHosts,
|
EffectiveConfigSections.SectionHosts,
|
||||||
DnsmasqConfKeys.AddnHosts,
|
DnsmasqConfKeys.AddnHosts);
|
||||||
DnsmasqConfKeys.Domain);
|
RegisterSemanticMultiDescriptor(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain);
|
||||||
|
RegisterMultiComponent(EffectiveConfigSections.SectionHosts, DnsmasqConfKeys.Domain, typeof(DomainMultiValueDisplay));
|
||||||
RegisterSemanticMultis(
|
RegisterSemanticMultis(
|
||||||
EffectiveConfigSections.SectionResolver,
|
EffectiveConfigSections.SectionResolver,
|
||||||
DnsmasqConfKeys.Local,
|
DnsmasqConfKeys.Local,
|
||||||
@@ -433,16 +434,19 @@ public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragm
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public RenderFragment<EffectiveConfigFieldDescriptor>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> onValuesChanged)
|
public RenderFragment<EffectiveConfigMultiDisplayContext>? BuildMultiFieldComponentFragment(string sectionId, string optionName, EventCallback<IReadOnlyList<string>> onValuesChanged)
|
||||||
{
|
{
|
||||||
if (!_multiDisplayComponents.TryGetValue((sectionId, optionName), out var componentType))
|
if (!_multiDisplayComponents.TryGetValue((sectionId, optionName), out var componentType))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return descriptor => builder =>
|
var hasCallback = onValuesChanged.HasDelegate;
|
||||||
|
|
||||||
|
return context => builder =>
|
||||||
{
|
{
|
||||||
builder.OpenComponent(0, componentType);
|
builder.OpenComponent(0, componentType);
|
||||||
builder.AddAttribute(1, "Descriptor", descriptor);
|
builder.AddAttribute(1, "Context", context);
|
||||||
builder.AddAttribute(2, "OnValuesChanged", onValuesChanged);
|
if (hasCallback)
|
||||||
|
builder.AddAttribute(2, "OnValuesChanged", onValuesChanged);
|
||||||
builder.CloseComponent();
|
builder.CloseComponent();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,14 +31,10 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
|||||||
private async Task<IJSObjectReference?> GetModuleAsync()
|
private async Task<IJSObjectReference?> GetModuleAsync()
|
||||||
{
|
{
|
||||||
if (_module != null) return _module;
|
if (_module != null) return _module;
|
||||||
try
|
_module = await _jsRuntime.InvokeAsyncSafe<IJSObjectReference>(
|
||||||
{
|
"import", default, "./js/client-settings-storage.js");
|
||||||
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
|
if (_module == null)
|
||||||
"import", "./js/client-settings-storage.js");
|
_logger.LogDebug("Client settings JS module unavailable (prerender, disconnect, or import failure)");
|
||||||
}
|
|
||||||
catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS import skipped: prerender (no JS available)"); return null; }
|
|
||||||
catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS import skipped: circuit disconnected"); return null; }
|
|
||||||
catch (JSException ex) { _logger.LogDebug(ex, "JS import failed"); return null; }
|
|
||||||
return _module;
|
return _module;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using DnsmasqWebUI.Models.Dnsmasq;
|
||||||
|
|
||||||
|
namespace DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolved multi-value effective-config state for default and custom multi displays (baseline, pending, draft merge).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EffectiveConfigMultiDisplayContext(
|
||||||
|
EffectiveConfigFieldDescriptor Descriptor,
|
||||||
|
IReadOnlyList<ValueWithSource> BaselineItems,
|
||||||
|
IReadOnlyList<ValueWithSource> EffectiveItems,
|
||||||
|
IReadOnlyList<string> EffectiveValues,
|
||||||
|
bool HasPendingChange,
|
||||||
|
bool HasDraftChange,
|
||||||
|
bool HasPendingOrDraft,
|
||||||
|
ConfigValueSource? RowSource,
|
||||||
|
bool IsEditMode,
|
||||||
|
bool IsActiveEditor,
|
||||||
|
bool ShowEditableBadge,
|
||||||
|
string FieldKey)
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds effective multi display state: draft overrides pending overrides baseline items (with source preservation).
|
||||||
|
/// </summary>
|
||||||
|
public static EffectiveConfigMultiDisplayContext From(
|
||||||
|
EffectiveConfigFieldDescriptor descriptor,
|
||||||
|
IReadOnlyList<ValueWithSource>? baselineItems,
|
||||||
|
IReadOnlyList<string>? pendingList,
|
||||||
|
bool hasPendingChange,
|
||||||
|
IReadOnlyList<string>? draftList,
|
||||||
|
bool isEditMode,
|
||||||
|
bool isActive,
|
||||||
|
string fieldKey)
|
||||||
|
{
|
||||||
|
var baseline = baselineItems ?? Array.Empty<ValueWithSource>();
|
||||||
|
var baselineValues = baseline.Select(i => i.Value).ToList();
|
||||||
|
var hasDraft = draftList != null && !ValuesEqual(baselineValues, draftList);
|
||||||
|
|
||||||
|
IReadOnlyList<ValueWithSource> effectiveItems = hasDraft && draftList != null
|
||||||
|
? BuildItemsWithSource(baseline, draftList)
|
||||||
|
: hasPendingChange && pendingList != null
|
||||||
|
? BuildItemsWithSource(baseline, pendingList)
|
||||||
|
: baseline;
|
||||||
|
|
||||||
|
var allReadonly = effectiveItems.Count > 0 && effectiveItems.All(i => i.Source?.IsReadOnly == true);
|
||||||
|
var rowSource = allReadonly && !isEditMode ? effectiveItems.FirstOrDefault()?.Source : null;
|
||||||
|
|
||||||
|
return new EffectiveConfigMultiDisplayContext(
|
||||||
|
descriptor,
|
||||||
|
baseline,
|
||||||
|
effectiveItems,
|
||||||
|
effectiveItems.Select(i => i.Value).ToList(),
|
||||||
|
hasPendingChange,
|
||||||
|
hasDraft,
|
||||||
|
hasPendingChange || hasDraft,
|
||||||
|
rowSource,
|
||||||
|
isEditMode,
|
||||||
|
isEditMode && isActive && !descriptor.IsCapabilityDisabled,
|
||||||
|
!descriptor.IsCapabilityDisabled,
|
||||||
|
fieldKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ValueWithSource> BuildItemsWithSource(
|
||||||
|
IReadOnlyList<ValueWithSource> descriptorItems,
|
||||||
|
IReadOnlyList<string> values)
|
||||||
|
{
|
||||||
|
var pool = descriptorItems.ToList();
|
||||||
|
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 static bool ValuesEqual(IReadOnlyList<string> a, IReadOnlyList<string>? b)
|
||||||
|
{
|
||||||
|
var bb = b ?? Array.Empty<string>();
|
||||||
|
return a.SequenceEqual(bb, StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ namespace DnsmasqWebUI.Models.Hosts;
|
|||||||
/// <summary>Preview of names after <c>expand-hosts</c> (does not mutate stored names).</summary>
|
/// <summary>Preview of names after <c>expand-hosts</c> (does not mutate stored names).</summary>
|
||||||
public static class HostsEffectiveNames
|
public static class HostsEffectiveNames
|
||||||
{
|
{
|
||||||
private sealed record ScopedDomainRule(uint Start, uint End, string Domain);
|
private sealed record ScopedDomainRule(string Raw, uint Start, uint End, string Domain);
|
||||||
|
|
||||||
public static IReadOnlyList<string> Expand(
|
public static IReadOnlyList<string> Expand(
|
||||||
IReadOnlyList<string> names,
|
IReadOnlyList<string> names,
|
||||||
@@ -32,6 +32,13 @@ public static class HostsEffectiveNames
|
|||||||
return Expand(names, expandHosts, domain);
|
return Expand(names, expandHosts, domain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the suffix domain for expand-hosts, mirroring dnsmasq <c>read_hostsfile</c> → <c>get_domain</c> (<c>domain.c</c>).
|
||||||
|
/// Conditional <c>domain=</c> lines are stored in a singly-linked list by prepending each new rule
|
||||||
|
/// (<c>option.c</c>: <c>new->next = daemon->cond_domain</c>), and <c>search_domain</c> returns the first match
|
||||||
|
/// walking from the head — so <strong>later</strong> occurrences in the config (later files / later lines) win when
|
||||||
|
/// ranges overlap. <see cref="EffectiveDnsmasqConfig.DomainValues"/> keeps multi-value options in that read order.
|
||||||
|
/// </summary>
|
||||||
public static string? ResolveDomainForAddress(
|
public static string? ResolveDomainForAddress(
|
||||||
IReadOnlyList<string>? domainValues,
|
IReadOnlyList<string>? domainValues,
|
||||||
string? address)
|
string? address)
|
||||||
@@ -48,7 +55,7 @@ public static class HostsEffectiveNames
|
|||||||
if (!TryParseIpv4ToUInt(address, out var ip))
|
if (!TryParseIpv4ToUInt(address, out var ip))
|
||||||
return defaultDomain;
|
return defaultDomain;
|
||||||
|
|
||||||
// dnsmasq prepends conditional domains as it parses, then scans first-match.
|
// Walk scoped rules last-to-first in config order ≡ dnsmasq's head-first list walk (later line wins on overlap).
|
||||||
for (var i = scopedRules.Count - 1; i >= 0; i--)
|
for (var i = scopedRules.Count - 1; i >= 0; i--)
|
||||||
{
|
{
|
||||||
var rule = scopedRules[i];
|
var rule = scopedRules[i];
|
||||||
@@ -59,6 +66,88 @@ public static class HostsEffectiveNames
|
|||||||
return defaultDomain;
|
return defaultDomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Short explanation for UI tooltips: which domain= rule supplies the suffix for this row address.</summary>
|
||||||
|
public static string ExplainSuffixSource(
|
||||||
|
bool expandHosts,
|
||||||
|
IReadOnlyList<string>? domainValues,
|
||||||
|
string? address,
|
||||||
|
IReadOnlyList<string>? names)
|
||||||
|
{
|
||||||
|
if (!expandHosts)
|
||||||
|
return "expand-hosts is off; dnsmasq does not add a domain suffix to short names.";
|
||||||
|
|
||||||
|
if (domainValues == null || domainValues.Count == 0)
|
||||||
|
return "No domain= lines are configured; there is no suffix to append.";
|
||||||
|
|
||||||
|
var nameList = names ?? Array.Empty<string>();
|
||||||
|
if (nameList.Count == 0 || nameList.All(n => string.IsNullOrWhiteSpace(n)))
|
||||||
|
return "No hostnames on this row.";
|
||||||
|
|
||||||
|
var nonEmpty = nameList.Where(n => !string.IsNullOrWhiteSpace(n)).ToList();
|
||||||
|
if (nonEmpty.Count > 0 && nonEmpty.All(n => n.Contains('.')))
|
||||||
|
return "Every name already contains a dot; dnsmasq does not append a suffix (expand-hosts only affects simple names).";
|
||||||
|
|
||||||
|
ParseAllDomainValues(domainValues, out var defaultDomain, out var defaultRaw, out var scopedRules);
|
||||||
|
|
||||||
|
if (TryParseIpv4ToUInt(address, out var ip))
|
||||||
|
{
|
||||||
|
for (var i = scopedRules.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
var rule = scopedRules[i];
|
||||||
|
if (ip >= rule.Start && ip <= rule.End)
|
||||||
|
return $"Suffix from scoped rule: {rule.Raw}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(defaultDomain))
|
||||||
|
return string.IsNullOrWhiteSpace(defaultRaw)
|
||||||
|
? $"Suffix from default domain: {defaultDomain}"
|
||||||
|
: $"Suffix from default rule: {defaultRaw}";
|
||||||
|
|
||||||
|
return "No default domain= is set; this IPv4 address matched no scoped rule, so no suffix is applied in this preview.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 or non-IP: dnsmasq host expansion uses default suffix only (see read_hostsfile in dnsmasq).
|
||||||
|
if (!string.IsNullOrWhiteSpace(defaultDomain))
|
||||||
|
return string.IsNullOrWhiteSpace(defaultRaw)
|
||||||
|
? $"Non-IPv4 address: preview uses default domain only: {defaultDomain}"
|
||||||
|
: $"Non-IPv4 address: preview uses default rule: {defaultRaw}";
|
||||||
|
|
||||||
|
return "Non-IPv4 address and no default domain=; no suffix in this preview.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseAllDomainValues(
|
||||||
|
IReadOnlyList<string> domainValues,
|
||||||
|
out string? defaultDomain,
|
||||||
|
out string? defaultRaw,
|
||||||
|
out List<ScopedDomainRule> scopedRules)
|
||||||
|
{
|
||||||
|
defaultDomain = null;
|
||||||
|
defaultRaw = null;
|
||||||
|
scopedRules = new List<ScopedDomainRule>();
|
||||||
|
|
||||||
|
foreach (var raw in domainValues)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
continue;
|
||||||
|
var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length == 0)
|
||||||
|
continue;
|
||||||
|
var domain = NormalizeDomain(parts[0]);
|
||||||
|
if (string.IsNullOrWhiteSpace(domain))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (parts.Length == 1)
|
||||||
|
{
|
||||||
|
defaultDomain = domain;
|
||||||
|
defaultRaw = raw.Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryParseScopedRange(parts, out var start, out var end))
|
||||||
|
scopedRules.Add(new ScopedDomainRule(raw.Trim(), start, end, domain));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ParseDomainValue(
|
private static void ParseDomainValue(
|
||||||
string? raw,
|
string? raw,
|
||||||
ICollection<ScopedDomainRule> scopedRules,
|
ICollection<ScopedDomainRule> scopedRules,
|
||||||
@@ -82,7 +171,7 @@ public static class HostsEffectiveNames
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (TryParseScopedRange(parts, out var start, out var end))
|
if (TryParseScopedRange(parts, out var start, out var end))
|
||||||
scopedRules.Add(new ScopedDomainRule(start, end, domain));
|
scopedRules.Add(new ScopedDomainRule(raw.Trim(), start, end, domain));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? NormalizeDomain(string value)
|
private static string? NormalizeDomain(string value)
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# Test data
|
# Test data
|
||||||
|
|
||||||
Source for the Docker test harness: `scripts/prepare-test-mount.sh` syncs this dir to `testdata-mount/`, which is bind-mounted as `/data`. Unit tests use the copy in the test output dir (copied at build via the .csproj).
|
Source for the Docker test harness: `scripts/prepare-test-mount.sh` syncs this dir to `testdata-mount/`, which is bind-mounted as `/data`. The sync **excludes** `leases`, `zz-dnsmasq-webui.conf`, and `zz-dnsmasq-webui.hosts` so image rebuilds keep the live lease file and app-managed config/hosts in the mount; use `--clear` or `--reset-managed` on the script when you want a clean slate. Unit tests use the copy in the test output dir (copied at build via the .csproj), including any committed `zz-dnsmasq-webui.conf` sample at repo root for fixtures.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user