diff --git a/README.md b/README.md index d4666fd..fa84a25 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # dnsmasq-webui
- dnsmasq-webui logo + dnsmasq-webui logo
A self-hosted web UI for managing [dnsmasq](https://thekelleys.org.uk/dnsmasq/doc.html) configuration and hosts. It runs alongside your existing dnsmasq setup and edits the same config files; it does not replace dnsmasq. @@ -70,7 +70,7 @@ On systemd-based systems you can install a unit so the app runs as a service. Us sudo ./scripts/install.sh --system --service # System service (starts at boot) ``` -After install: configure the app (see [Configuration](#configuration)), then start the service: `systemctl --user start dnsmasq-webui` or `sudo systemctl start dnsmasq-webui`. User services run only when you’re logged in unless you enable linger: `loginctl enable-linger`. +After install: configure the app (see [Configuration](#configuration)), then start the service: `systemctl --user start dnsmasq-webui` or `sudo systemctl start dnsmasq-webui`. User services run only when you’re logged in unless you enable linger: `loginctl enable-linger`. If you add `--service` later or switch from user to system service (or vice versa), the script removes the previous service type before installing the new one. **Update to latest release:** @@ -80,6 +80,15 @@ After install: configure the app (see [Configuration](#configuration)), then sta Reinstalls the latest release into the default user directory (~/.local/share/dnsmasq-webui). +**Uninstall:** + +```bash +./scripts/install.sh --uninstall # Remove services and symlinks only (keeps install dir) +./scripts/install.sh --uninstall --purge # Also remove default install dir (~/.local/share/dnsmasq-webui) +sudo ./scripts/install.sh --uninstall --purge --system # Also remove /opt/dnsmasq-webui +./scripts/install.sh --uninstall --purge --dir /path/to/dir # Purge a specific install dir +``` + **Custom directory or specific version:** ```bash @@ -203,7 +212,7 @@ All scripts live under `scripts/` and are intended for Linux (WSL may work but i ### scripts/install.sh -**Purpose:** Download and install dnsmasq-webui from a GitHub release for the current OS/arch (RID). Supports install, **update** (reinstall latest to default dir), and switch-release (re-run with same `--dir` and different `--version`). Default install is user-writable (~/.local/share/dnsmasq-webui) with an optional symlink so you can run `dnsmasq-webui` from the terminal; use `--system` for a system-wide install to /opt (requires sudo). Use `--service` to install a systemd unit (user service without sudo, or system service with `--system`); only supported on systemd-based systems. +**Purpose:** Download and install dnsmasq-webui from a GitHub release for the current OS/arch (RID). Supports install, **update** (reinstall latest to default dir), switch-release (re-run with same `--dir` and different `--version`), and **uninstall** (remove services and symlinks; use `--purge` to also remove the install directory). Default install is user-writable (~/.local/share/dnsmasq-webui) with an optional symlink so you can run `dnsmasq-webui` from the terminal; use `--system` for a system-wide install to /opt (requires sudo). Use `--service` to install a systemd unit (user service without sudo, or system service with `--system`); only supported on systemd-based systems. When installing a service, the script removes the other type first (e.g. switching from user to system service cleans up the user unit). **Usage:** `./scripts/install.sh [OPTIONS]` @@ -217,9 +226,59 @@ All scripts live under `scripts/` and are intended for Linux (WSL may work but i | `--update` | Reinstall latest into the default user directory (~/.local/share/dnsmasq-webui). | | `--dir DIR` | Install into DIR instead of default. | | `--system` | Install to /opt/dnsmasq-webui and symlink /usr/local/bin/dnsmasq-webui. Requires root (run with sudo). | -| `--service` | Install a systemd unit so the app runs as a service. With `--system`: system unit (requires root, starts at boot). Without: user unit (no sudo, starts at login). Only on systemd-based systems. | +| `--service` | Install a systemd unit so the app runs as a service. With `--system`: system unit (requires root, starts at boot). Without: user unit (no sudo, starts at login). Removes the other type first if present. Only on systemd-based systems. | +| `--uninstall` | Remove systemd units and symlinks (user and, if root, system). Does not remove the install directory. | +| `--purge` | With `--uninstall` only: also remove the install directory. Use `--dir DIR` or `--system` to target a specific location. Errors if used without `--uninstall`. | | `-h`, `-?`, `--help` | Show help. | +Invalid combinations (script errors with a clear message): `--purge` without `--uninstall`; `--uninstall` with `--version`, `--update`, or `--service`; `--list` with install/uninstall options. + +**How to use the script** + +1. **Run one command to get a runnable binary for your OS/arch** (no repo or build choice needed). + Script detects RID and default repo (or git origin), downloads the matching zip, extracts it, and creates a symlink when possible. + **Commands:** `curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/main/scripts/install.sh | sh` or `./scripts/install.sh` + +2. **Install into your home directory by default** (no root). + Default is `~/.local/share/dnsmasq-webui`; symlink in `~/.local/bin` if writable. + **Command:** `./scripts/install.sh` + +3. **Install to /opt and have `dnsmasq-webui` in PATH for all users.** + Script requires root, then installs to `/opt/dnsmasq-webui` and symlinks `/usr/local/bin/dnsmasq-webui`. + **Command:** `sudo ./scripts/install.sh --system` + +4. **Install a systemd unit that starts at boot** so the UI runs as a service. + Script checks root and systemd, installs and enables the system unit, and removes the invoking user's user unit so you do not have both. + **Command:** `sudo ./scripts/install.sh --system --service` + +5. **Install a user systemd unit (no sudo)** that starts when you log in. + Script installs and enables the user unit and removes any existing user unit; if a system unit exists it tells you how to remove it. + **Command:** `./scripts/install.sh --service` + +6. **Upgrade your existing install to the latest release** without changing where it is installed. + `--update` keeps your current target (default dir, `--dir`, or `--system`) and installs latest there. + **Commands:** `./scripts/install.sh --update` or `sudo ./scripts/install.sh --update --system` or `./scripts/install.sh --update --dir /path` + +7. **Switch from user service to system service (or the other way)** without manually removing the old unit. + Installing one type removes the other for that scope (or tells you how to remove the system unit when installing user). + **Commands:** `sudo ./scripts/install.sh --system --service` (user to system) or `./scripts/install.sh --service` (system to user; run `sudo ./scripts/install.sh --uninstall` first if you had a system unit). + +8. **Uninstall cleanly:** remove services and symlinks, and optionally delete the install directory. + `--uninstall` removes units and symlinks; `--purge` also removes one install dir, and you must say which (default, `--dir`, or `--system`). + **Commands:** `./scripts/install.sh --uninstall`, `./scripts/install.sh --uninstall --purge`, `sudo ./scripts/install.sh --uninstall --purge --system`, or `./scripts/install.sh --uninstall --purge --dir /path` + +9. **List available releases** to choose which version to install. + Script lists release tags and dates; list-only mode cannot be combined with install/uninstall so you do not accidentally change the system. + **Command:** `./scripts/install.sh --list` + +10. **Install from a fork or a specific GitHub repo** instead of the default. + You pass the repo; script uses it for the API. Without a clone or `--repo`/GITHUB_REPO it errors so you fix the command instead of getting a confusing API failure. + **Command:** `./scripts/install.sh --repo owner/dnsmasq-webui` or `GITHUB_REPO=owner/dnsmasq-webui ./scripts/install.sh` + +11. **Install a specific release tag** (e.g. an older or pinned version) instead of latest. + Script fetches that tag's release and the asset for your RID. If no asset matches your OS/arch it errors and prints the asset names so you can pick another tag or report a missing build. + **Command:** `./scripts/install.sh --version v1.0.0` + **Examples:** ```bash @@ -239,6 +298,11 @@ sudo ./scripts/install.sh --system ./scripts/install.sh --service sudo ./scripts/install.sh --system --service +# Uninstall (services + symlinks only; add --purge to remove install dir) +./scripts/install.sh --uninstall +./scripts/install.sh --uninstall --purge +sudo ./scripts/install.sh --uninstall --purge --system + # Install to custom dir ./scripts/install.sh --dir /opt/dnsmasq-webui @@ -249,6 +313,29 @@ sudo ./scripts/install.sh --system --service ./scripts/install.sh --repo alexhopeoconnor/dnsmasq-webui --list ``` +**How the script works and avoids mistakes** + +1. **Modes are exclusive** + The script does one of: list releases, uninstall, or install (including update/switch-release). It errors if you mix modes, e.g. `--uninstall` with `--update` or `--service`, or `--list` with install/uninstall options. `--purge` is only valid with `--uninstall`; using `--purge` alone errors. + +2. **Privilege checks** + `--system` (install to /opt and system-wide symlink) and `--system --service` (system systemd unit) require root. The script checks `id -u` and exits with a clear message (“Run with sudo: sudo $0 --system”) instead of failing partway. Uninstall with `--purge --system` also requires root and is checked before removing anything. + +3. **Service switching** + When you install a systemd unit, the script removes the *other* type first: installing a system service stops/disables and removes the invoking user’s user unit (via `SUDO_USER`), and installing a user service leaves any system unit in place but prints a note on how to remove it. You never end up with both user and system units for the same user by accident. + +4. **Update and target dir** + `--update` means “reinstall latest.” If you also pass `--dir DIR` or `--system`, that target is used (e.g. `--update --dir /opt/foo` or `sudo ./install.sh --update --system`). So upgrade-in-place is predictable and you don’t overwrite a different install. + +5. **Uninstall is explicit** + `--uninstall` removes only systemd units and symlinks unless you add `--purge`. With `--purge`, you must say *which* install to remove: default user dir (no extra flags), `--dir DIR`, or `--system` for /opt. That avoids accidentally deleting the wrong directory. + +6. **Repo and release** + When not in a git clone, the script needs a repo (e.g. `REPO_DEFAULT` in the one-liner or `--repo` / `GITHUB_REPO`). It errors with a clear message instead of hitting the GitHub API with an empty repo. If no asset matches your OS/arch (RID), it prints the available asset names so you can pick a different release or report a missing build. + +7. **Dependencies** + The script requires `curl`, `jq`, and `unzip`. It checks for `jq` before calling the GitHub API and errors with an install hint. `--service` requires `systemctl` (systemd) and errors on non-systemd systems instead of writing a unit that won’t be used. + --- ### scripts/publish-self-contained.sh diff --git a/scripts/install.sh b/scripts/install.sh index b2a0814..4b0d5f7 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -13,6 +13,8 @@ LIST=false INSTALL_DIR="" SYSTEM_INSTALL=false SERVICE=false +UNINSTALL=false +PURGE=false UPDATE=false # Default install dir (user-writable, no sudo). Overridden by --dir or --system. @@ -44,8 +46,15 @@ usage() { echo "Service (systemd):" echo " --service Install a systemd unit so the app can run as a service (start on boot)." echo " With --system: installs system unit (requires root). Without: installs user unit (no sudo)." + echo " If you already have the other type (user vs system), it is removed first." echo " Only supported on systemd-based systems; errors if systemd is not available." echo "" + echo "Uninstall:" + echo " --uninstall Remove symlinks and systemd units (user and, if root, system). Does not remove install directory." + echo " --purge With --uninstall: also remove the install directory (use --dir or --system to specify which)." + echo " E.g. $0 --uninstall --purge # remove default user dir" + echo " sudo $0 --uninstall --purge --system # also remove /opt/dnsmasq-webui" + echo "" echo "Examples:" echo " $0 # Install latest (clone: auto repo; curl: use REPO_DEFAULT or --repo)" echo " $0 --update # Reinstall latest to default dir (upgrade)" @@ -54,6 +63,9 @@ usage() { echo " sudo $0 --system # System-wide install to /opt, runnable as dnsmasq-webui" echo " $0 --service # Install + user systemd service (starts when you log in)" echo " sudo $0 --system --service # Install + system systemd service (starts at boot)" + echo " $0 --uninstall # Remove services and symlinks only" + echo " $0 --uninstall --purge # Remove services, symlinks, and default install dir" + echo " sudo $0 --uninstall --purge --system # Also remove /opt/dnsmasq-webui" echo " $0 --list # List releases" echo "" echo "After install, configure via appsettings.json or Dnsmasq__* environment variables, then run the binary (or dnsmasq-webui if symlink created). If you used --service, enable/start with systemctl." @@ -172,6 +184,92 @@ find_asset_url() { ' | head -n1 } +# Remove user systemd unit (stop, disable, rm file). No root. Safe if not present. +remove_user_service() { + if ! command -v systemctl >/dev/null 2>&1; then + return 0 + fi + systemctl --user stop dnsmasq-webui.service 2>/dev/null || true + systemctl --user disable dnsmasq-webui.service 2>/dev/null || true + rm -f "${HOME:-}/.config/systemd/user/dnsmasq-webui.service" 2>/dev/null || true + systemctl --user daemon-reload 2>/dev/null || true +} + +# Remove system systemd unit (stop, disable, rm file). Requires root. Safe if not present. +remove_system_service() { + if [ "$(id -u)" -ne 0 ]; then + return 0 + fi + if ! command -v systemctl >/dev/null 2>&1; then + return 0 + fi + systemctl stop dnsmasq-webui.service 2>/dev/null || true + systemctl disable dnsmasq-webui.service 2>/dev/null || true + rm -f /etc/systemd/system/dnsmasq-webui.service 2>/dev/null || true + systemctl daemon-reload 2>/dev/null || true +} + +# Remove user service for a specific user (by name). Call as root with SUDO_USER. +# Used when installing system service so the invoking user doesn't keep a user unit. +remove_user_service_for() { + local u + u="$1" + [ -n "$u" ] || return 0 + if [ "$(id -u)" -ne 0 ]; then + return 0 + fi + # Run as that user to stop/disable and remove their user unit + su "$u" -c 'systemctl --user stop dnsmasq-webui.service 2>/dev/null; systemctl --user disable dnsmasq-webui.service 2>/dev/null; rm -f ~/.config/systemd/user/dnsmasq-webui.service; systemctl --user daemon-reload 2>/dev/null' 2>/dev/null || true +} + +# Uninstall: remove services, symlinks, optionally purge install dir(s). +do_uninstall() { + echo "Uninstalling dnsmasq-webui..." + # Remove user service (current user) + remove_user_service + # Remove system service (if root) + remove_system_service + # Remove symlinks + if [ -n "${HOME:-}" ] && [ -L "${HOME}/.local/bin/dnsmasq-webui" ]; then + rm -f "${HOME}/.local/bin/dnsmasq-webui" + echo "Removed symlink ~/.local/bin/dnsmasq-webui" + fi + if [ "$(id -u)" -eq 0 ] && [ -L /usr/local/bin/dnsmasq-webui ]; then + rm -f /usr/local/bin/dnsmasq-webui + echo "Removed symlink /usr/local/bin/dnsmasq-webui" + fi + if [ "$PURGE" = true ]; then + if [ -n "$INSTALL_DIR" ]; then + if [ -d "$INSTALL_DIR" ]; then + rm -rf "$INSTALL_DIR" + echo "Removed directory $INSTALL_DIR" + else + echo "Directory $INSTALL_DIR not found." + fi + elif [ "$SYSTEM_INSTALL" = true ] && [ "$(id -u)" -eq 0 ]; then + if [ -d /opt/dnsmasq-webui ]; then + rm -rf /opt/dnsmasq-webui + echo "Removed directory /opt/dnsmasq-webui" + else + echo "Directory /opt/dnsmasq-webui not found." + fi + else + local default_dir + default_dir="$(default_install_dir)" + if [ -d "$default_dir" ]; then + rm -rf "$default_dir" + echo "Removed directory $default_dir" + else + echo "Directory $default_dir not found." + fi + fi + else + echo "Services and symlinks removed. To also remove the install directory, run with --purge (and --dir or --system if needed)." + fi + echo "Uninstall complete." + exit 0 +} + # Main install: fetch release, find asset for RID, download, extract, optionally symlink. do_install() { check_jq @@ -250,6 +348,7 @@ do_install() { } # Install systemd unit. Call only when SERVICE=true and systemctl exists. +# Removes the other service type first (user vs system) so switching works cleanly. # $1 = INSTALL_DIR (where DnsmasqWebUI binary and appsettings.json live) install_systemd_unit() { local dir bin @@ -260,6 +359,11 @@ install_systemd_unit() { return 0 fi if [ "$SYSTEM_INSTALL" = true ]; then + # Installing system service: remove existing system unit (idempotent), then remove user unit for whoever ran sudo so they don't have both + remove_system_service + if [ -n "${SUDO_USER:-}" ]; then + remove_user_service_for "$SUDO_USER" + fi cat > /etc/systemd/system/dnsmasq-webui.service << EOF [Unit] Description=dnsmasq-webui - Web UI for dnsmasq @@ -284,6 +388,12 @@ EOF echo " sudo systemctl enable dnsmasq-webui # already enabled for boot" echo " sudo systemctl status dnsmasq-webui # check status" else + # Installing user service: remove existing user unit (idempotent) + remove_user_service + # We cannot remove system service without root; remind if they might have had one + if [ -f /etc/systemd/system/dnsmasq-webui.service ] 2>/dev/null; then + echo "Note: A system-wide service also exists. To remove it and use only this user service: sudo $0 --uninstall (then re-run without --system --service)." >&2 + fi mkdir -p "${HOME:-}/.config/systemd/user" cat > "${HOME:-}/.config/systemd/user/dnsmasq-webui.service" << EOF [Unit] @@ -352,6 +462,14 @@ while [ $# -gt 0 ]; do SERVICE=true shift ;; + --uninstall) + UNINSTALL=true + shift + ;; + --purge) + PURGE=true + shift + ;; -*) echo "Error: unknown option $1" >&2 usage >&2 @@ -365,12 +483,39 @@ while [ $# -gt 0 ]; do esac done +# Validate option combinations to prevent mistakes +if [ "$PURGE" = true ] && [ "$UNINSTALL" != true ]; then + echo "Error: --purge must be used with --uninstall. E.g. $0 --uninstall --purge" >&2 + exit 1 +fi +if [ "$UNINSTALL" = true ]; then + if [ "$UPDATE" = true ] || [ -n "$VERSION" ] || [ "$SERVICE" = true ]; then + echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service). Run uninstall alone, then install if needed." >&2 + exit 1 + fi + if [ "$LIST" = true ]; then + echo "Error: --uninstall cannot be combined with --list." >&2 + exit 1 + fi +fi if [ "$LIST" = true ]; then + if [ "$UNINSTALL" = true ] || [ "$UPDATE" = true ] || [ "$SERVICE" = true ] || [ -n "$VERSION" ]; then + echo "Error: --list lists releases and exits; do not combine with install/uninstall options." >&2 + exit 1 + fi list_releases fi +if [ "$UNINSTALL" = true ]; then + if [ "$PURGE" = true ] && [ "$SYSTEM_INSTALL" = true ] && [ "$(id -u)" -ne 0 ]; then + echo "Error: --uninstall --purge --system requires root. Run with sudo." >&2 + exit 1 + fi + do_uninstall +fi + if [ "$UPDATE" = true ]; then - INSTALL_DIR="$(default_install_dir)" + [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$(default_install_dir)" VERSION="latest" fi diff --git a/src/DnsmasqWebUI/Components/App.razor b/src/DnsmasqWebUI/Components/App.razor index fd5425d..b529541 100644 --- a/src/DnsmasqWebUI/Components/App.razor +++ b/src/DnsmasqWebUI/Components/App.razor @@ -11,8 +11,12 @@ - - + + + + + + @_appTitle diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/FlagValueDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/FlagValueDisplay.razor new file mode 100644 index 0000000..d1e2def --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/FlagValueDisplay.razor @@ -0,0 +1,24 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays +@using DnsmasqWebUI.Models.EffectiveConfig + +@* Reusable value-only display for boolean/flag options. Edit mode: checkbox (unless readonly); View: "Enabled" / "Disabled". *@ +@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true) +{ + var isSet = Descriptor.GetValue() is bool b && b; + +} +else +{ + @(Descriptor.GetValue() is bool isSet && isSet ? "Enabled" : "Disabled") +} + +@code { + [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; + [CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; + [Parameter] public EventCallback CheckedChanged { get; set; } + + private async Task OnCheckedChange(ChangeEventArgs e) + { + await CheckedChanged.InvokeAsync(e.Value is true); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/PortValueDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/PortValueDisplay.razor new file mode 100644 index 0000000..93340d4 --- /dev/null +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/CustomDisplays/PortValueDisplay.razor @@ -0,0 +1,27 @@ +@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays +@using DnsmasqWebUI.Models.EffectiveConfig + +@* Value-only display for the port option. Edit mode: number input (unless readonly); View: text with "(default DNS port)". *@ +@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Descriptor.GetSource()?.IsReadOnly != true) +{ + var port = Descriptor.GetValue() is int p ? p : 53; + +} +else +{ + @(Descriptor.GetValue() is int port ? $"{port} (default DNS port)" : "(not set)") +} + +@code { + [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; + [CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; + [Parameter] public EventCallback PortChanged { get; set; } + + private async Task OnPortInput(ChangeEventArgs e) + { + if (int.TryParse(e.Value?.ToString(), out var port) && port >= 1 && port <= 65535) + await PortChanged.InvokeAsync(port); + else + await PortChanged.InvokeAsync(null); + } +} diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor index 0e051c1..8d2154c 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigFieldDisplay.razor @@ -1,18 +1,44 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.EffectiveConfig +@using DnsmasqWebUI.Models.Config +@using DnsmasqWebUI.Services.Abstractions +@inject IEffectiveConfigRenderFragmentRegistry RenderFragmentRegistry -@if (Descriptor.IsMultiValue) +@* Custom fragment renders only the value; we keep the row shell (label + value slot + badge) here for consistency. *@ +@if (_customFragment is { } fragment) +{ + var labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName); +
  • + @Descriptor.OptionName: + @fragment(Descriptor) + @if (Descriptor.GetSource()?.IsReadOnly == true) + { + readonly + } +
  • +} +else if (Descriptor.IsMultiValue) { var items = Descriptor.GetItems(); - + } else { var value = Descriptor.GetValue(); var displayValue = value == null ? "(not set)" : (value is IReadOnlyList list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString()); - + } @code { [Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!; + [CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; + + private RenderFragment? _customFragment; + private string? _labelTooltip; + + protected override void OnParametersSet() + { + _customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName); + _labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName); + } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor index a30f6ba..4568059 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueItem.razor @@ -1,7 +1,7 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.EffectiveConfig - + @Value @if (Source?.IsReadOnly == true) { diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor index 0fa6d66..7eb6ac5 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigMultiValueRow.razor @@ -1,24 +1,56 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.EffectiveConfig -
  • - @Label: - @if (Items?.Count > 0) +
  • + @Label: + @if (DisplayMode == EffectiveConfigDisplayMode.Edit) { - @for (var i = 0; i < Items.Count; i++) - { - if (i > 0) { , } - var item = Items[i]; - - } + + + } else { - (none) + + @if (Items?.Count > 0) + { +
      + @foreach (var item in Items!) + { +
    1. + } +
    + } + else + { + (none) + } +
    }
  • @code { [Parameter] public string Label { get; set; } = ""; [Parameter] public IReadOnlyList? Items { get; set; } + [Parameter] public EventCallback> ItemsChanged { get; set; } + [Parameter] public string? LabelTooltip { get; set; } + [CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; + + private string _editLines = ""; + + protected override void OnParametersSet() + { + if (Items != null && Items.Count > 0) + _editLines = string.Join("\n", Items.Select(i => i.Value)); + else + _editLines = ""; + } + + private async Task OnLinesInput(ChangeEventArgs e) + { + var text = e.Value?.ToString() ?? ""; + _editLines = text; + var lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(l => l.Trim()).Where(l => l.Length > 0).ToList(); + await ItemsChanged.InvokeAsync(lines); + } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor index 6ecbbe3..22f1a48 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigOptionItem.razor @@ -1,10 +1,21 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig +@using DnsmasqWebUI.Models.EffectiveConfig -
  • - @Label: @DisplayValue - @if (Source?.IsReadOnly == true) +
  • + @Label: + @if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true) { - readonly + + + + } + else + { + @DisplayValue + @if (Source?.IsReadOnly == true) + { + readonly + } }
  • @@ -12,4 +23,15 @@ [Parameter] public string Label { get; set; } = ""; [Parameter] public string DisplayValue { get; set; } = ""; [Parameter] public ConfigValueSource? Source { get; set; } + /// Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips). + [Parameter] public string? LabelTooltip { get; set; } + /// When Edit and not readonly: raised when the user changes the value. Not yet wired to save. + [Parameter] public EventCallback DisplayValueChanged { get; set; } + [CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; + + private async Task OnValueInput(ChangeEventArgs e) + { + var value = e.Value?.ToString() ?? ""; + await DisplayValueChanged.InvokeAsync(value); + } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor index 5b7177b..e818452 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor @@ -21,7 +21,7 @@ var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList(); if (sectionDescriptors.Count > 0) { - + } } @@ -29,6 +29,8 @@ @code { [Parameter] public DnsmasqServiceStatus? Status { get; set; } + /// View = read-only; Edit = editable (future). Cascaded to field displays. + [Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; private HashSet _openPanels = new(); diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css index 2855024..ebfff7d 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSection.razor.css @@ -14,14 +14,14 @@ border-bottom: none; } -/* Touch-friendly header: min 44px tap target (WCAG 2.5.5) */ +/* Touch-friendly header: min 44px tap target (WCAG 2.5.5), breathing room from edges */ ::deep .ec-section-header { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 2.75rem; - padding: 0.5rem 0; + padding: 0.625rem 1rem; margin: 0; border: none; border-radius: 0.25rem; @@ -75,9 +75,10 @@ } ::deep .ec-section-body { - padding: 0.25rem 0 0.5rem 0; - padding-left: 0.5rem; + padding: 0.25rem 1rem 0.5rem 1rem; + padding-left: 1rem; border-left: 2px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.075)); + margin-left: 0; overflow-wrap: break-word; word-break: break-word; } @@ -93,8 +94,69 @@ word-break: break-word; } +/* Field row: label + value + optional badge. Same structure for custom and default displays. */ +::deep .ec-field { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.5rem; + margin-bottom: 0.35rem; +} + +::deep .ec-field:last-child { + margin-bottom: 0; +} + +::deep .ec-field-label { + flex-shrink: 0; + min-width: 0; +} + +::deep .ec-field-value { + flex: 1 1 auto; + min-width: 0; + overflow-wrap: break-word; + word-break: break-word; +} + +/* Multi-value: full width below label, indented ordered list */ +::deep .ec-field-value-multi { + flex-basis: 100%; + padding-left: 1.25rem; + margin-top: 0.15rem; +} + +::deep .ec-field-value-list { + margin: 0; + padding-left: 1.25rem; + list-style-type: decimal; +} + +::deep .ec-field-value-list li { + margin-bottom: 0.2rem; + overflow-wrap: break-word; + word-break: break-word; +} + +::deep .ec-field-value-list li:last-child { + margin-bottom: 0; +} + +::deep .ec-field-value-item { + display: inline; +} + +::deep .ec-field-value-sep { + margin-right: 0.25rem; +} + @media (max-width: 767.98px) { + ::deep .ec-section-header { + padding-left: 0.75rem; + padding-right: 0.75rem; + } ::deep .ec-section-body { - padding-left: 0.35rem; + padding-left: 0.75rem; + padding-right: 0.75rem; } } diff --git a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor index 20cde5f..f7e524b 100644 --- a/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor +++ b/src/DnsmasqWebUI/Components/Dnsmasq/EffectiveConfig/EffectiveConfigSectionPanel.razor @@ -1,22 +1,24 @@ @namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig @using DnsmasqWebUI.Models.EffectiveConfig -
    - -
    -
    -
      - @foreach (var f in Descriptors) - { - - } -
    + +
    + +
    +
    +
      + @foreach (var f in Descriptors) + { + + } +
    +
    -
    + @code { [Parameter] public string SectionId { get; set; } = ""; @@ -24,6 +26,8 @@ [Parameter] public IReadOnlyList Descriptors { get; set; } = null!; [Parameter] public bool IsOpen { get; set; } [Parameter] public EventCallback OnToggle { get; set; } + /// Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future). + [Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View; private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId); } diff --git a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor index 1860cb2..d315eba 100644 --- a/src/DnsmasqWebUI/Components/Layout/NavMenu.razor +++ b/src/DnsmasqWebUI/Components/Layout/NavMenu.razor @@ -4,7 +4,7 @@