Prepare release 0.0.1: install script (uninstall/purge, service, validation), README (how to use, config), UI and effective config updates

This commit is contained in:
2026-02-05 00:05:52 +10:00
parent 8c411b8d09
commit 4ff9c552eb
28 changed files with 633 additions and 46 deletions
+91 -4
View File
@@ -1,7 +1,7 @@
# dnsmasq-webui
<div align="center">
<img src="src/DnsmasqWebUI/wwwroot/logo.png" alt="dnsmasq-webui logo" width="200">
<img src="src/DnsmasqWebUI/wwwroot/logo.webp" alt="dnsmasq-webui logo" width="200">
</div>
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
+146 -1
View File
@@ -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
+6 -2
View File
@@ -11,8 +11,12 @@
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["DnsmasqWebUI.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" sizes="32x32" />
<link rel="apple-touch-icon" href="logo.png" />
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="favicon-48.png" />
<link rel="icon" type="image/png" sizes="192x192" href="favicon-192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="apple-touch-icon.png" />
<title>@_appTitle</title>
<HeadOutlet />
</head>
@@ -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;
<input type="checkbox" class="form-check-input" checked="@isSet" @onchange="OnCheckedChange" />
}
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<bool> CheckedChanged { get; set; }
private async Task OnCheckedChange(ChangeEventArgs e)
{
await CheckedChanged.InvokeAsync(e.Value is true);
}
}
@@ -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;
<input type="number" class="form-control form-control-sm" style="max-width: 6rem;" min="1" max="65535" value="@port" @oninput="OnPortInput" />
}
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<int?> 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);
}
}
@@ -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);
<li class="ec-field" title="@(Descriptor.GetSource()?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(labelTooltip ?? Descriptor.GetSource()?.GetReadOnlyTooltip())"><strong>@Descriptor.OptionName:</strong></span>
<span class="ec-field-value">@fragment(Descriptor)</span>
@if (Descriptor.GetSource()?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
</li>
}
else if (Descriptor.IsMultiValue)
{
var items = Descriptor.GetItems();
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" />
<EffectiveConfigMultiValueRow Label="@Descriptor.OptionName" Items="@items" LabelTooltip="@_labelTooltip" />
}
else
{
var value = Descriptor.GetValue();
var displayValue = value == null ? "(not set)" : (value is IReadOnlyList<string> list ? (list.Count == 0 ? "(none)" : string.Join(", ", list)) : value.ToString());
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" />
<EffectiveConfigOptionItem Label="@Descriptor.OptionName" DisplayValue="@(displayValue ?? "(not set)")" Source="@Descriptor.GetSource()" LabelTooltip="@_labelTooltip" />
}
@code {
[Parameter] public EffectiveConfigFieldDescriptor Descriptor { get; set; } = null!;
[CascadingParameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private RenderFragment<EffectiveConfigFieldDescriptor>? _customFragment;
private string? _labelTooltip;
protected override void OnParametersSet()
{
_customFragment = RenderFragmentRegistry.GetDisplayFragment(Descriptor.SectionId, Descriptor.OptionName);
_labelTooltip = DnsmasqOptionTooltips.Get(Descriptor.OptionName);
}
}
@@ -1,7 +1,7 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
<span class="d-inline-block me-2" title="@(Source?.GetReadOnlyTooltip())">
<span class="ec-field-value-item" title="@(Source?.GetReadOnlyTooltip())">
@Value
@if (Source?.IsReadOnly == true)
{
@@ -1,24 +1,56 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
<li>
<strong>@Label:</strong>
<li class="ec-field">
<span class="ec-field-label" title="@LabelTooltip"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit)
{
<span class="ec-field-value ec-field-value-multi">
<textarea class="form-control form-control-sm" rows="@(Math.Max(2, (Items?.Count ?? 0) + 1))" @oninput="OnLinesInput" placeholder="One value per line">@_editLines</textarea>
</span>
}
else
{
<span class="ec-field-value ec-field-value-multi">
@if (Items?.Count > 0)
{
@for (var i = 0; i < Items.Count; i++)
<ol class="ec-field-value-list">
@foreach (var item in Items!)
{
if (i > 0) { <span class="me-2">, </span> }
var item = Items[i];
<EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" />
<li><EffectiveConfigMultiValueItem Value="@item.Value" Source="@item.Source" /></li>
}
</ol>
}
else
{
<span class="text-muted">(none)</span>
}
</span>
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public IReadOnlyList<ValueWithSource>? Items { get; set; }
[Parameter] public EventCallback<IReadOnlyList<string>> 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);
}
}
@@ -1,15 +1,37 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
<li title="@(Source?.GetReadOnlyTooltip())">
<strong>@Label:</strong> @DisplayValue
<li class="ec-field" title="@(Source?.GetReadOnlyTooltip())">
<span class="ec-field-label" title="@(LabelTooltip ?? Source?.GetReadOnlyTooltip())"><strong>@Label:</strong></span>
@if (DisplayMode == EffectiveConfigDisplayMode.Edit && Source?.IsReadOnly != true)
{
<span class="ec-field-value">
<input type="text" class="form-control form-control-sm" value="@DisplayValue" @oninput="OnValueInput" />
</span>
}
else
{
<span class="ec-field-value">@DisplayValue</span>
@if (Source?.IsReadOnly == true)
{
<span class="badge bg-secondary ms-1">readonly</span>
}
}
</li>
@code {
[Parameter] public string Label { get; set; } = "";
[Parameter] public string DisplayValue { get; set; } = "";
[Parameter] public ConfigValueSource? Source { get; set; }
/// <summary>Optional explainer shown on hover over the label (e.g. from DnsmasqOptionTooltips).</summary>
[Parameter] public string? LabelTooltip { get; set; }
/// <summary>When Edit and not readonly: raised when the user changes the value. Not yet wired to save.</summary>
[Parameter] public EventCallback<string> 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);
}
}
@@ -21,7 +21,7 @@
var sectionDescriptors = descriptors.Where(d => d.SectionId == sectionId).ToList();
if (sectionDescriptors.Count > 0)
{
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" />
<EffectiveConfigSectionPanel SectionId="@sectionId" Title="@title" Descriptors="@sectionDescriptors" IsOpen="@IsOpen(sectionId)" OnToggle="@OnSectionToggle" DisplayMode="@DisplayMode" />
}
}
</div>
@@ -29,6 +29,8 @@
@code {
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
/// <summary>View = read-only; Edit = editable (future). Cascaded to field displays.</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private HashSet<string> _openPanels = new();
@@ -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;
}
}
@@ -1,6 +1,7 @@
@namespace DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig
@using DnsmasqWebUI.Models.EffectiveConfig
<CascadingValue Value="DisplayMode">
<div class="ec-section @(IsOpen ? "ec-section-open" : "")">
<button type="button" class="ec-section-header" @onclick="ToggleSection" aria-expanded="@IsOpen">
<span class="ec-section-title fw-bold">@Title</span>
@@ -17,6 +18,7 @@
</div>
</div>
</div>
</CascadingValue>
@code {
[Parameter] public string SectionId { get; set; } = "";
@@ -24,6 +26,8 @@
[Parameter] public IReadOnlyList<EffectiveConfigFieldDescriptor> Descriptors { get; set; } = null!;
[Parameter] public bool IsOpen { get; set; }
[Parameter] public EventCallback<string> OnToggle { get; set; }
/// <summary>Cascaded from EffectiveConfigSection. View = read-only; Edit = editable (future).</summary>
[Parameter] public EffectiveConfigDisplayMode DisplayMode { get; set; } = EffectiveConfigDisplayMode.View;
private async Task ToggleSection() => await OnToggle.InvokeAsync(SectionId);
}
@@ -4,7 +4,7 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid d-flex align-items-center">
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="@AppTitle home">
<img src="logo.png" alt="" class="brand-logo" />
<img src="logo.webp" alt="" class="brand-logo" />
<span>@AppTitle</span>
</a>
</div>
@@ -0,0 +1,67 @@
using System.Collections.Frozen;
namespace DnsmasqWebUI.Models.Config;
/// <summary>
/// Short explainer tooltips for dnsmasq option names. Shown on hover over the field label.
/// One entry per effective-config key for consistency.
/// </summary>
public static class DnsmasqOptionTooltips
{
/// <summary>Display label used in EffectiveConfigFieldBuilder for server/local multi-value.</summary>
public const string ServerLocalLabel = "server / local";
private static readonly FrozenDictionary<string, string> Tooltips = new Dictionary<string, string>(StringComparer.Ordinal)
{
// --- Hosts ---
[DnsmasqConfKeys.NoHosts] = "Do not read /etc/hosts; only use addn-hosts and DHCP/hosts.",
[DnsmasqConfKeys.AddnHosts] = "Additional hosts files (one per line).",
// --- Resolver / DNS ---
[DnsmasqConfKeys.ExpandHosts] = "Expand simple names in /etc/hosts with the local domain.",
[DnsmasqConfKeys.BogusPriv] = "Do not forward reverse lookups for private IP ranges.",
[DnsmasqConfKeys.StrictOrder] = "Query servers in the order specified; do not try the next on failure.",
[DnsmasqConfKeys.NoResolv] = "Do not read /etc/resolv.conf; use only server= and resolv-file=.",
[DnsmasqConfKeys.DomainNeeded] = "Never forward plain names (without a domain) to upstream.",
[DnsmasqConfKeys.Port] = "Listen on this port for DNS queries (default 53).",
[ServerLocalLabel] = "Upstream DNS servers (server=) and local-only domains (local=). Order is preserved.",
[DnsmasqConfKeys.Address] = "Map a domain or hostname to an IP (e.g. for ad blocking or local names). Can repeat.",
[DnsmasqConfKeys.ResolvFile] = "File(s) to read upstream server addresses from (e.g. from DHCP). Can repeat.",
// --- DHCP ---
[DnsmasqConfKeys.DhcpAuthoritative] = "DHCP server is authoritative; required for DHCP to work.",
[DnsmasqConfKeys.LeasefileRo] = "Do not create or truncate the DHCP lease file.",
[DnsmasqConfKeys.DhcpLeasefile] = "Path to the DHCP lease file.",
[DnsmasqConfKeys.DhcpLeaseMax] = "Maximum number of DHCP leases to hold.",
[DnsmasqConfKeys.DhcpTtl] = "TTL for DHCP names in the DNS cache.",
[DnsmasqConfKeys.DhcpRange] = "DHCP address range(s) and lease time. Can repeat.",
[DnsmasqConfKeys.DhcpHost] = "DHCP host entries (reservations by MAC or client-id). Can repeat.",
[DnsmasqConfKeys.DhcpOption] = "DHCP option lines sent to clients. Can repeat.",
// --- Cache ---
[DnsmasqConfKeys.CacheSize] = "Maximum number of DNS cache entries (0 to disable).",
[DnsmasqConfKeys.LocalTtl] = "TTL for entries from /etc/hosts and DHCP.",
[DnsmasqConfKeys.NoNegcache] = "Do not cache negative (NXDOMAIN) responses.",
[DnsmasqConfKeys.NegTtl] = "TTL for negative (cached miss) responses.",
[DnsmasqConfKeys.MaxTtl] = "Maximum TTL for cached responses (cap on upstream TTL).",
[DnsmasqConfKeys.MaxCacheTtl] = "Maximum TTL for positive cache entries.",
[DnsmasqConfKeys.MinCacheTtl] = "Minimum TTL for positive cache entries.",
// --- Process & networking ---
[DnsmasqConfKeys.NoPoll] = "Do not poll /etc/resolv.conf for changes.",
[DnsmasqConfKeys.BindInterfaces] = "Only bind to interfaces in use; do not bind to wildcard.",
[DnsmasqConfKeys.Interface] = "Only use these interfaces for DHCP/DNS. Can repeat.",
[DnsmasqConfKeys.ListenAddress] = "Listen on these addresses for DNS/DHCP. Can repeat.",
[DnsmasqConfKeys.ExceptInterface] = "Do not use these interfaces. Can repeat.",
[DnsmasqConfKeys.PidFile] = "Write process ID to this file.",
[DnsmasqConfKeys.User] = "Run as this user after startup (requires root).",
[DnsmasqConfKeys.Group] = "Run as this group after startup (requires root).",
[DnsmasqConfKeys.LogFacility] = "Syslog facility for dnsmasq logs (e.g. local0, daemon).",
}.ToFrozenDictionary();
/// <summary>Returns a short tooltip for the option, or null if none is defined.</summary>
public static string? Get(string optionName)
{
return Tooltips.TryGetValue(optionName, out var tip) ? tip : null;
}
}
@@ -0,0 +1,14 @@
namespace DnsmasqWebUI.Models.EffectiveConfig;
/// <summary>
/// Display mode for effective-config fields. Cascaded from the parent so that
/// OptionItem, MultiValueRow, and custom value displays can render view vs edit appropriately.
/// </summary>
public enum EffectiveConfigDisplayMode
{
/// <summary>Show values as read-only text.</summary>
View,
/// <summary>Show editable controls (inputs, toggles). Not yet implemented; plumbing only.</summary>
Edit,
}
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Components;
using DnsmasqWebUI.Models.EffectiveConfig;
namespace DnsmasqWebUI.Services.Abstractions;
/// <summary>
/// Lookup for custom display fragments per effective-config field (section + option).
/// When non-null, the fragment renders the value portion of the row; label and source badge stay shared.
/// Registered as singleton via assembly scanning (<see cref="IApplicationSingleton"/>).
/// </summary>
public interface IEffectiveConfigRenderFragmentRegistry : IApplicationSingleton
{
/// <summary>
/// Returns a fragment that renders the custom value UI for this field, or null to use default rendering.
/// </summary>
RenderFragment<EffectiveConfigFieldDescriptor>? GetDisplayFragment(string sectionId, string optionName);
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Components;
using DnsmasqWebUI.Models.Config;
using DnsmasqWebUI.Models.EffectiveConfig;
using DnsmasqWebUI.Services.Abstractions;
using DnsmasqWebUI.Components.Dnsmasq.EffectiveConfig.CustomDisplays;
namespace DnsmasqWebUI.Services;
/// <summary>
/// Maps (sectionId, optionName) to custom effective-config value display component types and builds
/// RenderFragments that render those components with the descriptor.
/// </summary>
public class EffectiveConfigRenderFragmentRegistry : IEffectiveConfigRenderFragmentRegistry
{
private readonly Dictionary<(string SectionId, string OptionName), Type> _displayComponents = new();
public EffectiveConfigRenderFragmentRegistry()
{
// Port: single-field display (e.g. "53 (default DNS port)").
_displayComponents[(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.Port)] = typeof(PortValueDisplay);
// Boolean flags: shared display ("Enabled" / "Disabled") for all flag options.
RegisterFlag(EffectiveConfigFieldBuilder.SectionHosts, DnsmasqConfKeys.NoHosts);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.ExpandHosts);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.BogusPriv);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.StrictOrder);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.NoResolv);
RegisterFlag(EffectiveConfigFieldBuilder.SectionResolver, DnsmasqConfKeys.DomainNeeded);
RegisterFlag(EffectiveConfigFieldBuilder.SectionCache, DnsmasqConfKeys.NoNegcache);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.NoPoll);
RegisterFlag(EffectiveConfigFieldBuilder.SectionProcess, DnsmasqConfKeys.BindInterfaces);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.DhcpAuthoritative);
RegisterFlag(EffectiveConfigFieldBuilder.SectionDhcp, DnsmasqConfKeys.LeasefileRo);
}
private void RegisterFlag(string sectionId, string optionName)
{
_displayComponents[(sectionId, optionName)] = typeof(FlagValueDisplay);
}
/// <inheritdoc />
public RenderFragment<EffectiveConfigFieldDescriptor>? GetDisplayFragment(string sectionId, string optionName)
{
if (!_displayComponents.TryGetValue((sectionId, optionName), out var componentType))
return null;
return descriptor => builder =>
{
builder.OpenComponent(0, componentType);
builder.AddAttribute(1, "Descriptor", descriptor);
builder.CloseComponent();
};
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 761 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB