mirror of
https://github.com/alexhopeoconnor/dnsmasq-webui.git
synced 2026-10-03 22:12:00 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bdd176c18 | ||
|
|
b70aa02017 | ||
|
|
d06d6ef6c9 | ||
|
|
1dffdcf2ed |
@@ -29,14 +29,14 @@ jobs:
|
||||
run: |
|
||||
docker run --rm \
|
||||
-v "${{ github.workspace }}:/src" -w /src \
|
||||
mcr.microsoft.com/dotnet/sdk:9.0 \
|
||||
mcr.microsoft.com/dotnet/sdk:10.0 \
|
||||
./scripts/publish-self-contained.sh ${{ matrix.rid }}
|
||||
|
||||
- name: Setup .NET
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Publish for ${{ matrix.rid }} (musl on host)
|
||||
if: matrix.rid == 'linux-musl-x64' || matrix.rid == 'linux-musl-arm64'
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Zip publish output
|
||||
run: |
|
||||
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net9.0/${{ matrix.rid }}/publish"
|
||||
PUBLISH_DIR="src/DnsmasqWebUI/bin/Release/net10.0/${{ matrix.rid }}/publish"
|
||||
(cd "$PUBLISH_DIR" && zip -r "../../../../../../../dnsmasq-webui-${{ matrix.rid }}.zip" .)
|
||||
|
||||
- name: Upload artifact
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ testdata-mount/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# User-specific
|
||||
# User-specific / runtime
|
||||
appsettings.*.local.json
|
||||
appsettings.Overrides.json
|
||||
*.local
|
||||
|
||||
+2
-2
@@ -3,12 +3,12 @@
|
||||
# For dnsmasq on the host, use the self-contained publish (scripts/publish-self-contained.sh)
|
||||
# or run the app on the host; see DnsmasqOptions XML doc for permissions and ReloadCommand scope.
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
EXPOSE 8081
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["src/DnsmasqWebUI/DnsmasqWebUI.csproj", "src/DnsmasqWebUI/"]
|
||||
RUN dotnet restore "src/DnsmasqWebUI/DnsmasqWebUI.csproj"
|
||||
|
||||
@@ -39,7 +39,7 @@ The recommended way to get a prebuilt binary is the **install script**. It picks
|
||||
curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
**From a git clone** (repo is detected from `git remote origin`):
|
||||
**From a git clone:**
|
||||
|
||||
```bash
|
||||
./scripts/install.sh
|
||||
@@ -102,8 +102,6 @@ sudo ./scripts/install.sh --uninstall --purge --system # Also remove /opt/dnsm
|
||||
./scripts/install.sh --version v1.0.0
|
||||
```
|
||||
|
||||
**Installing from a fork:** Pass the repo explicitly: `./scripts/install.sh --repo owner/dnsmasq-webui` or set `GITHUB_REPO=owner/dnsmasq-webui`. The default repo is `alexhopeoconnor/dnsmasq-webui`.
|
||||
|
||||
**If the binary fails to run** (e.g. `TypeLoadException` or glibc errors): use the install script with `--build-from-source` so the app is built for your machine. This requires the .NET SDK and a **git clone** (it does not work when installing via `curl ... | sh`). Clone the repo, then run `./scripts/install.sh --build-from-source`. The script checks that .NET is installed, detects your OS/arch, and builds (on Ubuntu it may use a distro-specific RID for a better match). Other distros use the portable RID and may still hit runtime issues; building from source on the target machine is the most reliable.
|
||||
|
||||
**Configuration:** Set at least `Dnsmasq__MainConfigPath` to your main dnsmasq config. See [Configuration](#configuration) for all options.
|
||||
@@ -238,7 +236,6 @@ All scripts live under `scripts/` and are intended for Linux (WSL may work but i
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--repo OWNER/REPO` | GitHub owner/repo (or set `GITHUB_REPO`). When run from a clone, repo is detected from `git remote origin`. Default repo: `alexhopeoconnor/dnsmasq-webui`. |
|
||||
| `--list` | List available releases (tag, name, published_at) and exit. |
|
||||
| `--version TAG` | Install from release TAG (e.g. v1.0.0). Default: latest. |
|
||||
| `--update` | Reinstall latest into the default user directory (~/.local/share/dnsmasq-webui). |
|
||||
@@ -291,18 +288,14 @@ Invalid combinations (script errors with a clear message): `--purge` without `--
|
||||
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.
|
||||
10. **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
|
||||
# Install latest (from clone: repo auto-detected; from curl: uses alexhopeoconnor/dnsmasq-webui by default, or use --repo)
|
||||
# Install latest
|
||||
./scripts/install.sh
|
||||
|
||||
# Update to latest release
|
||||
@@ -329,8 +322,8 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
# Switch to another release (overwrites that dir)
|
||||
./scripts/install.sh --dir ~/.local/share/dnsmasq-webui --version v0.9.0
|
||||
|
||||
# List releases (from fork)
|
||||
./scripts/install.sh --repo alexhopeoconnor/dnsmasq-webui --list
|
||||
# List releases
|
||||
./scripts/install.sh --list
|
||||
```
|
||||
|
||||
**How the script works and avoids mistakes**
|
||||
@@ -351,7 +344,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
`--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.
|
||||
If no asset matches your OS/arch (RID), the script 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.
|
||||
@@ -392,7 +385,7 @@ sudo ./scripts/install.sh --uninstall --purge --system
|
||||
./scripts/publish-self-contained.sh --no-clean ubuntu.24.04-x64
|
||||
```
|
||||
|
||||
**Output:** `src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/`
|
||||
**Output:** `src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ services:
|
||||
# Simulated systemctl status + real logs (dnsmasq logs to /data/dnsmasq.log via log-facility in dnsmasq-test.conf)
|
||||
Dnsmasq__StatusShowCommand: "/app/dnsmasq-status.sh"
|
||||
Dnsmasq__LogsCommand: "tail -n 100 /data/dnsmasq.log 2>/dev/null || echo '(no log file yet)'"
|
||||
# Persist runtime log level across container restarts
|
||||
RuntimeOverrides__FilePath: "/data/appsettings.Overrides.json"
|
||||
volumes:
|
||||
- ${TESTDATA_MOUNT:-./testdata-mount}:/data
|
||||
networks:
|
||||
|
||||
+52
-81
@@ -1,12 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
# Download and install dnsmasq-webui from a GitHub release. Picks the binary
|
||||
# matching the current OS/arch (RID). Run from a clone or via:
|
||||
# curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/main/scripts/install.sh | sh
|
||||
# When run from a clone, repo is detected from git remote origin.
|
||||
# curl -sSL https://raw.githubusercontent.com/alexhopeoconnor/dnsmasq-webui/master/scripts/install.sh | sh
|
||||
set -e
|
||||
|
||||
GITHUB_REPO="${GITHUB_REPO:-}"
|
||||
REPO_DEFAULT="${REPO_DEFAULT:-alexhopeoconnor/dnsmasq-webui}"
|
||||
GITHUB_REPO="alexhopeoconnor/dnsmasq-webui"
|
||||
|
||||
RELEASE_TAG=""
|
||||
LIST=false
|
||||
@@ -37,10 +35,6 @@ usage() {
|
||||
echo "Download and install dnsmasq-webui from a GitHub release for this machine's OS/arch."
|
||||
echo "Supports install, update, and switch-release (re-run with same --dir and different --version)."
|
||||
echo ""
|
||||
echo "Repo (when not in a clone):"
|
||||
echo " --repo OWNER/REPO GitHub owner/repo. Or set GITHUB_REPO. Or set REPO_DEFAULT in the script."
|
||||
echo " When run inside a git clone with origin, repo is detected automatically."
|
||||
echo ""
|
||||
echo "Release:"
|
||||
echo " --list List available releases (tag, name, published_at) and exit."
|
||||
echo " --version TAG Install from release TAG (e.g. v1.0.0). Default: latest."
|
||||
@@ -72,7 +66,7 @@ usage() {
|
||||
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 # Install latest"
|
||||
echo " $0 --update # Reinstall latest to default dir (upgrade)"
|
||||
echo " $0 --version v1.0.0 # Install v1.0.0 to default dir"
|
||||
echo " $0 --dir /opt/dnsmasq-webui # Install to custom dir"
|
||||
@@ -90,11 +84,6 @@ usage() {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trim whitespace and carriage return (e.g. from script downloaded on Windows or with CRLF).
|
||||
trim_repo() {
|
||||
echo "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# Build CONFIG_VARS_FILE from env (DNSMASQ_WEBUI_*) then --set lines. Call before do_install when installing.
|
||||
# Result: key=value per line (env first, then --set so --set overrides). Safe for values with = in them.
|
||||
collect_config() {
|
||||
@@ -145,30 +134,6 @@ write_env_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Detect owner/repo from git remote, REPO_DEFAULT, or require --repo/GITHUB_REPO.
|
||||
detect_repo() {
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
|
||||
return
|
||||
fi
|
||||
if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
origin="$(git remote get-url origin 2>/dev/null)" || true
|
||||
if [ -n "$origin" ]; then
|
||||
GITHUB_REPO="$(echo "$origin" | sed -E 's|^https://github\.com/||; s|^git@github\.com:||; s|\.git$||; s|/$||')"
|
||||
GITHUB_REPO="$(trim_repo "$GITHUB_REPO")"
|
||||
if [ -n "$GITHUB_REPO" ]; then
|
||||
return
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -n "$REPO_DEFAULT" ]; then
|
||||
GITHUB_REPO="$(trim_repo "$REPO_DEFAULT")"
|
||||
return
|
||||
fi
|
||||
echo "Error: GitHub repo not set. Use --repo owner/repo, set GITHUB_REPO, or run from a clone. For the one-liner, set REPO_DEFAULT in the script." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Require jq for parsing GitHub API JSON.
|
||||
check_jq() {
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
@@ -178,6 +143,14 @@ check_jq() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Safely run jq on release JSON (avoids echo mangling). $1 = JSON, $2+ = jq args.
|
||||
jq_release() {
|
||||
local json
|
||||
json="$1"
|
||||
shift
|
||||
printf '%s' "$json" | jq -r "$@"
|
||||
}
|
||||
|
||||
# Detect RID (same logic as publish-self-contained.sh).
|
||||
detect_arch() {
|
||||
case "$(uname -m)" in
|
||||
@@ -234,40 +207,45 @@ fetch_release() {
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases/tags/$RELEASE_TAG"
|
||||
fi
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
|
||||
code="$(echo "$resp" | tail -n1)"
|
||||
body="$(echo "$resp" | sed '$d')"
|
||||
code="$(printf '%s' "$resp" | tail -n1)"
|
||||
body="$(printf '%s' "$resp" | sed '$d')"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "Error: GitHub API returned $code for $api_url" >&2
|
||||
echo "$body" | jq -r '.message // .' 2>/dev/null || echo "$body" >&2
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body" | jq -r '.message // .' 2>/dev/null || printf '%s' "$body" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$body"
|
||||
# GitHub API can return release body with unescaped control chars; jq rejects them. Strip control chars (keep \n \r).
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body"
|
||||
}
|
||||
|
||||
# List releases and exit.
|
||||
list_releases() {
|
||||
check_jq
|
||||
detect_repo
|
||||
api_url="https://api.github.com/repos/$GITHUB_REPO/releases?per_page=20"
|
||||
resp="$(curl -sSL -A "dnsmasq-webui-install/1.0" -w "\n%{http_code}" "$api_url" </dev/null)"
|
||||
code="$(echo "$resp" | tail -n1)"
|
||||
body="$(echo "$resp" | sed '$d')"
|
||||
code="$(printf '%s' "$resp" | tail -n1)"
|
||||
body="$(printf '%s' "$resp" | sed '$d')"
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "Error: GitHub API returned $code" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
|
||||
body="$(printf '%s' "$body" | tr -d '\000-\011\013\014\016-\037')"
|
||||
printf '%s' "$body" | jq -r '.[] | "\(.tag_name) \(.name) \(.published_at // .created_at)"'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Find asset download URL whose name contains the given RID.
|
||||
# Find asset download URL whose name contains the given RID. Output trimmed single line or empty.
|
||||
find_asset_url() {
|
||||
local release_json rid
|
||||
local release_json rid raw
|
||||
release_json="$1"
|
||||
rid="$2"
|
||||
echo "$release_json" | jq -r --arg rid "$rid" '
|
||||
raw="$(jq_release "$release_json" --arg rid "$rid" '
|
||||
.assets[] | select(.name | test($rid)) | .browser_download_url
|
||||
' | head -n1
|
||||
' | head -n1)"
|
||||
# Trim CR and leading/trailing whitespace (defensive for API quirks)
|
||||
printf '%s' "$raw" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# Remove user systemd unit (stop, disable, rm file). No root. Safe if not present.
|
||||
@@ -368,7 +346,7 @@ do_install_from_source() {
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v dotnet >/dev/null 2>&1; then
|
||||
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-9.0)." >&2
|
||||
echo "Error: --build-from-source requires the .NET SDK. Install from https://dotnet.microsoft.com/download or your distro (e.g. apt install dotnet-sdk-10.0)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -393,7 +371,7 @@ do_install_from_source() {
|
||||
echo "Error: Could not determine RID from build output." >&2
|
||||
exit 1
|
||||
fi
|
||||
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net9.0/$rid/publish"
|
||||
publish_dir="$repo_root/src/DnsmasqWebUI/bin/Release/net10.0/$rid/publish"
|
||||
if [ ! -d "$publish_dir" ] || [ ! -f "$publish_dir/DnsmasqWebUI" ]; then
|
||||
echo "Error: Build output not found at $publish_dir" >&2
|
||||
exit 1
|
||||
@@ -449,7 +427,6 @@ do_install() {
|
||||
|
||||
echo "Installing dnsmasq-webui..."
|
||||
check_jq
|
||||
detect_repo
|
||||
|
||||
if [ "$SYSTEM_INSTALL" = true ]; then
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
@@ -476,18 +453,18 @@ do_install() {
|
||||
print_env_detection "$rid"
|
||||
echo "Fetching release..."
|
||||
release_json="$(fetch_release)"
|
||||
tag="$(echo "$release_json" | jq -r '.tag_name')"
|
||||
tag="$(jq_release "$release_json" '.tag_name')"
|
||||
echo "Release: $tag"
|
||||
url="$(find_asset_url "$release_json" "$rid" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||
url="$(find_asset_url "$release_json" "$rid")"
|
||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
||||
echo "Error: No asset found for RID $rid in release $tag." >&2
|
||||
echo "Available assets:" >&2
|
||||
echo "$release_json" | jq -r '.assets[].name' | sed 's/^/ /' >&2
|
||||
jq_release "$release_json" '.assets[].name' | sed 's/^/ /' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
tmpzip="${TMPDIR:-/tmp}/dnsmasq-webui-$rid.zip"
|
||||
tmpzip="$(mktemp)"
|
||||
echo "Downloading $url ..."
|
||||
curl -sSL -A "dnsmasq-webui-install/1.0" -o "$tmpzip" "$url" </dev/null
|
||||
echo "Extracting to $INSTALL_DIR ..."
|
||||
@@ -627,12 +604,6 @@ while [ $# -gt 0 ]; do
|
||||
-h|-?|--help)
|
||||
usage
|
||||
;;
|
||||
--repo)
|
||||
shift
|
||||
[ $# -gt 0 ] || { echo "Error: --repo requires OWNER/REPO" >&2; exit 1; }
|
||||
GITHUB_REPO="$1"
|
||||
shift
|
||||
;;
|
||||
--list)
|
||||
LIST=true
|
||||
shift
|
||||
@@ -697,26 +668,26 @@ 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 "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). 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
|
||||
if [ "$UNINSTALL" = true ]; then
|
||||
if [ "$UPDATE" = true ] || [ -n "$RELEASE_TAG" ] || [ "$SERVICE" = true ] || [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with install/update options (--version, --update, --service, --build-from-source). Run uninstall alone, then install if needed." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --build-from-source cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
|
||||
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --uninstall cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$BUILD_FROM_SOURCE" = true ]; then
|
||||
if [ "$LIST" = true ]; then
|
||||
echo "Error: --build-from-source cannot be combined with --list." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$RELEASE_TAG" ] || [ "$UPDATE" = true ]; then
|
||||
echo "Error: --build-from-source builds from current source; do not use --version or --update." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$LIST" = true ]; then
|
||||
if [ "$UNINSTALL" = true ] || [ "$UPDATE" = true ] || [ "$SERVICE" = true ] || [ -n "$RELEASE_TAG" ]; then
|
||||
echo "Error: --list lists releases and exits; do not combine with install/uninstall options." >&2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# (app + dnsmasq + DHCP client). See testdata/README.md and docker-compose.test.yml.
|
||||
#
|
||||
# What this script does:
|
||||
# Start (default): clear mount (unless --no-clear), sync source -> mount, clean up previous test data,
|
||||
# Start (default): sync source -> mount (preserving existing unless --clear), clean up previous test data,
|
||||
# then docker compose up -d [--build] [--force-recreate].
|
||||
# --stop: docker compose down (stop and remove containers/networks).
|
||||
# --tidy: docker compose down, then clear the mount directory for a clean next run.
|
||||
@@ -18,7 +18,7 @@ MOUNT_DIR=""
|
||||
PREPARE_ONLY=false
|
||||
BUILD=false
|
||||
RECREATE=false
|
||||
NO_CLEAR=false
|
||||
CLEAR=false
|
||||
STOP=false
|
||||
TIDY=false
|
||||
|
||||
@@ -26,10 +26,10 @@ usage() {
|
||||
echo "Usage: $0 [OPTIONS] [--]"
|
||||
echo ""
|
||||
echo "Prepare the testdata mount directory and optionally start or stop the Docker test harness"
|
||||
echo "(app + dnsmasq + DHCP client). By default: clear mount, sync testdata -> mount, then up -d (no rebuild)."
|
||||
echo "(app + dnsmasq + DHCP client). By default: sync testdata -> mount (preserving existing), then up -d (no rebuild)."
|
||||
echo ""
|
||||
echo "Steps (when starting):"
|
||||
echo " 1. Clear mount dir (unless --no-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 " 3. If not --prepare-only: docker compose -f $COMPOSE_FILE up -d [options]."
|
||||
echo ""
|
||||
@@ -39,9 +39,8 @@ usage() {
|
||||
echo " Compose uses TESTDATA_MOUNT; script exports it if you use --mount."
|
||||
echo ""
|
||||
echo "Mount behaviour:"
|
||||
echo " (default) Clear mount dir completely, then sync. Use for a clean run."
|
||||
echo " --no-clear Do not clear mount dir; only sync over existing contents."
|
||||
echo " Use to preserve leases or debug files between runs."
|
||||
echo " (default) Preserve mount dir; sync source over existing contents."
|
||||
echo " --clear Clear mount dir completely before sync. Use for a clean run."
|
||||
echo ""
|
||||
echo "Compose behaviour:"
|
||||
echo " --prepare-only Only prepare the mount; do not run docker compose."
|
||||
@@ -62,19 +61,19 @@ usage() {
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0"
|
||||
echo " Full run: clear mount, sync testdata, start containers (no rebuild)."
|
||||
echo " Sync testdata to mount (preserving existing), start containers (no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --build"
|
||||
echo " Clear and sync, then rebuild images and start containers."
|
||||
echo " Sync, rebuild images, and start containers."
|
||||
echo ""
|
||||
echo " $0 --recreate"
|
||||
echo " Clear and sync, then up -d --force-recreate (fresh containers)."
|
||||
echo " Sync, then up -d --force-recreate (fresh containers)."
|
||||
echo ""
|
||||
echo " $0 --no-clear"
|
||||
echo " Preserve mount contents, sync over it, start (no rebuild)."
|
||||
echo " $0 --clear"
|
||||
echo " Clear mount, sync testdata, start (clean run, no rebuild)."
|
||||
echo ""
|
||||
echo " $0 --prepare-only"
|
||||
echo " Only clear and 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 ""
|
||||
echo " $0 --source myfixtures --mount mymount --prepare-only"
|
||||
@@ -106,8 +105,8 @@ while [ $# -gt 0 ]; do
|
||||
MOUNT_DIR="$1"
|
||||
shift
|
||||
;;
|
||||
--no-clear)
|
||||
NO_CLEAR=true
|
||||
--clear)
|
||||
CLEAR=true
|
||||
shift
|
||||
;;
|
||||
--prepare-only)
|
||||
@@ -189,7 +188,7 @@ docker compose -f "$COMPOSE_FILE" down
|
||||
|
||||
mkdir -p "$MOUNT_DIR"
|
||||
|
||||
if [ "$NO_CLEAR" = false ]; then
|
||||
if [ "$CLEAR" = true ]; then
|
||||
echo "Clearing mount directory: $MOUNT_DIR"
|
||||
find "$MOUNT_DIR" -mindepth 1 -delete 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -85,7 +85,7 @@ while [ $# -gt 0 ]; do
|
||||
echo " ubuntu.24.04-x64 ubuntu.24.04-arm64"
|
||||
echo " ubuntu.22.04-x64 ubuntu.22.04-arm64"
|
||||
echo ""
|
||||
echo "Output: src/DnsmasqWebUI/bin/Release/net9.0/<RID>/publish/"
|
||||
echo "Output: src/DnsmasqWebUI/bin/Release/net10.0/<RID>/publish/"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Publish for current machine (auto-detect RID)"
|
||||
@@ -163,7 +163,7 @@ else
|
||||
--self-contained true
|
||||
fi
|
||||
|
||||
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net9.0/$RID/publish"
|
||||
OUT_DIR="$REPO_ROOT/src/DnsmasqWebUI/bin/Release/net10.0/$RID/publish"
|
||||
echo ""
|
||||
echo "Done. Output: $OUT_DIR"
|
||||
echo "Run on this host: $OUT_DIR/DnsmasqWebUI"
|
||||
|
||||
@@ -17,25 +17,30 @@ public class ConfigValueSourceTests
|
||||
[Fact]
|
||||
public void GetReadOnlyTooltip_WhenReadOnlyWithoutLineNumber_ReturnsFileNameOnly()
|
||||
{
|
||||
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: null);
|
||||
var fileName = "02.conf";
|
||||
var fullPath = $"/etc/dnsmasq.d/{fileName}";
|
||||
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: null);
|
||||
var tooltip = source.GetReadOnlyTooltip();
|
||||
Assert.NotNull(tooltip);
|
||||
Assert.Contains("02.conf", tooltip);
|
||||
Assert.Contains(fileName, tooltip);
|
||||
Assert.Contains("readonly", tooltip);
|
||||
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
|
||||
Assert.Contains(fullPath, tooltip);
|
||||
Assert.DoesNotContain("line", tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetReadOnlyTooltip_WhenReadOnlyWithLineNumber_IncludesLineNumber()
|
||||
{
|
||||
var source = new ConfigValueSource("/etc/dnsmasq.d/02.conf", "02.conf", IsManaged: false, LineNumber: 3);
|
||||
var fileName = "02.conf";
|
||||
var fullPath = $"/etc/dnsmasq.d/{fileName}";
|
||||
int? lineNumber = 3;
|
||||
var source = new ConfigValueSource(fullPath, fileName, IsManaged: false, LineNumber: lineNumber);
|
||||
var tooltip = source.GetReadOnlyTooltip();
|
||||
Assert.NotNull(tooltip);
|
||||
Assert.Contains("02.conf", tooltip);
|
||||
Assert.Contains("line 3", tooltip);
|
||||
Assert.Contains(fileName, tooltip);
|
||||
Assert.Contains($"line {lineNumber}", tooltip);
|
||||
Assert.Contains("readonly", tooltip);
|
||||
Assert.Contains("/etc/dnsmasq.d/02.conf", tooltip);
|
||||
Assert.Contains(fullPath, tooltip);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -20,136 +20,219 @@ public class DnsmasqConfDhcpHostLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_BasicMacIpNameLease_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", e.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal("testpc", e.Name);
|
||||
Assert.Equal("infinite", e.Lease);
|
||||
Assert.Equal(mac, e.MacAddresses[0]);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(lease, e.Lease);
|
||||
Assert.Empty(e.Extra);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_CommentedLine_ParsesWithIsComment()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"#dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.False(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_DeletedLine_ParsesWithIsDeleted()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "oldpc";
|
||||
|
||||
var line = $"##dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsDeleted);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingComment_ParsesComment()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop", 1);
|
||||
string mac = "11:22:33:44:55:66";
|
||||
string address = "192.168.1.11";
|
||||
string name = "laptop";
|
||||
string lease = "infinite";
|
||||
string comment = "main laptop";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("main laptop", e!.Comment);
|
||||
Assert.Equal(comment, e!.Comment);
|
||||
Assert.Single(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleMacs_SameIp_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=11:22:33:44:55:66,12:34:56:78:90:12,192.168.0.2", 1);
|
||||
string mac1 = "11:22:33:44:55:66";
|
||||
string mac2 = "12:34:56:78:90:12";
|
||||
string address = "192.168.0.2";
|
||||
|
||||
var line = $"dhcp-host={mac1},{mac2},{address}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(2, e!.MacAddresses.Count);
|
||||
Assert.Equal("11:22:33:44:55:66", e.MacAddresses[0]);
|
||||
Assert.Equal("12:34:56:78:90:12", e.MacAddresses[1]);
|
||||
Assert.Equal("192.168.0.2", e.Address);
|
||||
Assert.Equal(mac1, e.MacAddresses[0]);
|
||||
Assert.Equal(mac2, e.MacAddresses[1]);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithSetTag_PutsInExtra()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=AA:BB:CC:DD:CC:BB,redhost1,192.168.1.41,infinite,set:red", 1);
|
||||
string mac = "AA:BB:CC:DD:CC:BB";
|
||||
string name = "redhost1";
|
||||
string address = "192.168.1.41";
|
||||
string lease = "infinite";
|
||||
string extraTag = "set:red";
|
||||
|
||||
var line = $"dhcp-host={mac},{name},{address},{lease},{extraTag}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.41", e!.Address);
|
||||
Assert.Equal("redhost1", e.Name);
|
||||
Assert.Contains(e.Extra, x => x == "set:red");
|
||||
Assert.Equal(address, e!.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Contains(e.Extra, x => x == extraTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_HostnameOnly_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=lap,192.168.0.199", 1);
|
||||
string name = "lap";
|
||||
string address = "192.168.0.199";
|
||||
|
||||
var line = $"dhcp-host={name},{address}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("lap", e!.Name);
|
||||
Assert.Equal("192.168.0.199", e.Address);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Empty(e.MacAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_Ignore_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=00:20:e0:3b:13:af,ignore", 1);
|
||||
string mac = "00:20:e0:3b:13:af";
|
||||
|
||||
var line = $"dhcp-host={mac},ignore";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.Ignore);
|
||||
Assert.Single(e.MacAddresses);
|
||||
Assert.Equal("00:20:e0:3b:13:af", e.MacAddresses[0]);
|
||||
Assert.Equal(mac, e.MacAddresses[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_NumericLease_Parses()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,pc,3600", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "pc";
|
||||
string lease = "3600";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("3600", e!.Lease);
|
||||
Assert.Equal(lease, e!.Lease);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_WithIdClient_PutsInExtra()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("dhcp-host=id:01:02:03:04,192.168.1.50,myhost", 1);
|
||||
string idPart = "id:01:02:03:04";
|
||||
string address = "192.168.1.50";
|
||||
string name = "myhost";
|
||||
|
||||
var line = $"dhcp-host={idPart},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Contains(e!.Extra, x => x.StartsWith("id:", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.Equal("192.168.1.50", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_Basic()
|
||||
{
|
||||
var line = "dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite";
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("aa:bb:cc:dd:ee:ff", back);
|
||||
Assert.Contains("192.168.1.10", back);
|
||||
Assert.Contains("testpc", back);
|
||||
Assert.Contains("infinite", back);
|
||||
Assert.Contains(mac, back);
|
||||
Assert.Contains(address, back);
|
||||
Assert.Contains(name, back);
|
||||
Assert.Contains(lease, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Roundtrip_WithComment()
|
||||
{
|
||||
var line = "dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite # main laptop";
|
||||
string mac = "11:22:33:44:55:66";
|
||||
string address = "192.168.1.11";
|
||||
string name = "laptop";
|
||||
string lease = "infinite";
|
||||
string comment = "main laptop";
|
||||
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease} # {comment}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("dhcp-host=", back);
|
||||
Assert.Contains("main laptop", back);
|
||||
Assert.Contains(comment, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_Prefix()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
|
||||
var line = $"#dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("#dhcp-host=", back);
|
||||
@@ -158,7 +241,13 @@ public class DnsmasqConfDhcpHostLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_DeletedEntry_DoubleHash()
|
||||
{
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine("##dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,old", 1);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "old";
|
||||
|
||||
var line = $"##dhcp-host={mac},{address},{name}";
|
||||
var e = DnsmasqConfDhcpHostLineParser.ParseLine(line, 1);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var back = DnsmasqConfDhcpHostLineParser.ToLine(e!);
|
||||
Assert.StartsWith("##dhcp-host=", back);
|
||||
|
||||
@@ -26,10 +26,12 @@ public class DnsmasqConfDirectiveParserTests
|
||||
[Fact]
|
||||
public void TryParseKeyValue_KeyValue_ReturnsKeyAndValue()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53");
|
||||
var value = "53";
|
||||
var line = $"port={value}";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal("53", kv.Value.value);
|
||||
Assert.Equal(value, kv.Value.value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -50,16 +52,22 @@ public class DnsmasqConfDirectiveParserTests
|
||||
[Fact]
|
||||
public void StripComment_CommentAfterSpace_StripsToEnd()
|
||||
{
|
||||
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # DNS port"));
|
||||
Assert.Equal("port=53", DnsmasqConfDirectiveParser.StripComment("port=53 # comment"));
|
||||
var before = "port=53";
|
||||
var comment1 = " DNS port";
|
||||
var comment2 = " comment";
|
||||
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment1));
|
||||
Assert.Equal(before, DnsmasqConfDirectiveParser.StripComment(before + " #" + comment2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseKeyValue_LineWithComment_ValueExcludesComment()
|
||||
{
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue("port=53 # DNS port");
|
||||
var value = "53";
|
||||
var comment = " DNS port";
|
||||
var line = $"port={value} #{comment}";
|
||||
var kv = DnsmasqConfDirectiveParser.TryParseKeyValue(line);
|
||||
Assert.NotNull(kv);
|
||||
Assert.Equal(DnsmasqConfKeys.Port, kv!.Value.key);
|
||||
Assert.Equal("53", kv.Value.value);
|
||||
Assert.Equal(value, kv.Value.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,73 +19,89 @@ public class DnsmasqConfFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseFile_BlankLine_BlankKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([""]);
|
||||
var raw = "";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var blank = Assert.IsType<BlankLine>(lines[0]);
|
||||
Assert.Equal(1, blank.LineNumber);
|
||||
Assert.Equal("", blank.RawLine);
|
||||
Assert.Equal(raw, blank.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_BlankLineWithSpaces_BlankKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([" \t "]);
|
||||
var raw = " \t ";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var blank = Assert.IsType<BlankLine>(lines[0]);
|
||||
Assert.Equal(" \t ", blank.RawLine);
|
||||
Assert.Equal(raw, blank.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_CommentLine_CommentKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["# Not managed by this app."]);
|
||||
var commentLine = "# Not managed by this app.";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([commentLine]);
|
||||
Assert.Single(lines);
|
||||
var comment = Assert.IsType<CommentLine>(lines[0]);
|
||||
Assert.Equal(1, comment.LineNumber);
|
||||
Assert.Equal("# Not managed by this app.", comment.RawLine);
|
||||
Assert.Equal(commentLine, comment.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_OtherDirective_OtherKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["domain=local"]);
|
||||
var raw = "domain=local";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([raw]);
|
||||
Assert.Single(lines);
|
||||
var other = Assert.IsType<OtherLine>(lines[0]);
|
||||
Assert.Equal("domain=local", other.RawLine);
|
||||
Assert.Equal(raw, other.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_AddnHostsLine_AddnHostsKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["addn-hosts=/var/lib/dnsmasq/hosts"]);
|
||||
var path = "/var/lib/dnsmasq/hosts";
|
||||
var line = $"addn-hosts={path}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
var addn = Assert.IsType<AddnHostsLine>(lines[0]);
|
||||
Assert.Equal("/var/lib/dnsmasq/hosts", addn.AddnHostsPath);
|
||||
Assert.Equal(path, addn.AddnHostsPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_AddnHosts_EmitsAddnHostsLine()
|
||||
{
|
||||
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = "/etc/hosts" };
|
||||
Assert.Equal("addn-hosts=/etc/hosts", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var path = "/etc/hosts";
|
||||
var line = new AddnHostsLine { LineNumber = 1, AddnHostsPath = path };
|
||||
Assert.Equal($"addn-hosts={path}", DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_DhcpHostLine_DhcpHostKind()
|
||||
{
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,testpc,infinite"]);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
var line = $"dhcp-host={mac},{address},{name},{lease}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
var dhcpLine = Assert.IsType<DhcpHostLine>(lines[0]);
|
||||
Assert.Single(dhcpLine.DhcpHost.MacAddresses);
|
||||
Assert.Equal("aa:bb:cc:dd:ee:ff", dhcpLine.DhcpHost.MacAddresses[0]);
|
||||
Assert.Equal("192.168.1.10", dhcpLine.DhcpHost.Address);
|
||||
Assert.Equal(mac, dhcpLine.DhcpHost.MacAddresses[0]);
|
||||
Assert.Equal(address, dhcpLine.DhcpHost.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseFile_CommentedDhcpHost_TreatedAsComment()
|
||||
{
|
||||
// Parser treats any line starting with # as Comment (does not parse dhcp-host content)
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(["#dhcp-host=aa:bb:cc:dd:ee:ff,192.168.1.10,oldpc"]);
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "oldpc";
|
||||
var line = $"#dhcp-host={mac},{address},{name}";
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile([line]);
|
||||
Assert.Single(lines);
|
||||
Assert.Equal(DnsmasqConfLineKind.Comment, lines[0].Kind);
|
||||
}
|
||||
@@ -93,13 +109,21 @@ public class DnsmasqConfFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseFile_MixedFile_ParsesAll()
|
||||
{
|
||||
var domainLine = "domain=local";
|
||||
string dhcpMac = "11:22:33:44:55:66";
|
||||
string dhcpAddress = "192.168.1.11";
|
||||
string dhcpName = "laptop";
|
||||
string dhcpLease = "infinite";
|
||||
var rangeLine = "dhcp-range=192.168.1.100,192.168.1.200";
|
||||
var dhcpLine = $"dhcp-host={dhcpMac},{dhcpAddress},{dhcpName},{dhcpLease}";
|
||||
|
||||
var input = new[]
|
||||
{
|
||||
"# Sample config",
|
||||
"",
|
||||
"domain=local",
|
||||
"dhcp-host=11:22:33:44:55:66,192.168.1.11,laptop,infinite",
|
||||
"dhcp-range=192.168.1.100,192.168.1.200"
|
||||
domainLine,
|
||||
dhcpLine,
|
||||
rangeLine
|
||||
};
|
||||
var lines = DnsmasqConfFileLineParser.ParseFile(input);
|
||||
Assert.Equal(5, lines.Count);
|
||||
@@ -107,48 +131,55 @@ public class DnsmasqConfFileLineParserTests
|
||||
Assert.IsType<CommentLine>(lines[0]);
|
||||
Assert.IsType<BlankLine>(lines[1]);
|
||||
var other2 = Assert.IsType<OtherLine>(lines[2]);
|
||||
Assert.Equal("domain=local", other2.RawLine);
|
||||
Assert.Equal(domainLine, other2.RawLine);
|
||||
var dhcp3 = Assert.IsType<DhcpHostLine>(lines[3]);
|
||||
Assert.Equal("192.168.1.11", dhcp3.DhcpHost.Address);
|
||||
Assert.Equal(dhcpAddress, dhcp3.DhcpHost.Address);
|
||||
var other4 = Assert.IsType<OtherLine>(lines[4]);
|
||||
Assert.Equal("dhcp-range=192.168.1.100,192.168.1.200", other4.RawLine);
|
||||
Assert.Equal(rangeLine, other4.RawLine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Blank_PreservesRawLine()
|
||||
{
|
||||
var line = new BlankLine { LineNumber = 1, RawLine = " " };
|
||||
Assert.Equal(" ", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = " ";
|
||||
var line = new BlankLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Comment_PreservesRawLine()
|
||||
{
|
||||
var line = new CommentLine { LineNumber = 1, RawLine = "# comment" };
|
||||
Assert.Equal("# comment", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = "# comment";
|
||||
var line = new CommentLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Other_PreservesRawLine()
|
||||
{
|
||||
var line = new OtherLine { LineNumber = 1, RawLine = "domain=local" };
|
||||
Assert.Equal("domain=local", DnsmasqConfFileLineParser.ToLine(line));
|
||||
var raw = "domain=local";
|
||||
var line = new OtherLine { LineNumber = 1, RawLine = raw };
|
||||
Assert.Equal(raw, DnsmasqConfFileLineParser.ToLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_DhcpHost_SerializesViaDhcpHostLineParser()
|
||||
{
|
||||
string mac = "aa:bb:cc:dd:ee:ff";
|
||||
string address = "192.168.1.10";
|
||||
string name = "testpc";
|
||||
string lease = "infinite";
|
||||
var dhcp = new DhcpHostEntry
|
||||
{
|
||||
LineNumber = 1,
|
||||
MacAddresses = ["aa:bb:cc:dd:ee:ff"],
|
||||
Address = "192.168.1.10",
|
||||
Name = "testpc",
|
||||
Lease = "infinite"
|
||||
MacAddresses = [mac],
|
||||
Address = address,
|
||||
Name = name,
|
||||
Lease = lease
|
||||
};
|
||||
var line = new DhcpHostLine { LineNumber = 1, DhcpHost = dhcp };
|
||||
var back = DnsmasqConfFileLineParser.ToLine(line);
|
||||
Assert.Equal("dhcp-host=aa:bb:cc:dd:ee:ff, testpc, 192.168.1.10, infinite", back);
|
||||
Assert.Equal($"dhcp-host={mac}, {name}, {address}, {lease}", back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -46,10 +46,11 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var includePath = Path.Combine(dir, "extra.conf");
|
||||
var includeFileName = "extra.conf";
|
||||
var includePath = Path.Combine(dir, includeFileName);
|
||||
File.WriteAllText(includePath, "# extra\n");
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-file=extra.conf\n");
|
||||
File.WriteAllText(main, $"conf-file={includeFileName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
@@ -66,19 +67,22 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-confdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
var confDirName = "d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
var file1 = "aa.conf";
|
||||
var file2 = "zz.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "zz.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "aa.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, file2), "");
|
||||
File.WriteAllText(Path.Combine(subDir, file1), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d\n");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count); // main + aa.conf + zz.conf (alphabetical)
|
||||
Assert.Equal(Path.GetFullPath(main), paths[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "aa.conf")), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, "zz.conf")), paths[2]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file1)), paths[1]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(subDir, file2)), paths[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -109,12 +113,13 @@ public class DnsmasqConfIncludeParserTests
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-firstdir-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(baseDir);
|
||||
var subDir = Path.Combine(baseDir, "dnsmasq.d");
|
||||
var confDirName = "dnsmasq.d";
|
||||
var subDir = Path.Combine(baseDir, confDirName);
|
||||
Directory.CreateDirectory(subDir);
|
||||
try
|
||||
{
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=dnsmasq.d\n");
|
||||
File.WriteAllText(main, $"conf-dir={confDirName}\n");
|
||||
var first = DnsmasqConfIncludeParser.GetFirstConfDir(main);
|
||||
Assert.NotNull(first);
|
||||
Assert.Equal(Path.GetFullPath(subDir), first);
|
||||
@@ -240,12 +245,14 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var firstLeasePath = "/var/first.leases";
|
||||
var secondLeasePath = "/var/second.leases";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, "dhcp-leasefile=/var/first.leases\n");
|
||||
File.WriteAllText(first, $"dhcp-leasefile={firstLeasePath}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, "dhcp-leasefile=/var/second.leases\n");
|
||||
File.WriteAllText(second, $"dhcp-leasefile={secondLeasePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(Path.GetFullPath("/var/second.leases"), result);
|
||||
Assert.Equal(Path.GetFullPath(secondLeasePath), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -260,10 +267,13 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var subdir = "subdir";
|
||||
var leasesFile = "leases";
|
||||
var relativePath = $"{subdir}/{leasesFile}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "dhcp-leasefile=subdir/leases\n");
|
||||
File.WriteAllText(conf, $"dhcp-leasefile={relativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetDhcpLeaseFilePathFromConfigFiles(new[] { conf });
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "subdir", "leases")), result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subdir, leasesFile)), result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -285,15 +295,18 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var path1 = "/etc/hosts.d/first";
|
||||
var path2 = "/data/hosts";
|
||||
var path3 = "/data/extra.hosts";
|
||||
var first = Path.Combine(dir, "first.conf");
|
||||
File.WriteAllText(first, "addn-hosts=/etc/hosts.d/first\n");
|
||||
File.WriteAllText(first, $"addn-hosts={path1}\n");
|
||||
var second = Path.Combine(dir, "second.conf");
|
||||
File.WriteAllText(second, "addn-hosts=/data/hosts\naddn-hosts=/data/extra.hosts\n");
|
||||
File.WriteAllText(second, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { first, second });
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.d/first"), result[0]);
|
||||
Assert.Equal(Path.GetFullPath("/data/hosts"), result[1]);
|
||||
Assert.Equal(Path.GetFullPath("/data/extra.hosts"), result[2]);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0]);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1]);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -308,11 +321,14 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var pathPart1 = "hosts.d";
|
||||
var pathPart2 = "app.hosts";
|
||||
var addnRelativePath = $"{pathPart1}/{pathPart2}";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "addn-hosts=hosts.d/app.hosts\n");
|
||||
File.WriteAllText(conf, $"addn-hosts={addnRelativePath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFiles(new[] { conf });
|
||||
Assert.Single(result);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "hosts.d", "app.hosts")), result[0]);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, pathPart1, pathPart2)), result[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -364,12 +380,14 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstVal = "100";
|
||||
var secondVal = "200";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "cache-size=100\n");
|
||||
File.WriteAllText(f2, "cache-size=200\n");
|
||||
File.WriteAllText(f1, $"cache-size={firstVal}\n");
|
||||
File.WriteAllText(f2, $"cache-size={secondVal}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.CacheSize);
|
||||
Assert.Equal("200", value);
|
||||
Assert.Equal(secondVal, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
@@ -385,10 +403,11 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var logPath = "/data/dnsmasq.log";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "log-facility=/data/dnsmasq.log\n");
|
||||
File.WriteAllText(conf, $"log-facility={logPath}\n");
|
||||
var (value, configDir) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
Assert.Equal(logPath, value);
|
||||
Assert.Equal(dir, configDir);
|
||||
}
|
||||
finally
|
||||
@@ -404,12 +423,14 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var firstLogPath = "/var/log/dnsmasq.log";
|
||||
var secondLogPath = "/data/dnsmasq.log";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "log-facility=/var/log/dnsmasq.log\n");
|
||||
File.WriteAllText(f2, "log-facility=/data/dnsmasq.log\n");
|
||||
File.WriteAllText(f1, $"log-facility={firstLogPath}\n");
|
||||
File.WriteAllText(f2, $"log-facility={secondLogPath}\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.LogFacility);
|
||||
Assert.Equal("/data/dnsmasq.log", value);
|
||||
Assert.Equal(secondLogPath, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -421,16 +442,19 @@ public class DnsmasqConfIncludeParserTests
|
||||
public void ResolvePath_Relative_ResolvesAgainstDir()
|
||||
{
|
||||
var dir = Path.GetTempPath();
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath("sub/pid.pid", dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, "sub", "pid.pid")), result);
|
||||
var subDirName = "sub";
|
||||
var fileName = "pid.pid";
|
||||
var relativePath = $"{subDirName}/{fileName}";
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(relativePath, dir);
|
||||
Assert.Equal(Path.GetFullPath(Path.Combine(dir, subDirName, fileName)), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolvePath_Absolute_ReturnsAsIs()
|
||||
{
|
||||
var abs = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(abs, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(abs), result);
|
||||
var absPath = Path.Combine(Path.GetTempPath(), "absolute.pid");
|
||||
var result = DnsmasqConfIncludeParser.ResolvePath(absPath, "/some/dir");
|
||||
Assert.Equal(Path.GetFullPath(absPath), result);
|
||||
}
|
||||
|
||||
// --- Tests matching dnsmasq option.c semantics (ARG_ONE = last wins, flags = no value allowed) ---
|
||||
@@ -443,8 +467,9 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var invalidValue = "1";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "expand-hosts=1\n");
|
||||
File.WriteAllText(conf, $"expand-hosts={invalidValue}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetFlagFromConfigFiles(new[] { conf }, DnsmasqConfKeys.ExpandHosts);
|
||||
Assert.False(result);
|
||||
}
|
||||
@@ -462,10 +487,12 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
try
|
||||
{
|
||||
var wrongKey = "Port";
|
||||
var value = "53";
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
File.WriteAllText(conf, "Port=53\n");
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(value);
|
||||
File.WriteAllText(conf, $"{wrongKey}={value}\n");
|
||||
var (parsedValue, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.Port);
|
||||
Assert.Null(parsedValue);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -481,13 +508,16 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
var main = Path.Combine(dir, "dnsmasq.conf");
|
||||
var extra = Path.Combine(dir, "extra.conf");
|
||||
var port1 = "53";
|
||||
var port2 = "5353";
|
||||
var port3 = "54";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(main, "port=53\nconf-file=extra.conf\nport=5353\n");
|
||||
File.WriteAllText(extra, "port=54\n");
|
||||
File.WriteAllText(main, $"port={port1}\nconf-file=extra.conf\nport={port2}\n");
|
||||
File.WriteAllText(extra, $"port={port3}\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
var (value, _) = DnsmasqConfIncludeParser.GetLastValueFromConfigFiles(paths, DnsmasqConfKeys.Port);
|
||||
Assert.Equal("54", value);
|
||||
Assert.Equal(port3, value);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -502,18 +532,21 @@ public class DnsmasqConfIncludeParserTests
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-match-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileConf1 = "a.conf";
|
||||
var fileTxt = "b.txt";
|
||||
var fileConf2 = "c.conf";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "b.txt"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "c.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf1), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileTxt), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileConf2), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,*.conf\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(3, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith("c.conf", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith("b.txt", StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf1, StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith(fileConf2, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileTxt, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -528,16 +561,18 @@ public class DnsmasqConfIncludeParserTests
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), "dnsmasq-ignore-" + Guid.NewGuid().ToString("N"));
|
||||
var subDir = Path.Combine(baseDir, "d");
|
||||
Directory.CreateDirectory(subDir);
|
||||
var fileIncluded = "a.conf";
|
||||
var fileExcluded = "b.conf.bak";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(subDir, "a.conf"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, "b.conf.bak"), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileIncluded), "");
|
||||
File.WriteAllText(Path.Combine(subDir, fileExcluded), "");
|
||||
var main = Path.Combine(baseDir, "dnsmasq.conf");
|
||||
File.WriteAllText(main, "conf-dir=d,.bak\n");
|
||||
var paths = DnsmasqConfIncludeParser.GetIncludedPaths(main);
|
||||
Assert.Equal(2, paths.Count);
|
||||
Assert.Contains(paths, p => p.EndsWith("a.conf", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith("b.conf.bak", StringComparison.Ordinal));
|
||||
Assert.Contains(paths, p => p.EndsWith(fileIncluded, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(paths, p => p.EndsWith(fileExcluded, StringComparison.Ordinal));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -553,20 +588,23 @@ public class DnsmasqConfIncludeParserTests
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var managedPath = Path.Combine(dir, "managed.conf");
|
||||
var path1 = "/etc/hosts.a";
|
||||
var path2 = "/etc/hosts.b";
|
||||
var path3 = "/etc/hosts.c";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "addn-hosts=/etc/hosts.a\n");
|
||||
File.WriteAllText(f2, "addn-hosts=/etc/hosts.b\naddn-hosts=/etc/hosts.c\n");
|
||||
File.WriteAllText(f1, $"addn-hosts={path1}\n");
|
||||
File.WriteAllText(f2, $"addn-hosts={path2}\naddn-hosts={path3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { f1, f2 }, managedPath);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.a"), result[0].Path);
|
||||
Assert.Equal(Path.GetFullPath(path1), result[0].Path);
|
||||
Assert.Equal(Path.GetFileName(f1), result[0].Source.FileName);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
Assert.Equal(1, result[0].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.b"), result[1].Path);
|
||||
Assert.Equal(Path.GetFullPath(path2), result[1].Path);
|
||||
Assert.Equal(Path.GetFileName(f2), result[1].Source.FileName);
|
||||
Assert.Equal(1, result[1].Source.LineNumber);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.c"), result[2].Path);
|
||||
Assert.Equal(Path.GetFullPath(path3), result[2].Path);
|
||||
Assert.Equal(2, result[2].Source.LineNumber);
|
||||
Assert.True(result[2].Source.IsReadOnly == !result[2].Source.IsManaged);
|
||||
}
|
||||
@@ -583,10 +621,12 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
var mainPath = Path.Combine(dir, "dnsmasq.conf");
|
||||
var managedPath = Path.Combine(dir, "zz-managed.conf");
|
||||
var readOnlyPath = "/other/hosts";
|
||||
var managedAddnPath = "/managed/hosts";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "addn-hosts=/other/hosts\n");
|
||||
File.WriteAllText(managedPath, "addn-hosts=/managed/hosts\n");
|
||||
File.WriteAllText(mainPath, $"addn-hosts={readOnlyPath}\n");
|
||||
File.WriteAllText(managedPath, $"addn-hosts={managedAddnPath}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetAddnHostsPathsFromConfigFilesWithSource(new[] { mainPath, managedPath }, managedPath);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.False(result[0].Source.IsManaged);
|
||||
@@ -607,15 +647,18 @@ public class DnsmasqConfIncludeParserTests
|
||||
Directory.CreateDirectory(dir);
|
||||
var f1 = Path.Combine(dir, "01.conf");
|
||||
var f2 = Path.Combine(dir, "02.conf");
|
||||
var v1 = "1.1.1.1";
|
||||
var v2 = "/local/";
|
||||
var v3 = "/example.com/192.168.1.1";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(f1, "server=1.1.1.1\nlocal=/local/\n");
|
||||
File.WriteAllText(f2, "server=/example.com/192.168.1.1\n");
|
||||
File.WriteAllText(f1, $"server={v1}\nlocal={v2}\n");
|
||||
File.WriteAllText(f2, $"server={v3}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { f1, f2 }, DnsmasqConfKeys.ServerLocalKeys);
|
||||
Assert.Equal(3, result.Count);
|
||||
Assert.Equal("1.1.1.1", result[0]);
|
||||
Assert.Equal("/local/", result[1]);
|
||||
Assert.Equal("/example.com/192.168.1.1", result[2]);
|
||||
Assert.Equal(v1, result[0]);
|
||||
Assert.Equal(v2, result[1]);
|
||||
Assert.Equal(v3, result[2]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -629,13 +672,15 @@ public class DnsmasqConfIncludeParserTests
|
||||
var dir = Path.Combine(Path.GetTempPath(), "dnsmasq-dhcp-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var conf = Path.Combine(dir, "dnsmasq.conf");
|
||||
var range1 = "172.28.0.10,172.28.0.50,12h";
|
||||
var range2 = "192.168.1.10,192.168.1.100,255.255.255.0,24h";
|
||||
try
|
||||
{
|
||||
File.WriteAllText(conf, "dhcp-range=172.28.0.10,172.28.0.50,12h\ndhcp-range=192.168.1.10,192.168.1.100,255.255.255.0,24h\n");
|
||||
File.WriteAllText(conf, $"dhcp-range={range1}\ndhcp-range={range2}\n");
|
||||
var result = DnsmasqConfIncludeParser.GetMultiValueFromConfigFiles(new[] { conf }, DnsmasqConfKeys.DhcpRange);
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.Equal("172.28.0.10,172.28.0.50,12h", result[0]);
|
||||
Assert.Equal("192.168.1.10,192.168.1.100,255.255.255.0,24h", result[1]);
|
||||
Assert.Equal(range1, result[0]);
|
||||
Assert.Equal(range2, result[1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -41,7 +41,10 @@ public class DnsmasqConfigSetServiceTests
|
||||
ConfigSetCache? cache = null;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(mainPath, "port=53\ncache-size=500\naddn-hosts=/etc/hosts.extra\n");
|
||||
int port = 53;
|
||||
int cacheSize = 500;
|
||||
var addnHostsPath = "/etc/hosts.extra";
|
||||
File.WriteAllText(mainPath, $"port={port}\ncache-size={cacheSize}\naddn-hosts={addnHostsPath}\n");
|
||||
var options = Options.Create(new DnsmasqOptions
|
||||
{
|
||||
MainConfigPath = mainPath,
|
||||
@@ -52,10 +55,10 @@ public class DnsmasqConfigSetServiceTests
|
||||
var (config, sources) = service.GetEffectiveConfigWithSources();
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(53, config.Port);
|
||||
Assert.Equal(500, config.CacheSize);
|
||||
Assert.Equal(port, config.Port);
|
||||
Assert.Equal(cacheSize, config.CacheSize);
|
||||
Assert.Single(config.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), config.AddnHostsPaths[0]);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), config.AddnHostsPaths[0]);
|
||||
Assert.Empty(config.ServerLocalValues);
|
||||
Assert.Empty(config.DhcpRanges);
|
||||
|
||||
@@ -64,7 +67,7 @@ public class DnsmasqConfigSetServiceTests
|
||||
Assert.Equal(Path.GetFileName(mainPath), sources.Port!.FileName);
|
||||
Assert.NotNull(sources.CacheSize);
|
||||
Assert.Single(sources.AddnHostsPaths);
|
||||
Assert.Equal(Path.GetFullPath("/etc/hosts.extra"), sources.AddnHostsPaths[0].Path);
|
||||
Assert.Equal(Path.GetFullPath(addnHostsPath), sources.AddnHostsPaths[0].Path);
|
||||
Assert.NotNull(sources.AddnHostsPaths[0].Source);
|
||||
Assert.Equal(Path.GetFileName(mainPath), sources.AddnHostsPaths[0].Source!.FileName);
|
||||
}
|
||||
|
||||
@@ -19,63 +19,113 @@ public class DnsmasqLeasesFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_ValidFiveFields_Parses()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
Assert.Equal(mac, e.Mac);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownHostname_Asterisk()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689522 00:00:00:00:00:04 192.168.1.237 * 01:00:00:00:00:00:04");
|
||||
long epoch = 946689522;
|
||||
string mac = "00:00:00:00:00:04";
|
||||
string address = "192.168.1.237";
|
||||
string name = "*";
|
||||
string clientId = "01:00:00:00:00:00:04";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("*", e!.Name);
|
||||
Assert.Equal("192.168.1.237", e.Address);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(address, e.Address);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_UnknownClientId_Asterisk()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689351 00:0f:b0:3a:b5:0b 192.168.1.208 colinux *");
|
||||
long epoch = 946689351;
|
||||
string mac = "00:0f:b0:3a:b5:0b";
|
||||
string address = "192.168.1.208";
|
||||
string name = "colinux";
|
||||
string clientId = "*";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("colinux", e!.Name);
|
||||
Assert.Equal("*", e.ClientId);
|
||||
Assert.Equal(name, e!.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_MultipleSpacesBetweenFields_Parses()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(" 946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 ");
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $" {epoch} {mac} {address} {name} {clientId} ";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal(946689575, e!.Epoch);
|
||||
Assert.Equal("00:00:00:00:00:05", e.Mac);
|
||||
Assert.Equal("192.168.1.155", e.Address);
|
||||
Assert.Equal("wdt", e.Name);
|
||||
Assert.Equal("01:00:00:00:00:00:05", e.ClientId);
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
Assert.Equal(mac, e.Mac);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Equal(name, e.Name);
|
||||
Assert.Equal(clientId, e.ClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TooFewFields_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155"));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05"));
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac} {address}"));
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine($"{epoch} {mac}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_TrailingJunk_ReturnsNull()
|
||||
{
|
||||
// Parser uses .End() so extra text after fifth field fails
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05 extra"));
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId} extra";
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_InvalidEpoch_ReturnsNull()
|
||||
{
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine("notanum 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05"));
|
||||
string invalidEpoch = "notanum";
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{invalidEpoch} {mac} {address} {name} {clientId}";
|
||||
Assert.Null(DnsmasqLeasesFileLineParser.ParseLine(line));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -90,9 +140,18 @@ public class DnsmasqLeasesFileLineParserTests
|
||||
[Fact]
|
||||
public void Timestamp_ConvertsEpochToDateTime()
|
||||
{
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine("946689575 00:00:00:00:00:05 192.168.1.155 wdt 01:00:00:00:00:00:05");
|
||||
long epoch = 946689575;
|
||||
string mac = "00:00:00:00:00:05";
|
||||
string address = "192.168.1.155";
|
||||
string name = "wdt";
|
||||
string clientId = "01:00:00:00:00:00:05";
|
||||
|
||||
var line = $"{epoch} {mac} {address} {name} {clientId}";
|
||||
var e = DnsmasqLeasesFileLineParser.ParseLine(line);
|
||||
|
||||
Assert.NotNull(e);
|
||||
var dt = e!.Timestamp;
|
||||
Assert.Equal(epoch, e!.Epoch);
|
||||
var dt = e.Timestamp;
|
||||
Assert.True(dt.Year >= 1999 && dt.Year <= 2000); // epoch 946689575 is ~1999-12
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
|
||||
@@ -76,35 +76,41 @@ public class EffectiveDnsmasqConfigTests
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePath_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "/data/dnsmasq.log");
|
||||
Assert.Equal("/data/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
var path = "/data/dnsmasq.log";
|
||||
var config = CreateConfig(logFacility: path);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_AbsolutePathWithTrim_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: " /var/log/dnsmasq.log ");
|
||||
Assert.Equal("/var/log/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
var path = "/var/log/dnsmasq.log";
|
||||
var pathWithSpaces = $" {path} ";
|
||||
var config = CreateConfig(logFacility: pathWithSpaces);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_RelativePathWithSlash_ReturnsPath()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "logs/dnsmasq.log");
|
||||
Assert.Equal("logs/dnsmasq.log", EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
var path = "logs/dnsmasq.log";
|
||||
var config = CreateConfig(logFacility: path);
|
||||
Assert.Equal(path, EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_SyslogFacilityName_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "local0");
|
||||
var facility = "local0";
|
||||
var config = CreateConfig(logFacility: facility);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLogsPath_Stderr_ReturnsNull()
|
||||
{
|
||||
var config = CreateConfig(logFacility: "-");
|
||||
var facility = "-";
|
||||
var config = CreateConfig(logFacility: facility);
|
||||
Assert.Null(EffectiveDnsmasqConfig.GetLogsPath(config));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_WhitespaceOnly_ReturnsPassthrough()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine(" \t ", 2);
|
||||
var raw = " \t ";
|
||||
var e = HostsFileLineParser.ParseLine(raw, 2);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
@@ -30,7 +31,8 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_CommentOnly_TreatsWholeLineAsComment()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("# This is a comment", 1);
|
||||
var commentLine = "# This is a comment";
|
||||
var e = HostsFileLineParser.ParseLine(commentLine, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.True(e.IsPassthrough);
|
||||
@@ -42,7 +44,10 @@ public class HostsFileLineParserTests
|
||||
public void ParseLine_CommentWithDataLikeContent_DoesNotParseAsAddress()
|
||||
{
|
||||
// Per hosts(5): text from # to EOL is comment - so "# 127.0.0.1 localhost" is entirely comment
|
||||
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"# {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsComment);
|
||||
Assert.Equal("", e.Address);
|
||||
@@ -52,75 +57,99 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ParseLine_IPv4Localhost_ParsesAddressAndName()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.False(e!.IsPassthrough);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithCanonicalAndAlias_ParsesAll()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("127.0.1.1 thishost.example.org thishost", 1);
|
||||
string address = "127.0.1.1";
|
||||
string canonical = "thishost.example.org";
|
||||
string alias = "thishost";
|
||||
var line = $"{address} {canonical} {alias}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("127.0.1.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("thishost.example.org", e.Names[0]);
|
||||
Assert.Equal(canonical, e.Names[0]);
|
||||
if (e.Names.Count >= 2)
|
||||
Assert.Equal("thishost", e.Names[1]);
|
||||
Assert.Equal(alias, e.Names[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv4WithMultipleSpaces_ParsesCorrectly()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("192.168.1.10 foo.example.org foo", 1);
|
||||
string address = "192.168.1.10";
|
||||
string name1 = "foo.example.org";
|
||||
string name2 = "foo";
|
||||
var line = $"{address} {name1} {name2}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.10", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("foo.example.org", e.Names[0]);
|
||||
Assert.Equal(name1, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6_ParsesAddressAndNames()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("::1 localhost ip6-localhost ip6-loopback", 1);
|
||||
string address = "::1";
|
||||
string name1 = "localhost";
|
||||
var line = $"{address} {name1} ip6-localhost ip6-loopback";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("::1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.True(e.Names.Count >= 1);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name1, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_IPv6Multicast_Parses()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("ff02::1 ip6-allnodes", 1);
|
||||
string address = "ff02::1";
|
||||
string name = "ip6-allnodes";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("ff02::1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("ip6-allnodes", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_InlineComment_StopsAtHash()
|
||||
{
|
||||
// Token stops at #; only address and names before # are parsed
|
||||
var e = HostsFileLineParser.ParseLine("127.0.0.1 localhost # loopback", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
string comment = "loopback";
|
||||
var line = $"{address} {name} # {comment}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("127.0.0.1", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("localhost", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseLine_LeadingWhitespace_TrimmedAndParsed()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine(" 192.168.1.13 bar", 1);
|
||||
string address = "192.168.1.13";
|
||||
string name = "bar";
|
||||
var line = $" {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.Equal("192.168.1.13", e.Address);
|
||||
Assert.Equal(address, e.Address);
|
||||
Assert.Single(e.Names);
|
||||
Assert.Equal("bar", e.Names[0]);
|
||||
Assert.Equal(name, e.Names[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -136,7 +165,8 @@ public class HostsFileLineParserTests
|
||||
public void ParseLine_OnlyAddressNoNames_FailsContent_Passthrough()
|
||||
{
|
||||
// Content requires address + whitespace + at least one name
|
||||
var e = HostsFileLineParser.ParseLine("192.168.1.1", 1);
|
||||
string address = "192.168.1.1";
|
||||
var e = HostsFileLineParser.ParseLine(address, 1);
|
||||
Assert.NotNull(e);
|
||||
Assert.True(e!.IsPassthrough);
|
||||
}
|
||||
@@ -144,7 +174,9 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_Entry_Roundtrips()
|
||||
{
|
||||
var line = "127.0.0.1 localhost";
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var line = $"{address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(line, 1);
|
||||
Assert.NotNull(e);
|
||||
var back = HostsFileLineParser.ToLine(e!);
|
||||
@@ -154,19 +186,23 @@ public class HostsFileLineParserTests
|
||||
[Fact]
|
||||
public void ToLine_CommentedEntry_PrefixPreserved()
|
||||
{
|
||||
var e = HostsFileLineParser.ParseLine("# 127.0.0.1 localhost", 1);
|
||||
string address = "127.0.0.1";
|
||||
string name = "localhost";
|
||||
var commentLine = $"# {address} {name}";
|
||||
var e = HostsFileLineParser.ParseLine(commentLine, 1);
|
||||
Assert.NotNull(e);
|
||||
// When we parse "# ..." we get isComment=true, address="", names=[]. ToLine for passthrough returns RawLine.
|
||||
e = new HostEntry { LineNumber = 1, Address = "127.0.0.1", Names = ["localhost"], IsComment = true };
|
||||
e = new HostEntry { LineNumber = 1, Address = address, Names = [name], IsComment = true };
|
||||
var back = HostsFileLineParser.ToLine(e);
|
||||
Assert.Equal("# 127.0.0.1 localhost", back);
|
||||
Assert.Equal(commentLine, back);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLine_Passthrough_ReturnsRawLine()
|
||||
{
|
||||
var e = new HostEntry { LineNumber = 1, RawLine = " \t ", IsPassthrough = true };
|
||||
Assert.Equal(" \t ", HostsFileLineParser.ToLine(e));
|
||||
var raw = " \t ";
|
||||
var e = new HostEntry { LineNumber = 1, RawLine = raw, IsPassthrough = true };
|
||||
Assert.Equal(raw, HostsFileLineParser.ToLine(e));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@inject ILoggingClient LoggingClient
|
||||
@inject IJSRuntime JSRuntime
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
<dialog @ref="_dialogRef" class="settings-modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">App logs filters</h5>
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted small mb-2">Logger categories matching these prefixes are excluded from the app logs display.</p>
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<input type="text" class="form-control" placeholder="e.g. Microsoft.AspNetCore."
|
||||
@bind="_newPrefix" @bind:event="oninput" @onkeydown="OnNewPrefixKeydown" />
|
||||
<button type="button" class="btn btn-outline-primary" @onclick="AddPrefix" disabled="@string.IsNullOrWhiteSpace(_newPrefix)">Add</button>
|
||||
</div>
|
||||
<ul class="list-group list-group-flush" style="max-height: 12rem; overflow-y: auto;">
|
||||
@foreach (var prefix in _prefixes)
|
||||
{
|
||||
<li class="list-group-item d-flex align-items-center justify-content-between py-1 px-2">
|
||||
<code class="small text-break">@prefix</code>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-danger" aria-label="Remove" @onclick="() => RemovePrefix(prefix)">
|
||||
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@if (_prefixes.Count == 0)
|
||||
{
|
||||
<li class="list-group-item text-muted small py-2">No filters. All categories are shown.</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="modal-footer d-flex justify-content-between align-items-center">
|
||||
<button type="button" class="btn btn-secondary" @onclick="RestoreDefaults" disabled="@_saving">Restore defaults</button>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" @onclick="Save" disabled="@_saving">@(_saving ? "Saving…" : "Save")</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.Components.Web;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
|
||||
public partial class AppLogsFiltersModal : IAsyncDisposable
|
||||
{
|
||||
private ElementReference _dialogRef;
|
||||
private IJSObjectReference? _jsModule;
|
||||
private DotNetObjectReference<AppLogsFiltersModal>? _dotNetRef;
|
||||
private List<string> _prefixes = [];
|
||||
private List<string> _originalPrefixes = [];
|
||||
private string _newPrefix = string.Empty;
|
||||
private bool _saving;
|
||||
private bool _moduleLoaded;
|
||||
private bool _dialogInitialized;
|
||||
private bool _wasVisible;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
[Parameter] public bool IsVisible { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnDialogClosed(string returnValue)
|
||||
{
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
var justOpened = IsVisible && !_wasVisible;
|
||||
_wasVisible = IsVisible;
|
||||
if (justOpened)
|
||||
{
|
||||
_dialogInitialized = false;
|
||||
try
|
||||
{
|
||||
var filters = await LoggingClient.GetFiltersAsync(_cts.Token);
|
||||
_prefixes = filters.ToList();
|
||||
_originalPrefixes = filters.ToList();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* leave current */ }
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException) { }
|
||||
catch (JSDisconnectedException) { }
|
||||
catch (JSException) { }
|
||||
}
|
||||
|
||||
if (IsVisible && _moduleLoaded && _jsModule != null)
|
||||
{
|
||||
if (!_dialogInitialized)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("initDialog", _dialogRef, _dotNetRef);
|
||||
_dialogInitialized = true;
|
||||
}
|
||||
await _jsModule.InvokeVoidAsync("showModal", _dialogRef);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private void AddPrefix()
|
||||
{
|
||||
var p = _newPrefix?.Trim();
|
||||
if (string.IsNullOrEmpty(p) || _prefixes.Contains(p, StringComparer.Ordinal)) return;
|
||||
_prefixes.Add(p);
|
||||
_newPrefix = string.Empty;
|
||||
}
|
||||
|
||||
private void RemovePrefix(string prefix)
|
||||
{
|
||||
_prefixes.Remove(prefix);
|
||||
}
|
||||
|
||||
private void OnNewPrefixKeydown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter")
|
||||
{
|
||||
AddPrefix();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_saving = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
await LoggingClient.SetFiltersAsync(_prefixes, _cts.Token);
|
||||
_originalPrefixes = [.. _prefixes];
|
||||
await Close();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* leave modal open */ }
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreDefaults()
|
||||
{
|
||||
_saving = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var defaults = await LoggingClient.RestoreFilterDefaultsAsync(_cts.Token);
|
||||
_prefixes = defaults.ToList();
|
||||
_originalPrefixes = [.. _prefixes];
|
||||
await Close();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { /* leave modal open */ }
|
||||
finally
|
||||
{
|
||||
_saving = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
_dotNetRef?.Dispose();
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* App logs filters modal – scoped styles; root is the dialog */
|
||||
.settings-modal-dialog {
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0;
|
||||
box-shadow: 0 0.5rem 2rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.settings-modal-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 28rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
min-width: 4.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@* App logs — live view via SignalR from ILogger *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<AppLogsSection> Logger
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
|
||||
<span class="d-flex align-items-center gap-1 flex-wrap">
|
||||
<strong>App logs</strong>
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 bg-success @(_justUpdated ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(_justUpdated ? "Updating…" : "Live")" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="d-flex align-items-center gap-1 flex-wrap">
|
||||
<select class="form-select form-select-sm" style="width: auto;" aria-label="Log level" @bind="_logLevel" @bind:after="OnLogLevelChanged" disabled="@_logLevelDisabled">
|
||||
<option value="Trace">Trace</option>
|
||||
<option value="Debug">Debug</option>
|
||||
<option value="Information">Information</option>
|
||||
<option value="Warning">Warning</option>
|
||||
<option value="Error">Error</option>
|
||||
<option value="Critical">Critical</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="App logs category filters" @onclick="OpenFiltersModal" title="Configure category filters">
|
||||
<i class="bi bi-funnel-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="App logs polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure app logs polling interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
<AppLogsFiltersModal IsVisible="_filtersModalVisible" OnClose="CloseFiltersModal" />
|
||||
<pre id="app-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
|
||||
@@ -0,0 +1,195 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
|
||||
/// <summary>
|
||||
/// App logs block: connects to LogsHub, receives AppLogsUpdate pushes, polls at interval as fallback.
|
||||
/// Snapshot on connect; RequestAppLogsUpdate on poll. Log level is configurable at runtime (persisted to appsettings.Overrides.json).
|
||||
/// </summary>
|
||||
public partial class AppLogsSection : IAsyncDisposable
|
||||
{
|
||||
private const string AppLogsPreId = "app-logs-pre";
|
||||
|
||||
[Inject] private ILoggingClient LoggingClient { get; set; } = null!;
|
||||
|
||||
private bool _justUpdated;
|
||||
private int _intervalSeconds;
|
||||
private string _logLevel = "Information";
|
||||
private bool _logLevelDisabled;
|
||||
private bool _filtersModalVisible;
|
||||
private HubConnection? _hubConnection;
|
||||
private IJSObjectReference? _logsJs;
|
||||
private Timer? _pollTimer;
|
||||
private Timer? _justUpdatedResetTimer;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
|
||||
|
||||
[Parameter] public int LogsMaxLines { get; set; } = 500;
|
||||
|
||||
[Parameter] public bool LogsAutoScroll { get; set; } = true;
|
||||
|
||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||
|
||||
private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…";
|
||||
|
||||
private object LogsOptions => new { maxLines = LogsMaxLines, autoScroll = LogsAutoScroll };
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var next = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
RestartPollTimer();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_intervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUri)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<LogsUpdatePayload>("AppLogsUpdate", OnAppLogsUpdate);
|
||||
|
||||
try
|
||||
{
|
||||
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
|
||||
}
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (prerender)"); return; }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import skipped (circuit disconnected)"); return; }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: JS import failed"); return; }
|
||||
|
||||
await _hubConnection.StartAsync(_cts.Token);
|
||||
RestartPollTimer();
|
||||
|
||||
try
|
||||
{
|
||||
_logLevel = await LoggingClient.GetLevelAsync(_cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* leave default */ }
|
||||
|
||||
// Request initial snapshot (replace)
|
||||
try
|
||||
{
|
||||
await _hubConnection.InvokeAsync("RequestAppLogsSnapshot", _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* ignore */ }
|
||||
}
|
||||
|
||||
private void OpenFiltersModal() => _filtersModalVisible = true;
|
||||
private void CloseFiltersModal() => _filtersModalVisible = false;
|
||||
|
||||
private async Task OnLogLevelChanged()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_logLevel)) return;
|
||||
_logLevelDisabled = true;
|
||||
try
|
||||
{
|
||||
var updated = await LoggingClient.SetLevelAsync(_logLevel, _cts.Token);
|
||||
_logLevel = updated;
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* leave binding as-is */ }
|
||||
finally
|
||||
{
|
||||
_logLevelDisabled = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void RestartPollTimer()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_pollTimer = new Timer(
|
||||
_ => _ = InvokeAsync(PollTick),
|
||||
null,
|
||||
TimeSpan.FromSeconds(_intervalSeconds),
|
||||
TimeSpan.FromSeconds(_intervalSeconds));
|
||||
}
|
||||
|
||||
private async Task PollTick()
|
||||
{
|
||||
if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await _hubConnection.InvokeAsync("RequestAppLogsUpdate", _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception) { /* ignore */ }
|
||||
}
|
||||
|
||||
private async void OnAppLogsUpdate(LogsUpdatePayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", AppLogsPreId, payload.Content, LogsOptions);
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||
}
|
||||
|
||||
private void SetJustUpdated()
|
||||
{
|
||||
_justUpdated = true;
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = new Timer(_ =>
|
||||
{
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = null;
|
||||
_ = InvokeAsync(() =>
|
||||
{
|
||||
_justUpdated = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_logsJs != null)
|
||||
{
|
||||
try { await _logsJs.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "AppLogsSection: DisposeAsync failed"); }
|
||||
_logsJs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Status dot pulse – shared with StatusSection, LogsSection */
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.status-dot-updating {
|
||||
animation: status-dot-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
@namespace DnsmasqWebUI.Components.Dnsmasq
|
||||
@* Recent logs — live view from LogsCommand; only this component re-renders on its timer *@
|
||||
@* Recent logs — live view via SignalR from LogsCommand; client polls at interval, server pushes deltas *@
|
||||
@using DnsmasqWebUI.Models.Logs
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ILogger<LogsSection> Logger
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@if (_status != null && (_status.LogsConfigured || _status.LogsPath != null))
|
||||
{
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between">
|
||||
<strong>Recent logs</strong>
|
||||
<p class="mb-1 mt-3 d-flex align-items-center justify-content-between flex-wrap gap-1">
|
||||
<span class="d-flex align-items-center gap-1">
|
||||
<strong>Recent logs</strong>
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 bg-success @(_justUpdated ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(_justUpdated ? "Updating…" : "Live")" aria-hidden="true"></span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Recent logs polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure recent logs polling interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -14,17 +24,10 @@
|
||||
}
|
||||
@if (_status.LogsConfigured)
|
||||
{
|
||||
@if (string.IsNullOrWhiteSpace(_status.LogsOutput))
|
||||
{
|
||||
<p class="text-muted small">No output.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="pre-block mb-0" style="max-height: 24rem;">@_status.LogsOutput</pre>
|
||||
}
|
||||
<pre id="dnsmasq-logs-pre" class="pre-block mb-0" style="max-height: 24rem;">@_initialPlaceholder</pre>
|
||||
}
|
||||
<div class="mt-2 d-flex flex-wrap gap-2 align-items-center">
|
||||
<button class="btn btn-primary btn-sm" @onclick="RefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm" @onclick="RequestRefreshAsync" disabled="@_refreshing">Refresh</button>
|
||||
@if (_status.LogsPath != null)
|
||||
{
|
||||
<a href="api/status/logs/download" download="dnsmasq.log" class="btn btn-primary btn-sm">Download logs</a>
|
||||
|
||||
@@ -1,81 +1,181 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DnsmasqWebUI.Components.Dnsmasq;
|
||||
|
||||
/// <summary>
|
||||
/// Recent logs block: polls LogsCommand output at its own interval and re-renders only itself.
|
||||
/// Recent logs block: connects to LogsHub, polls at interval via RequestDnsmasqLogs,
|
||||
/// receives DnsmasqLogsUpdate pushes, updates DOM via JS interop.
|
||||
/// </summary>
|
||||
public partial class LogsSection : IDisposable
|
||||
public partial class LogsSection : IAsyncDisposable
|
||||
{
|
||||
private const string LogsPreId = "dnsmasq-logs-pre";
|
||||
|
||||
private DnsmasqServiceStatus? _status;
|
||||
private bool _refreshing;
|
||||
private bool _justUpdated;
|
||||
private int _intervalSeconds;
|
||||
private HubConnection? _hubConnection;
|
||||
private IJSObjectReference? _logsJs;
|
||||
private Timer? _pollTimer;
|
||||
private Timer? _justUpdatedResetTimer;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Timer? _timer;
|
||||
|
||||
[Parameter] public DnsmasqServiceStatus? Status { get; set; }
|
||||
|
||||
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
|
||||
|
||||
[Parameter] public int LogsMaxLines { get; set; } = 500;
|
||||
|
||||
[Parameter] public bool LogsAutoScroll { get; set; } = true;
|
||||
|
||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||
|
||||
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
||||
private object LogsOptions => new { maxLines = LogsMaxLines, autoScroll = LogsAutoScroll };
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
null,
|
||||
TimeSpan.FromSeconds(_intervalSeconds),
|
||||
TimeSpan.FromSeconds(_intervalSeconds));
|
||||
}
|
||||
private string _initialPlaceholder => _hubConnection?.State == HubConnectionState.Connected ? "Waiting for logs…" : "Connecting…";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
_status = Status;
|
||||
var next = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
_timer?.Dispose();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
RestartPollTimer();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_intervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
_status = Status;
|
||||
|
||||
var hubUri = new Uri(new Uri(Navigation.BaseUri), "hubs/logs").ToString();
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUri)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<LogsUpdatePayload>("DnsmasqLogsUpdate", OnDnsmasqLogsUpdate);
|
||||
|
||||
try
|
||||
{
|
||||
_logsJs = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/logs.js");
|
||||
}
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (prerender)"); return; }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: JS import skipped (circuit disconnected)"); return; }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: JS import failed"); return; }
|
||||
|
||||
await _hubConnection.StartAsync(_cts.Token);
|
||||
RestartPollTimer();
|
||||
|
||||
// Initial request
|
||||
await RequestRefreshAsync();
|
||||
}
|
||||
|
||||
private void RestartPollTimer()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_pollTimer = new Timer(
|
||||
_ => _ = InvokeAsync(PollTick),
|
||||
null,
|
||||
TimeSpan.FromSeconds(_intervalSeconds),
|
||||
TimeSpan.FromSeconds(_intervalSeconds));
|
||||
}
|
||||
|
||||
private async Task OnRefreshTick()
|
||||
private async Task PollTick()
|
||||
{
|
||||
if (_cts.Token.IsCancellationRequested) return;
|
||||
await RefreshAsync();
|
||||
StateHasChanged();
|
||||
if (_cts.Token.IsCancellationRequested || _hubConnection?.State != HubConnectionState.Connected)
|
||||
return;
|
||||
await RequestRefreshAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
private async Task RequestRefreshAsync()
|
||||
{
|
||||
if (_hubConnection?.State != HubConnectionState.Connected)
|
||||
return;
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var token = _cts.Token;
|
||||
_status = await StatusClient.GetStatusAsync(token);
|
||||
await _hubConnection.InvokeAsync("RequestDnsmasqLogs", _cts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch
|
||||
catch (Exception)
|
||||
{
|
||||
// Don't overwrite on background refresh
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshing = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
private async void OnDnsmasqLogsUpdate(LogsUpdatePayload payload)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
try
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
if (_logsJs == null) return;
|
||||
try
|
||||
{
|
||||
if (payload.Mode == "replace")
|
||||
await _logsJs.InvokeVoidAsync("replaceLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
else
|
||||
await _logsJs.InvokeVoidAsync("appendLogs", LogsPreId, payload.Content, LogsOptions);
|
||||
SetJustUpdated();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (JSDisconnectedException) { /* Circuit disconnected; ignore */ }
|
||||
catch (InvalidOperationException) { /* Prerender or circuit disposed; ignore */ }
|
||||
catch (JSException) { /* JS error; ignore */ }
|
||||
});
|
||||
}
|
||||
catch (ObjectDisposedException) { /* Component disposed; ignore */ }
|
||||
catch (InvalidOperationException) { /* Circuit disconnected; ignore */ }
|
||||
}
|
||||
|
||||
private void SetJustUpdated()
|
||||
{
|
||||
_justUpdated = true;
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = new Timer(_ =>
|
||||
{
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_justUpdatedResetTimer = null;
|
||||
_ = InvokeAsync(() =>
|
||||
{
|
||||
_justUpdated = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
}, null, TimeSpan.FromSeconds(2), Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_pollTimer?.Dispose();
|
||||
_justUpdatedResetTimer?.Dispose();
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
await _hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
if (_logsJs != null)
|
||||
{
|
||||
try { await _logsJs.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "LogsSection: DisposeAsync failed"); }
|
||||
_logsJs = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Status dot pulse – shared with StatusSection */
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.status-dot-updating {
|
||||
animation: status-dot-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
@@ -7,8 +7,9 @@
|
||||
<strong>Service status</strong>
|
||||
@{
|
||||
var isActive = _status.DnsmasqStatus == "active";
|
||||
var isUpdating = _refreshing || IsReloading;
|
||||
}
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger")" style="width: 0.5rem; height: 0.5rem;" title="@(isActive ? "Running" : "Not running")" aria-hidden="true"></span>
|
||||
<span class="status-dot d-inline-block rounded-circle flex-shrink-0 @(isActive ? "bg-success" : "bg-danger") @(isUpdating ? "status-dot-updating" : null)" style="width: 0.5rem; height: 0.5rem;" title="@(isUpdating ? "Updating…" : (isActive ? "Running" : "Not running"))" aria-hidden="true"></span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-body" aria-label="Service status polling settings" @onclick="() => OnOpenSettings.InvokeAsync()" title="Configure service status polling interval">
|
||||
<i class="bi bi-gear-fill bi-gear-sm" aria-hidden="true"></i>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -17,13 +18,15 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
[Parameter] public int RefreshIntervalSeconds { get; set; } = 15;
|
||||
|
||||
[Parameter] public bool IsReloading { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnOpenSettings { get; set; }
|
||||
|
||||
[Inject] private IStatusClient StatusClient { get; set; } = null!;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_intervalSeconds = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
_intervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
await RefreshAsync();
|
||||
_timer = new Timer(
|
||||
_ => _ = InvokeAsync(OnRefreshTick),
|
||||
@@ -34,7 +37,7 @@ public partial class StatusSection : IDisposable
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
var next = Math.Clamp(RefreshIntervalSeconds, 5, 300);
|
||||
var next = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(RefreshIntervalSeconds);
|
||||
if (next == _intervalSeconds) return;
|
||||
_intervalSeconds = next;
|
||||
_timer?.Dispose();
|
||||
@@ -52,9 +55,13 @@ public partial class StatusSection : IDisposable
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static readonly TimeSpan MinUpdatingDuration = TimeSpan.FromSeconds(1);
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
var started = DateTime.UtcNow;
|
||||
_refreshing = true;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
var token = _cts.Token;
|
||||
@@ -67,6 +74,13 @@ public partial class StatusSection : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - started;
|
||||
var remaining = MinUpdatingDuration - elapsed;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
try { await Task.Delay(remaining, _cts.Token).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
_refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Status dot pulse when fetching/updating status */
|
||||
@keyframes status-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.status-dot-updating {
|
||||
animation: status-dot-pulse 0.7s ease-in-out infinite;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@implements IDisposable
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
@@ -14,8 +16,41 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss" aria-label="Dismiss">×</span>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private bool _settingsModalOpen;
|
||||
private string _settingsModalTitle = "Settings";
|
||||
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SettingsModalService.OpenRequested += OnSettingsOpenRequested;
|
||||
}
|
||||
|
||||
private void OnSettingsOpenRequested(SettingsModalContext context, string title)
|
||||
{
|
||||
_settingsModalContext = context;
|
||||
_settingsModalTitle = title;
|
||||
_settingsModalOpen = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task CloseSettingsModal()
|
||||
{
|
||||
_settingsModalOpen = false;
|
||||
SettingsModalService.NotifySettingsChanged();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.OpenRequested -= OnSettingsOpenRequested;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
@namespace DnsmasqWebUI.Components.Layout
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid d-flex align-items-center">
|
||||
<div class="top-row-inner">
|
||||
<a class="navbar-brand d-flex align-items-center gap-2" href="" aria-label="@AppTitle home">
|
||||
<span class="brand-logo-wrap"><img src="logo.webp" alt="" class="brand-logo" /></span>
|
||||
<span>@AppTitle</span>
|
||||
</a>
|
||||
<div class="nav-top-controls">
|
||||
<button type="button" class="nav-settings-btn" aria-label="Settings" title="Settings" @onclick="OpenAllSettings">
|
||||
<i class="bi bi-gear-fill" aria-hidden="true"></i>
|
||||
</button>
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
|
||||
</div>
|
||||
</div>
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" id="nav-toggler" aria-label="Toggle navigation" />
|
||||
</div>
|
||||
|
||||
<div class="nav-scrollable" onclick="document.getElementById('nav-toggler')?.click()">
|
||||
@@ -33,4 +39,6 @@
|
||||
|
||||
@code {
|
||||
private string? AppTitle => AppOptions?.Value?.EffectiveTitle;
|
||||
|
||||
private void OpenAllSettings() => SettingsModalService.Open(SettingsModalContext.All, "Settings");
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Flex row: brand left, controls right; single line, aligned with brand. */
|
||||
.top-row-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem 0.5rem 0.75rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Settings + hamburger: push right, align with brand */
|
||||
.nav-top-controls {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 1rem;
|
||||
transform: translateY(-50%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
@@ -16,10 +37,29 @@
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: relative;
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
/* Settings button: same height as toggler on mobile for alignment */
|
||||
.nav-settings-btn {
|
||||
padding: 0.375rem 0.5rem;
|
||||
height: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.375rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-settings-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.12);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-settings-btn .bi-gear-fill {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
@@ -27,13 +67,24 @@
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: rgba(255, 255, 255, 0.95) !important;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.navbar-brand:hover {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Logo size/alignment: see app.css .sidebar .navbar-brand .brand-logo-wrap / .brand-logo */
|
||||
/* Smaller logo on mobile so controls align with brand text */
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row .navbar-brand .brand-logo-wrap {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.top-row .navbar-brand .brand-logo {
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Bootstrap Icons in nav: white, sized to match link text */
|
||||
.nav-item .nav-icon {
|
||||
@@ -84,7 +135,8 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
/* Toggler is inside top-row; nav-scrollable is sibling of top-row */
|
||||
.top-row:has(.navbar-toggler:checked) ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@implements IAsyncDisposable
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ILogger<SettingsModal> Logger
|
||||
|
||||
@if (IsVisible)
|
||||
{
|
||||
@@ -12,41 +13,98 @@
|
||||
<button type="button" class="btn-close" @onclick="Close" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (_validationErrors.Count > 0)
|
||||
{
|
||||
<div class="alert alert-danger mb-3" role="alert">
|
||||
<strong>Please fix the following:</strong>
|
||||
<ul class="mb-0 mt-1">
|
||||
@foreach (var err in _validationErrors)
|
||||
{
|
||||
<li>@err</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@if (SettingsContext == SettingsModalContext.All)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Search settings</label>
|
||||
<input type="search" class="form-control" placeholder="Search..."
|
||||
@bind="_searchTerm" @bind:event="oninput" />
|
||||
<input type="search" class="form-control" placeholder="Search..." autocomplete="off"
|
||||
@bind="_searchTerm" @bind:event="oninput" @bind:after="FilterSettings" />
|
||||
</div>
|
||||
}
|
||||
@if (ShouldShowSection("ServiceStatus"))
|
||||
@if (ShouldShowSection(SettingsModalSections.ServiceStatus))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.ServiceStatus];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">Service status</h6>
|
||||
<label for="status-interval" class="form-label">Polling interval (seconds)</label>
|
||||
<input id="status-interval" type="number" class="form-control" min="5" step="1"
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="status-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="status-interval" type="number" class="form-control" min="@ClientSettingsFields.ServiceStatusPollingInterval.Min" max="@ClientSettingsFields.ServiceStatusPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.ServiceStatusPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection("Logs"))
|
||||
@if (ShouldShowSection(SettingsModalSections.Logs))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Logs];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">Recent logs</h6>
|
||||
<label for="logs-interval" class="form-label">Polling interval (seconds)</label>
|
||||
<input id="logs-interval" type="number" class="form-control" min="5" step="1"
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="logs-interval" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsPollingInterval.Min" max="@ClientSettingsFields.RecentLogsPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.RecentLogsPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection("Leases"))
|
||||
@if (ShouldShowSection(SettingsModalSections.AppLogs))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.AppLogs];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">DHCP leases</h6>
|
||||
<label for="leases-interval" class="form-label">Refresh interval (seconds)</label>
|
||||
<input id="leases-interval" type="number" class="form-control" min="5" step="1"
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="app-logs-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="app-logs-interval" type="number" class="form-control" min="@ClientSettingsFields.AppLogsPollingInterval.Min" max="@ClientSettingsFields.AppLogsPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.AppLogsPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.Leases))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.Leases];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<label for="leases-interval" class="form-label">@meta.FieldLabel</label>
|
||||
<input id="leases-interval" type="number" class="form-control" min="@ClientSettingsFields.LeasesPollingInterval.Min" max="@ClientSettingsFields.LeasesPollingInterval.Max" step="1"
|
||||
@bind="_editingSettings.LeasesPollingIntervalSeconds" />
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.RecentLogsDisplay))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.RecentLogsDisplay];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<div class="mb-2">
|
||||
<label for="recent-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="recent-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.RecentLogsMaxLines.Min" max="@ClientSettingsFields.RecentLogsMaxLines.Max" step="1"
|
||||
@bind="_editingSettings.RecentLogsMaxLines" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="recent-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.RecentLogsAutoScroll" />
|
||||
<label for="recent-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@if (ShouldShowSection(SettingsModalSections.AppLogsDisplay))
|
||||
{
|
||||
var meta = SettingsModalSections.All[SettingsModalSections.AppLogsDisplay];
|
||||
<section class="mb-4">
|
||||
<h6 class="text-muted">@meta.DisplayName</h6>
|
||||
<div class="mb-2">
|
||||
<label for="app-logs-max-lines" class="form-label">Max lines</label>
|
||||
<input id="app-logs-max-lines" type="number" class="form-control" min="@ClientSettingsFields.AppLogsMaxLines.Min" max="@ClientSettingsFields.AppLogsMaxLines.Max" step="1"
|
||||
@bind="_editingSettings.AppLogsMaxLines" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input id="app-logs-auto-scroll" type="checkbox" class="form-check-input" @bind="_editingSettings.AppLogsAutoScroll" />
|
||||
<label for="app-logs-auto-scroll" class="form-check-label">Auto-scroll when new logs arrive</label>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @onclick="Close">Cancel</button>
|
||||
|
||||
@@ -16,6 +16,7 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
private IJSObjectReference? _jsModule;
|
||||
private DotNetObjectReference<SettingsModal>? _dotNetRef;
|
||||
private ClientSettings _editingSettings = new();
|
||||
private List<string> _validationErrors = new();
|
||||
private string _searchTerm = string.Empty;
|
||||
private bool _moduleLoaded;
|
||||
private bool _dialogInitialized;
|
||||
@@ -38,12 +39,19 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
// Only load from storage when the modal opens; parent re-renders (e.g. refresh timer) must not overwrite in-progress edits
|
||||
if (justOpened)
|
||||
{
|
||||
_searchTerm = string.Empty;
|
||||
_validationErrors.Clear();
|
||||
_editingSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_editingSettings = new ClientSettings
|
||||
{
|
||||
ServiceStatusPollingIntervalSeconds = _editingSettings.ServiceStatusPollingIntervalSeconds,
|
||||
RecentLogsPollingIntervalSeconds = _editingSettings.RecentLogsPollingIntervalSeconds,
|
||||
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds
|
||||
AppLogsPollingIntervalSeconds = _editingSettings.AppLogsPollingIntervalSeconds,
|
||||
LeasesPollingIntervalSeconds = _editingSettings.LeasesPollingIntervalSeconds,
|
||||
RecentLogsMaxLines = _editingSettings.RecentLogsMaxLines,
|
||||
RecentLogsAutoScroll = _editingSettings.RecentLogsAutoScroll,
|
||||
AppLogsMaxLines = _editingSettings.AppLogsMaxLines,
|
||||
AppLogsAutoScroll = _editingSettings.AppLogsAutoScroll
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -52,9 +60,15 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
try
|
||||
{
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./js/settings-modal.js");
|
||||
_dotNetRef = DotNetObjectReference.Create(this);
|
||||
_moduleLoaded = true;
|
||||
}
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: JS import skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: JS import failed"); }
|
||||
}
|
||||
|
||||
if (IsVisible && _moduleLoaded && _jsModule != null)
|
||||
@@ -68,48 +82,52 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void FilterSettings() => StateHasChanged();
|
||||
|
||||
private bool ShouldShowSection(string key)
|
||||
{
|
||||
if (SettingsContext == SettingsModalContext.ServicePolling)
|
||||
return key == "ServiceStatus";
|
||||
if (SettingsContext == SettingsModalContext.LogsPolling)
|
||||
return key == "Logs";
|
||||
if (SettingsContext == SettingsModalContext.LeasesPolling)
|
||||
return key == "Leases";
|
||||
if (SettingsContext == SettingsModalContext.All)
|
||||
var keys = SettingsModalSections.GetSectionKeysForContext(SettingsContext);
|
||||
if (keys != null)
|
||||
return keys.Any(k => string.Equals(key, k, StringComparison.OrdinalIgnoreCase));
|
||||
return SettingsModalSections.MatchesSearch(key, _searchTerm);
|
||||
}
|
||||
|
||||
private void RunValidation()
|
||||
{
|
||||
_validationErrors.Clear();
|
||||
var checks = new (string Section, int Value, ClientSettingsFields.FieldBounds Bounds)[]
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_searchTerm)) return true;
|
||||
var term = _searchTerm.Trim();
|
||||
return key switch
|
||||
{
|
||||
"ServiceStatus" => "service status polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
"Logs" => "recent logs polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
"Leases" => "dhcp leases refresh polling".Contains(term, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false
|
||||
};
|
||||
(SettingsModalSections.ServiceStatus, _editingSettings.ServiceStatusPollingIntervalSeconds, ClientSettingsFields.ServiceStatusPollingInterval),
|
||||
(SettingsModalSections.Logs, _editingSettings.RecentLogsPollingIntervalSeconds, ClientSettingsFields.RecentLogsPollingInterval),
|
||||
(SettingsModalSections.AppLogs, _editingSettings.AppLogsPollingIntervalSeconds, ClientSettingsFields.AppLogsPollingInterval),
|
||||
(SettingsModalSections.Leases, _editingSettings.LeasesPollingIntervalSeconds, ClientSettingsFields.LeasesPollingInterval),
|
||||
(SettingsModalSections.RecentLogsDisplay, _editingSettings.RecentLogsMaxLines, ClientSettingsFields.RecentLogsMaxLines),
|
||||
(SettingsModalSections.AppLogsDisplay, _editingSettings.AppLogsMaxLines, ClientSettingsFields.AppLogsMaxLines),
|
||||
};
|
||||
foreach (var (section, value, bounds) in checks)
|
||||
{
|
||||
if (ShouldShowSection(section) && bounds.Validate(value) is { } err)
|
||||
_validationErrors.Add(err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
_editingSettings.ServiceStatusPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
|
||||
_editingSettings.RecentLogsPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.RecentLogsPollingIntervalSeconds, 5, 300);
|
||||
_editingSettings.LeasesPollingIntervalSeconds = Math.Clamp(
|
||||
_editingSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
RunValidation();
|
||||
if (_validationErrors.Count > 0)
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
await ClientSettingsService.SaveSettingsAsync(_editingSettings);
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.InvokeVoidAsync("closeModal", _dialogRef);
|
||||
await (_jsModule?.InvokeVoidAsync("closeModal", _dialogRef) ?? ValueTask.CompletedTask);
|
||||
await OnClose.InvokeAsync();
|
||||
}
|
||||
|
||||
@@ -125,13 +143,12 @@ public partial class SettingsModal : IAsyncDisposable
|
||||
/// </summary>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_jsModule != null)
|
||||
await _jsModule.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
if (_jsModule != null)
|
||||
{
|
||||
try { await _jsModule.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync skipped (circuit disconnected)"); }
|
||||
catch (JSException ex) { Logger.LogDebug(ex, "SettingsModal: DisposeAsync failed"); }
|
||||
_jsModule = null;
|
||||
}
|
||||
_dotNetRef?.Dispose();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject ILeasesClient LeasesClient
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
|
||||
@@ -320,7 +321,6 @@ else
|
||||
<button class="btn btn-primary" @onclick="() => LoadLeases(showLoading: true, forceRefresh: true)" disabled="@_leasesLoading">Refresh</button>
|
||||
</div>
|
||||
}
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -336,9 +336,6 @@ else
|
||||
private Timer? _leasesRefreshTimer;
|
||||
private int _leasesRefreshIntervalSeconds = 5;
|
||||
private string? _message;
|
||||
private bool _settingsModalOpen;
|
||||
private string _settingsModalTitle = "Client settings";
|
||||
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
|
||||
private bool _saving;
|
||||
|
||||
private IEnumerable<DhcpHostEntry> _visibleStaticEntries =>
|
||||
@@ -402,9 +399,16 @@ else
|
||||
("2592000", "30 days"),
|
||||
};
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SettingsModalService.SettingsChanged += OnSettingsChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
await Load();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
@@ -414,7 +418,7 @@ else
|
||||
try
|
||||
{
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
|
||||
_status = await StatusClient.GetStatusAsync();
|
||||
var entries = await DhcpHostsClient.GetDhcpHostsAsync();
|
||||
_entries = entries?.ToList() ?? new List<DhcpHostEntry>();
|
||||
@@ -428,20 +432,18 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenLeasesSettings()
|
||||
{
|
||||
_settingsModalContext = SettingsModalContext.LeasesPolling;
|
||||
_settingsModalTitle = "Leases refresh interval";
|
||||
_settingsModalOpen = true;
|
||||
}
|
||||
private void OpenLeasesSettings() => SettingsModalService.Open(SettingsModalContext.LeasesPolling, "Leases refresh interval");
|
||||
|
||||
private async Task CloseSettingsModal()
|
||||
private async void OnSettingsChanged()
|
||||
{
|
||||
_settingsModalOpen = false;
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = Math.Clamp(clientSettings.LeasesPollingIntervalSeconds, 5, 300);
|
||||
if (_status != null && _status.LeasesPathConfigured)
|
||||
StartLeasesRefreshTimer();
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_leasesRefreshIntervalSeconds = ClientSettingsFields.LeasesPollingInterval.Clamp(clientSettings.LeasesPollingIntervalSeconds);
|
||||
if (_status != null && _status.LeasesPathConfigured)
|
||||
StartLeasesRefreshTimer();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void StartLeasesRefreshTimer()
|
||||
@@ -640,6 +642,7 @@ else
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
||||
_leasesRefreshTimer?.Dispose();
|
||||
_leasesRefreshTimer = null;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@inject IConfigSetClient ConfigSetClient
|
||||
@inject IReloadClient ReloadClient
|
||||
@inject IClientSettingsService ClientSettingsService
|
||||
@inject ISettingsModalService SettingsModalService
|
||||
@inject IOptions<ApplicationOptions> AppOptions
|
||||
@implements IDisposable
|
||||
|
||||
@@ -77,10 +78,9 @@ else if (_status != null)
|
||||
</div>
|
||||
}
|
||||
|
||||
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<LogsSection RefreshIntervalSeconds="_recentLogsIntervalSeconds" OnOpenSettings="OpenRecentLogsSettings" />
|
||||
|
||||
<SettingsModal IsVisible="_settingsModalOpen" Title="@_settingsModalTitle" SettingsContext="_settingsModalContext" OnClose="CloseSettingsModal" />
|
||||
<StatusSection RefreshIntervalSeconds="_serviceStatusIntervalSeconds" IsReloading="_reloading" OnOpenSettings="OpenServiceStatusSettings" />
|
||||
<LogsSection Status="_status" RefreshIntervalSeconds="_recentLogsIntervalSeconds" LogsMaxLines="_recentLogsMaxLines" LogsAutoScroll="_recentLogsAutoScroll" OnOpenSettings="OpenRecentLogsSettings" />
|
||||
<AppLogsSection RefreshIntervalSeconds="_appLogsIntervalSeconds" LogsMaxLines="_appLogsMaxLines" LogsAutoScroll="_appLogsAutoScroll" OnOpenSettings="OpenAppLogsSettings" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@@ -88,10 +88,11 @@ else if (_status != null)
|
||||
private string AppTitle => AppOptions!.Value!.EffectiveTitle;
|
||||
private int _serviceStatusIntervalSeconds = 15;
|
||||
private int _recentLogsIntervalSeconds = 15;
|
||||
private bool _settingsModalOpen;
|
||||
private string _settingsModalTitle = "Client settings";
|
||||
private SettingsModalContext _settingsModalContext = SettingsModalContext.All;
|
||||
private ClientSettings _clientSettings = new();
|
||||
private int _appLogsIntervalSeconds = 15;
|
||||
private int _recentLogsMaxLines = 500;
|
||||
private bool _recentLogsAutoScroll = true;
|
||||
private int _appLogsMaxLines = 500;
|
||||
private bool _appLogsAutoScroll = true;
|
||||
|
||||
private DnsmasqServiceStatus? _status;
|
||||
private DnsmasqConfigSet? _configSet;
|
||||
@@ -103,16 +104,27 @@ else if (_status != null)
|
||||
private bool _reloadSuccess;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
SettingsModalService.SettingsChanged += OnSettingsChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender) return;
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
await Task.Delay(1, _cts.Token);
|
||||
var token = _cts.Token;
|
||||
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
|
||||
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
|
||||
_recentLogsIntervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(clientSettings.RecentLogsPollingIntervalSeconds);
|
||||
_appLogsIntervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(clientSettings.AppLogsPollingIntervalSeconds);
|
||||
_recentLogsMaxLines = ClientSettingsFields.RecentLogsMaxLines.Clamp(clientSettings.RecentLogsMaxLines);
|
||||
_recentLogsAutoScroll = clientSettings.RecentLogsAutoScroll;
|
||||
_appLogsMaxLines = ClientSettingsFields.AppLogsMaxLines.Clamp(clientSettings.AppLogsMaxLines);
|
||||
_appLogsAutoScroll = clientSettings.AppLogsAutoScroll;
|
||||
_status = await StatusClient.GetStatusAsync(token);
|
||||
if (_status != null)
|
||||
{
|
||||
@@ -137,12 +149,31 @@ else if (_status != null)
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenServiceStatusSettings() => OpenSettingsModal(SettingsModalContext.ServicePolling, "Service status polling");
|
||||
private void OpenServiceStatusSettings() => SettingsModalService.Open(SettingsModalContext.ServicePolling, "Service status polling");
|
||||
|
||||
private void OpenRecentLogsSettings() => OpenSettingsModal(SettingsModalContext.LogsPolling, "Recent logs polling");
|
||||
private void OpenRecentLogsSettings() => SettingsModalService.Open(SettingsModalContext.LogsPolling, "Recent logs polling");
|
||||
|
||||
private void OpenAppLogsSettings() => SettingsModalService.Open(SettingsModalContext.AppLogsPolling, "App logs polling");
|
||||
|
||||
private async void OnSettingsChanged()
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
var clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_serviceStatusIntervalSeconds = ClientSettingsFields.ServiceStatusPollingInterval.Clamp(clientSettings.ServiceStatusPollingIntervalSeconds);
|
||||
_recentLogsIntervalSeconds = ClientSettingsFields.RecentLogsPollingInterval.Clamp(clientSettings.RecentLogsPollingIntervalSeconds);
|
||||
_appLogsIntervalSeconds = ClientSettingsFields.AppLogsPollingInterval.Clamp(clientSettings.AppLogsPollingIntervalSeconds);
|
||||
_recentLogsMaxLines = ClientSettingsFields.RecentLogsMaxLines.Clamp(clientSettings.RecentLogsMaxLines);
|
||||
_recentLogsAutoScroll = clientSettings.RecentLogsAutoScroll;
|
||||
_appLogsMaxLines = ClientSettingsFields.AppLogsMaxLines.Clamp(clientSettings.AppLogsMaxLines);
|
||||
_appLogsAutoScroll = clientSettings.AppLogsAutoScroll;
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task ReloadDnsmasq()
|
||||
{
|
||||
@@ -193,24 +224,9 @@ else if (_status != null)
|
||||
return parts.Count > 0 ? string.Join("\n\n", parts) : null;
|
||||
}
|
||||
|
||||
private void OpenSettingsModal(SettingsModalContext context, string title)
|
||||
{
|
||||
_settingsModalContext = context;
|
||||
_settingsModalTitle = title;
|
||||
_settingsModalOpen = true;
|
||||
}
|
||||
|
||||
private async Task CloseSettingsModal()
|
||||
{
|
||||
_settingsModalOpen = false;
|
||||
_clientSettings = await ClientSettingsService.LoadSettingsAsync();
|
||||
_serviceStatusIntervalSeconds = Math.Clamp(_clientSettings.ServiceStatusPollingIntervalSeconds, 5, 300);
|
||||
_recentLogsIntervalSeconds = Math.Clamp(_clientSettings.RecentLogsPollingIntervalSeconds, 5, 300);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SettingsModalService.SettingsChanged -= OnSettingsChanged;
|
||||
_cts.Cancel();
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -14,12 +16,14 @@ public class ConfigController : ControllerBase
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly ILogger<ConfigController> _logger;
|
||||
|
||||
public ConfigController(IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IReloadService reloadService)
|
||||
public ConfigController(IDnsmasqConfigSetService configSetService, IDnsmasqConfigService configService, IReloadService reloadService, ILogger<ConfigController> logger)
|
||||
{
|
||||
_configSetService = configSetService;
|
||||
_configService = configService;
|
||||
_reloadService = reloadService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Returns the config set (main + conf-file/conf-dir) and managed file path. Read-only structure for UI.</summary>
|
||||
@@ -28,11 +32,13 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get config set");
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
return Ok(set);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.ConfigGetSetFailed, ex, "Get config set failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -43,11 +49,13 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get managed config");
|
||||
var content = await _configService.ReadManagedConfigAsync(ct);
|
||||
return Ok(content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.ConfigGetManagedFailed, ex, "Get managed config failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -62,10 +70,12 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
await _configService.WriteManagedConfigAsync(lines, ct);
|
||||
var reload = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation(LogEvents.ConfigPutManagedSuccess, "Config managed updated, reload success={Success}", reload.Success);
|
||||
return Ok(new { saved = true, reload = new { reload.Success, reload.ExitCode, reload.StdErr } });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.ConfigPutManagedFailed, ex, "Put managed config failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -11,11 +13,13 @@ public class DhcpController : ControllerBase
|
||||
{
|
||||
private readonly IDnsmasqConfigService _configService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly ILogger<DhcpController> _logger;
|
||||
|
||||
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService)
|
||||
public DhcpController(IDnsmasqConfigService configService, IReloadService reloadService, ILogger<DhcpController> logger)
|
||||
{
|
||||
_configService = configService;
|
||||
_reloadService = reloadService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("hosts")]
|
||||
@@ -23,11 +27,13 @@ public class DhcpController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get DHCP hosts");
|
||||
var entries = await _configService.ReadDhcpHostsAsync(ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.DhcpGetHostsFailed, ex, "Get DHCP hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -41,14 +47,17 @@ public class DhcpController : ControllerBase
|
||||
{
|
||||
await _configService.WriteDhcpHostsAsync(entries, ct);
|
||||
var reload = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation(LogEvents.DhcpPutHostsSuccess, "DHCP hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
|
||||
return Ok(new SaveWithReloadResult(true, reload));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
_logger.LogWarning(LogEvents.DhcpPutHostsValidationFailed, ex, "DHCP hosts put validation failed");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.DhcpPutHostsFailed, ex, "Put DHCP hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -12,12 +14,14 @@ public class HostsController : ControllerBase
|
||||
private readonly IHostsFileService _hostsService;
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly IHostsCache _hostsCache;
|
||||
private readonly ILogger<HostsController> _logger;
|
||||
|
||||
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache)
|
||||
public HostsController(IHostsFileService hostsService, IReloadService reloadService, IHostsCache hostsCache, ILogger<HostsController> logger)
|
||||
{
|
||||
_hostsService = hostsService;
|
||||
_reloadService = reloadService;
|
||||
_hostsCache = hostsCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -25,11 +29,13 @@ public class HostsController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get hosts");
|
||||
var entries = await _hostsService.ReadAsync(ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.HostsGetFailed, ex, "Get hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -40,11 +46,13 @@ public class HostsController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get readonly hosts");
|
||||
var snapshot = await _hostsCache.GetSnapshotAsync(ct);
|
||||
return Ok(snapshot.ReadOnlyFiles);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.HostsGetReadOnlyFailed, ex, "Get readonly hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -58,14 +66,17 @@ public class HostsController : ControllerBase
|
||||
{
|
||||
await _hostsService.WriteAsync(entries, ct);
|
||||
var reload = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation(LogEvents.HostsPutSuccess, "Hosts saved, count={Count}, reload success={Success}", entries.Count, reload.Success);
|
||||
return Ok(new SaveWithReloadResult(true, reload));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(LogEvents.HostsPutValidationFailed, ex, "Hosts put validation failed");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.HostsPutFailed, ex, "Put hosts failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -10,11 +12,13 @@ public class LeasesController : ControllerBase
|
||||
{
|
||||
private readonly ILeasesFileService _leasesService;
|
||||
private readonly ILeasesCache _cache;
|
||||
private readonly ILogger<LeasesController> _logger;
|
||||
|
||||
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache)
|
||||
public LeasesController(ILeasesFileService leasesService, ILeasesCache cache, ILogger<LeasesController> logger)
|
||||
{
|
||||
_leasesService = leasesService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -22,6 +26,7 @@ public class LeasesController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get leases, refresh={Refresh}", refresh);
|
||||
if (refresh)
|
||||
_cache.Invalidate();
|
||||
var (available, entries) = await _leasesService.TryReadAsync(ct);
|
||||
@@ -33,6 +38,7 @@ public class LeasesController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.LeasesGetFailed, ex, "Get leases failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class LoggingController : ControllerBase
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IConfigurationRoot _configurationRoot;
|
||||
private readonly RuntimeOverridesOptions _overridesOptions;
|
||||
private readonly ILogger<LoggingController> _logger;
|
||||
|
||||
public LoggingController(
|
||||
IConfiguration configuration,
|
||||
IOptions<RuntimeOverridesOptions> overridesOptions,
|
||||
ILogger<LoggingController> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_configurationRoot = (IConfigurationRoot)configuration;
|
||||
_overridesOptions = overridesOptions.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Get the current minimum log level from configuration (Logging:LogLevel:Default).</summary>
|
||||
[HttpGet("level")]
|
||||
public ActionResult<LogLevelResponse> GetLevel()
|
||||
{
|
||||
var level = ParseLevel(_configuration["Logging:LogLevel:Default"]) ?? LogLevel.Information;
|
||||
return Ok(new LogLevelResponse(LevelName(level)));
|
||||
}
|
||||
|
||||
/// <summary>Set the minimum log level. Writes to appsettings.Overrides.json and reloads config.</summary>
|
||||
[HttpPost("level")]
|
||||
public ActionResult<LogLevelResponse> SetLevel([FromBody] LogLevelRequest request)
|
||||
{
|
||||
var level = ParseLevel(request?.LogLevel);
|
||||
if (level == null)
|
||||
{
|
||||
_logger.LogWarning(LogEvents.LogLevelSetBadRequest, "Set log level failed: invalid level requested");
|
||||
return BadRequest(new { error = "Invalid LogLevel. Use Trace, Debug, Information, Warning, Error, or Critical." });
|
||||
}
|
||||
|
||||
var path = GetOverridesPath();
|
||||
if (!TryUpdateOverrides(path, logLevel: level.Value, excludedPrefixes: null))
|
||||
{
|
||||
_logger.LogError(LogEvents.LogLevelSetFailed, "Failed to write log level overrides file to {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to write overrides file." });
|
||||
}
|
||||
|
||||
_configurationRoot.Reload();
|
||||
_logger.LogInformation(LogEvents.LogLevelChanged, "Log level changed to {Level}", LevelName(level.Value));
|
||||
return Ok(new LogLevelResponse(LevelName(level.Value)));
|
||||
}
|
||||
|
||||
/// <summary>Get excluded category prefixes. Prefers runtime overrides (ExcludedCategoryPrefixesOverrides) when present; otherwise uses defaults (ExcludedCategoryPrefixes).</summary>
|
||||
[HttpGet("filters")]
|
||||
public ActionResult<FiltersResponse> GetFilters()
|
||||
{
|
||||
var prefixes = AppLogsConfigHelper.GetEffectiveExcludedPrefixes(_configuration);
|
||||
return Ok(new FiltersResponse(prefixes));
|
||||
}
|
||||
|
||||
/// <summary>Set excluded category prefixes. Writes to appsettings.Overrides.json and reloads config.</summary>
|
||||
[HttpPost("filters")]
|
||||
public ActionResult<FiltersResponse> SetFilters([FromBody] FiltersRequest request)
|
||||
{
|
||||
var prefixes = request?.ExcludedCategoryPrefixes ?? [];
|
||||
var path = GetOverridesPath();
|
||||
if (!TryUpdateOverrides(path, logLevel: null, excludedPrefixes: prefixes))
|
||||
{
|
||||
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to write filters overrides file to {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to write overrides file." });
|
||||
}
|
||||
|
||||
_configurationRoot.Reload();
|
||||
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters updated, count={Count}", prefixes.Count);
|
||||
return Ok(new FiltersResponse(prefixes));
|
||||
}
|
||||
|
||||
/// <summary>Restore filter defaults by removing overrides. Uses ExcludedCategoryPrefixes from appsettings.json.</summary>
|
||||
[HttpPost("filters/restore-defaults")]
|
||||
public ActionResult<FiltersResponse> RestoreFilterDefaults()
|
||||
{
|
||||
var path = GetOverridesPath();
|
||||
if (!TryRemoveFilterOverrides(path))
|
||||
{
|
||||
_logger.LogError(LogEvents.FiltersSetFailed, "Failed to remove filter overrides from {Path}", path);
|
||||
return StatusCode(500, new { error = "Failed to update overrides file." });
|
||||
}
|
||||
|
||||
_configurationRoot.Reload();
|
||||
var defaults = AppLogsConfigHelper.GetDefaultExcludedPrefixes(_configuration);
|
||||
_logger.LogInformation(LogEvents.FiltersChanged, "App logs filters restored to defaults, count={Count}", defaults.Count);
|
||||
return Ok(new FiltersResponse(defaults));
|
||||
}
|
||||
|
||||
private string GetOverridesPath()
|
||||
{
|
||||
var path = _overridesOptions.FilePath?.Trim();
|
||||
return string.IsNullOrEmpty(path)
|
||||
? Path.Combine(AppContext.BaseDirectory, RuntimeOverridesOptions.DefaultFileName)
|
||||
: path;
|
||||
}
|
||||
|
||||
private bool TryUpdateOverrides(string filePath, LogLevel? logLevel, List<string>? excludedPrefixes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var root = LoadOverrides(filePath);
|
||||
|
||||
if (logLevel.HasValue)
|
||||
{
|
||||
root["Logging"] = new JsonObject
|
||||
{
|
||||
["LogLevel"] = new JsonObject { ["Default"] = LevelName(logLevel.Value) }
|
||||
};
|
||||
}
|
||||
|
||||
if (excludedPrefixes != null)
|
||||
{
|
||||
// Use sentinel when empty so .NET config returns non-null (empty arrays bind to null)
|
||||
var values = excludedPrefixes.Count == 0
|
||||
? new[] { JsonValue.Create(AppLogsConfigHelper.ExplicitlyEmptySentinel) }
|
||||
: excludedPrefixes.Select(s => JsonValue.Create(s)).ToArray();
|
||||
root["AppLogs"] = new JsonObject
|
||||
{
|
||||
["ExcludedCategoryPrefixesOverrides"] = new JsonArray(values)
|
||||
};
|
||||
}
|
||||
|
||||
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
System.IO.File.WriteAllText(filePath, json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryRemoveFilterOverrides(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var root = LoadOverrides(filePath);
|
||||
if (root["AppLogs"] is JsonObject appLogs)
|
||||
{
|
||||
appLogs.Remove("ExcludedCategoryPrefixesOverrides");
|
||||
if (appLogs.Count == 0)
|
||||
root.Remove("AppLogs");
|
||||
}
|
||||
|
||||
var json = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
|
||||
System.IO.File.WriteAllText(filePath, json);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonObject LoadOverrides(string filePath)
|
||||
{
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
return new JsonObject();
|
||||
|
||||
try
|
||||
{
|
||||
var json = System.IO.File.ReadAllText(filePath);
|
||||
var node = JsonNode.Parse(json);
|
||||
return node as JsonObject ?? new JsonObject();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JsonObject();
|
||||
}
|
||||
}
|
||||
|
||||
private static string LevelName(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => "Trace",
|
||||
LogLevel.Debug => "Debug",
|
||||
LogLevel.Information => "Information",
|
||||
LogLevel.Warning => "Warning",
|
||||
LogLevel.Error => "Error",
|
||||
LogLevel.Critical => "Critical",
|
||||
_ => "Information"
|
||||
};
|
||||
|
||||
private static LogLevel? ParseLevel(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
return value.Trim() switch
|
||||
{
|
||||
"Trace" => LogLevel.Trace,
|
||||
"Debug" => LogLevel.Debug,
|
||||
"Information" or "Info" => LogLevel.Information,
|
||||
"Warning" or "Warn" => LogLevel.Warning,
|
||||
"Error" => LogLevel.Error,
|
||||
"Critical" => LogLevel.Critical,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
public record LogLevelRequest(string? LogLevel);
|
||||
public record LogLevelResponse(string LogLevel);
|
||||
public record FiltersRequest(List<string>? ExcludedCategoryPrefixes);
|
||||
public record FiltersResponse(List<string> ExcludedCategoryPrefixes);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
|
||||
@@ -8,10 +10,12 @@ namespace DnsmasqWebUI.Controllers;
|
||||
public class ReloadController : ControllerBase
|
||||
{
|
||||
private readonly IReloadService _reloadService;
|
||||
private readonly ILogger<ReloadController> _logger;
|
||||
|
||||
public ReloadController(IReloadService reloadService)
|
||||
public ReloadController(IReloadService reloadService, ILogger<ReloadController> logger)
|
||||
{
|
||||
_reloadService = reloadService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@@ -20,10 +24,12 @@ public class ReloadController : ControllerBase
|
||||
try
|
||||
{
|
||||
var result = await _reloadService.ReloadAsync(ct);
|
||||
_logger.LogInformation(LogEvents.ReloadRequestSuccess, "Reload requested, success={Success}", result.Success);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.ReloadRequestFailed, ex, "Reload request failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Controllers;
|
||||
@@ -15,15 +17,18 @@ public class StatusController : ControllerBase
|
||||
private readonly DnsmasqOptions _options;
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly IProcessRunner _processRunner;
|
||||
private readonly ILogger<StatusController> _logger;
|
||||
|
||||
public StatusController(
|
||||
IOptions<DnsmasqOptions> options,
|
||||
IDnsmasqConfigSetService configSetService,
|
||||
IProcessRunner processRunner)
|
||||
IProcessRunner processRunner,
|
||||
ILogger<StatusController> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_configSetService = configSetService;
|
||||
_processRunner = processRunner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -31,6 +36,7 @@ public class StatusController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get status");
|
||||
var set = await _configSetService.GetConfigSetAsync(ct);
|
||||
var effectiveLeasesPath = _configSetService.GetLeasesPath();
|
||||
var (effectiveConfig, effectiveConfigSources) = _configSetService.GetEffectiveConfigWithSources();
|
||||
@@ -98,6 +104,7 @@ public class StatusController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.StatusGetFailed, ex, "Get status failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
@@ -140,6 +147,7 @@ public class StatusController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("Get status logs download");
|
||||
var fullPath = Path.IsPathRooted(logsPath) ? Path.GetFullPath(logsPath) : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), logsPath));
|
||||
if (!System.IO.File.Exists(fullPath))
|
||||
return NotFound();
|
||||
@@ -154,6 +162,7 @@ public class StatusController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(LogEvents.StatusLogsDownloadFailed, ex, "Status logs download failed");
|
||||
return StatusCode(500, new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Avoid pre-compressed .br/.gz so MapStaticAssets serves uncompressed files (fixes 0-byte/empty response bug). -->
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.2" />
|
||||
<PackageReference Include="Superpower" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Threading.Channels;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// App logs setup: buffer, channel, and provider.
|
||||
/// Encapsulates the wiring that must run before the DI container exists (ILoggerProvider is created during host configuration).
|
||||
/// Log level and excluded category prefixes are configured via appsettings.Overrides.json (config source with reloadOnChange).
|
||||
/// </summary>
|
||||
public static class LoggingBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds the app logs provider (forwards ILogger output to SignalR) and registers buffer and channel.
|
||||
/// Call before <c>builder.Build()</c>.
|
||||
/// </summary>
|
||||
public static ILoggingBuilder AddAppLogs(this ILoggingBuilder logging, IConfiguration configuration)
|
||||
{
|
||||
var services = logging.Services;
|
||||
|
||||
var buffer = new AppLogsBuffer();
|
||||
var pushChannel = Channel.CreateBounded<byte>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
|
||||
|
||||
services.AddSingleton(pushChannel);
|
||||
services.AddSingleton<IAppLogsBuffer>(buffer);
|
||||
services.AddSingleton<ILogsService, LogsService>();
|
||||
|
||||
logging.AddProvider(new AppLogsLoggerProvider(buffer, pushChannel, configuration));
|
||||
|
||||
return logging;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddHttpClient<IHostsClient, HostsClient>(DnsmasqApiClientName);
|
||||
services.AddHttpClient<IDhcpHostsClient, DhcpHostsClient>(DnsmasqApiClientName);
|
||||
services.AddHttpClient<ILeasesClient, LeasesClient>(DnsmasqApiClientName);
|
||||
services.AddHttpClient<ILoggingClient, LoggingClient>(DnsmasqApiClientName);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public static class WebApplicationExtensions
|
||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
||||
{
|
||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||||
KnownNetworks = { new Microsoft.AspNetCore.HttpOverrides.IPNetwork(IPAddress.Loopback, 8) },
|
||||
KnownIPNetworks = { new System.Net.IPNetwork(IPAddress.Loopback, 8) },
|
||||
});
|
||||
|
||||
return app;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace DnsmasqWebUI.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for log streaming. Clients receive DnsmasqLogsUpdate and AppLogsUpdate.
|
||||
/// Server push is done via IHubContext; this hub defines methods clients can invoke.
|
||||
/// </summary>
|
||||
public class LogsHub : Hub
|
||||
{
|
||||
private readonly ILogsService _logsService;
|
||||
|
||||
public LogsHub(ILogsService logsService)
|
||||
{
|
||||
_logsService = logsService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client requests dnsmasq logs. Server runs LogsCommand, diffs, and pushes updates.
|
||||
/// </summary>
|
||||
public async Task RequestDnsmasqLogs()
|
||||
{
|
||||
await _logsService.RunAndPushDnsmasqLogsAsync(Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client requests a snapshot of recent app logs (replace). Used on connect.
|
||||
/// </summary>
|
||||
public async Task RequestAppLogsSnapshot()
|
||||
{
|
||||
await _logsService.PushAppLogsSnapshotAsync(Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client polls for new app logs (append). Fallback for reconnect/missed pushes.
|
||||
/// </summary>
|
||||
public async Task RequestAppLogsUpdate()
|
||||
{
|
||||
await _logsService.PushAppLogsPendingAsync(Context.ConnectionAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
|
||||
/// <summary>Client for runtime logging API (log level and category filters).</summary>
|
||||
public interface ILoggingClient
|
||||
{
|
||||
Task<string> GetLevelAsync(CancellationToken ct = default);
|
||||
Task<string> SetLevelAsync(string logLevel, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<string>> GetFiltersAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<string>> SetFiltersAsync(IReadOnlyList<string> excludedCategoryPrefixes, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<string>> RestoreFilterDefaultsAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Net.Http.Json;
|
||||
using DnsmasqWebUI.Infrastructure.Client.Abstractions;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Client;
|
||||
|
||||
public sealed class LoggingClient : ILoggingClient
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
|
||||
public LoggingClient(HttpClient http) => _http = http;
|
||||
|
||||
public async Task<string> GetLevelAsync(CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.GetFromJsonAsync<LogLevelResponse>("api/logging/level", ApiJsonOptions.ClientOptions, ct);
|
||||
return response?.LogLevel ?? "Information";
|
||||
}
|
||||
|
||||
public async Task<string> SetLevelAsync(string logLevel, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/logging/level", new { logLevel }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<LogLevelResponse>(ApiJsonOptions.ClientOptions, ct);
|
||||
return body?.LogLevel ?? "Information";
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetFiltersAsync(CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.GetFromJsonAsync<FiltersResponse>("api/logging/filters", ApiJsonOptions.ClientOptions, ct);
|
||||
return response?.ExcludedCategoryPrefixes ?? [];
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> SetFiltersAsync(IReadOnlyList<string> excludedCategoryPrefixes, CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PostAsJsonAsync("api/logging/filters", new { excludedCategoryPrefixes }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<FiltersResponse>(ApiJsonOptions.ClientOptions, ct);
|
||||
return body?.ExcludedCategoryPrefixes ?? [];
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> RestoreFilterDefaultsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var response = await _http.PostAsync("api/logging/filters/restore-defaults", null, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<FiltersResponse>(ApiJsonOptions.ClientOptions, ct);
|
||||
return body?.ExcludedCategoryPrefixes ?? [];
|
||||
}
|
||||
|
||||
private record LogLevelResponse(string LogLevel);
|
||||
private record FiltersResponse(List<string> ExcludedCategoryPrefixes);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for resolving app logs config. Uses ExcludedCategoryPrefixesOverrides when present (avoids .NET config array merge);
|
||||
/// otherwise falls back to ExcludedCategoryPrefixes from appsettings.json.
|
||||
/// Uses a sentinel value when user explicitly clears all filters, since .NET config returns null for empty arrays.
|
||||
/// </summary>
|
||||
public static class AppLogsConfigHelper
|
||||
{
|
||||
private const string OverridesKey = "AppLogs:ExcludedCategoryPrefixesOverrides";
|
||||
private const string DefaultsKey = "AppLogs:ExcludedCategoryPrefixes";
|
||||
|
||||
/// <summary>Sentinel value stored when user clears all filters. Filtered out from results; extremely unlikely to match real categories.</summary>
|
||||
public const string ExplicitlyEmptySentinel = "__AppLogsExplicitlyEmpty__";
|
||||
|
||||
/// <summary>Gets effective excluded category prefixes from configuration.</summary>
|
||||
public static List<string> GetEffectiveExcludedPrefixes(IConfiguration configuration)
|
||||
{
|
||||
var overrides = configuration.GetSection(OverridesKey).Get<List<string>>();
|
||||
if (overrides != null)
|
||||
return overrides.Where(s => s != ExplicitlyEmptySentinel).ToList();
|
||||
return GetDefaultExcludedPrefixes(configuration);
|
||||
}
|
||||
|
||||
/// <summary>Gets default excluded category prefixes from appsettings.json (ignores overrides).</summary>
|
||||
public static List<string> GetDefaultExcludedPrefixes(IConfiguration configuration) =>
|
||||
configuration.GetSection(DefaultsKey).Get<List<string>>() ?? [];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Threading.Channels;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// ILoggerProvider that forwards log entries to IAppLogsBuffer for real-time display.
|
||||
/// Signals the push channel when logs are written so the hosted service can push to clients.
|
||||
/// Filter is evaluated at Log() time (not CreateLogger) so runtime config changes take effect despite LoggerFactory caching.
|
||||
/// </summary>
|
||||
public sealed class AppLogsLoggerProvider : ILoggerProvider
|
||||
{
|
||||
private readonly IAppLogsBuffer _buffer;
|
||||
private readonly Channel<byte> _pushChannel;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public AppLogsLoggerProvider(
|
||||
IAppLogsBuffer buffer,
|
||||
Channel<byte> pushChannel,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_buffer = buffer;
|
||||
_pushChannel = pushChannel;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public ILogger CreateLogger(string categoryName) =>
|
||||
new FilteringAppLogsLogger(categoryName, _buffer, _pushChannel, _configuration);
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
private sealed class FilteringAppLogsLogger : ILogger
|
||||
{
|
||||
private readonly string _category;
|
||||
private readonly IAppLogsBuffer _buffer;
|
||||
private readonly Channel<byte> _pushChannel;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public FilteringAppLogsLogger(string category, IAppLogsBuffer buffer, Channel<byte> pushChannel, IConfiguration configuration)
|
||||
{
|
||||
_category = category;
|
||||
_buffer = buffer;
|
||||
_pushChannel = pushChannel;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (IsExcluded(_category))
|
||||
return;
|
||||
|
||||
var msg = formatter(state, exception);
|
||||
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var level = logLevel switch
|
||||
{
|
||||
LogLevel.Trace => "TRC",
|
||||
LogLevel.Debug => "DBG",
|
||||
LogLevel.Information => "INF",
|
||||
LogLevel.Warning => "WRN",
|
||||
LogLevel.Error => "ERR",
|
||||
LogLevel.Critical => "CRT",
|
||||
_ => "???"
|
||||
};
|
||||
var line = $"{timestamp} [{level}] {_category}: {msg}";
|
||||
if (exception != null)
|
||||
line += $"{Environment.NewLine}{exception}";
|
||||
_buffer.Enqueue(line);
|
||||
_pushChannel.Writer.TryWrite(0);
|
||||
}
|
||||
|
||||
private bool IsExcluded(string category)
|
||||
{
|
||||
var prefixes = AppLogsConfigHelper.GetEffectiveExcludedPrefixes(_configuration);
|
||||
if (prefixes.Count == 0) return false;
|
||||
foreach (var prefix in prefixes)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(prefix) && category.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Logging;
|
||||
|
||||
/// <summary>Structured event IDs for application logging. Enables filtering and alerting by event type.</summary>
|
||||
internal static class LogEvents
|
||||
{
|
||||
// Config (1xxx)
|
||||
public static readonly EventId ConfigGetSetFailed = new(1001, nameof(ConfigGetSetFailed));
|
||||
public static readonly EventId ConfigGetManagedFailed = new(1002, nameof(ConfigGetManagedFailed));
|
||||
public static readonly EventId ConfigPutManagedFailed = new(1003, nameof(ConfigPutManagedFailed));
|
||||
public static readonly EventId ConfigPutManagedSuccess = new(1004, nameof(ConfigPutManagedSuccess));
|
||||
public static readonly EventId ConfigNoManagedFilePath = new(1005, nameof(ConfigNoManagedFilePath));
|
||||
public static readonly EventId ConfigWroteManagedFile = new(1006, nameof(ConfigWroteManagedFile));
|
||||
|
||||
// Hosts (2xxx)
|
||||
public static readonly EventId HostsGetFailed = new(2001, nameof(HostsGetFailed));
|
||||
public static readonly EventId HostsGetReadOnlyFailed = new(2002, nameof(HostsGetReadOnlyFailed));
|
||||
public static readonly EventId HostsPutFailed = new(2003, nameof(HostsPutFailed));
|
||||
public static readonly EventId HostsPutValidationFailed = new(2004, nameof(HostsPutValidationFailed));
|
||||
public static readonly EventId HostsPutSuccess = new(2005, nameof(HostsPutSuccess));
|
||||
public static readonly EventId HostsWroteManagedFile = new(2006, nameof(HostsWroteManagedFile));
|
||||
|
||||
// Dhcp (3xxx)
|
||||
public static readonly EventId DhcpGetHostsFailed = new(3001, nameof(DhcpGetHostsFailed));
|
||||
public static readonly EventId DhcpPutHostsFailed = new(3002, nameof(DhcpPutHostsFailed));
|
||||
public static readonly EventId DhcpPutHostsValidationFailed = new(3003, nameof(DhcpPutHostsValidationFailed));
|
||||
public static readonly EventId DhcpPutHostsSuccess = new(3004, nameof(DhcpPutHostsSuccess));
|
||||
|
||||
// Reload (4xxx)
|
||||
public static readonly EventId ReloadRequestFailed = new(4001, nameof(ReloadRequestFailed));
|
||||
public static readonly EventId ReloadRequestSuccess = new(4002, nameof(ReloadRequestSuccess));
|
||||
public static readonly EventId ReloadCommandNotConfigured = new(4003, nameof(ReloadCommandNotConfigured));
|
||||
public static readonly EventId ReloadRejectedConcurrent = new(4004, nameof(ReloadRejectedConcurrent));
|
||||
public static readonly EventId ReloadNonZeroExit = new(4005, nameof(ReloadNonZeroExit));
|
||||
public static readonly EventId ReloadSucceeded = new(4006, nameof(ReloadSucceeded));
|
||||
public static readonly EventId ReloadFailed = new(4007, nameof(ReloadFailed));
|
||||
|
||||
// Status (5xxx)
|
||||
public static readonly EventId StatusGetFailed = new(5001, nameof(StatusGetFailed));
|
||||
public static readonly EventId StatusLogsDownloadFailed = new(5002, nameof(StatusLogsDownloadFailed));
|
||||
|
||||
// Leases (6xxx)
|
||||
public static readonly EventId LeasesGetFailed = new(6001, nameof(LeasesGetFailed));
|
||||
|
||||
// Logging (7xxx)
|
||||
public static readonly EventId LogLevelChanged = new(7001, nameof(LogLevelChanged));
|
||||
public static readonly EventId LogLevelSetBadRequest = new(7002, nameof(LogLevelSetBadRequest));
|
||||
public static readonly EventId LogLevelSetFailed = new(7003, nameof(LogLevelSetFailed));
|
||||
public static readonly EventId FiltersChanged = new(7004, nameof(FiltersChanged));
|
||||
public static readonly EventId FiltersSetFailed = new(7005, nameof(FiltersSetFailed));
|
||||
|
||||
// LogsService (8010)
|
||||
public static readonly EventId DnsmasqLogsPushFailed = new(8010, nameof(DnsmasqLogsPushFailed));
|
||||
|
||||
// ProcessRunner (8xxx)
|
||||
public static readonly EventId CommandFailed = new(8001, nameof(CommandFailed));
|
||||
public static readonly EventId CommandTimeout = new(8002, nameof(CommandTimeout));
|
||||
public static readonly EventId CommandStarted = new(8003, nameof(CommandStarted));
|
||||
public static readonly EventId CommandCompleted = new(8004, nameof(CommandCompleted));
|
||||
|
||||
// LeasesFileService (9xxx)
|
||||
public static readonly EventId LeasesReadSuccess = new(9001, nameof(LeasesReadSuccess));
|
||||
|
||||
// Application lifecycle (9xxx)
|
||||
public static readonly EventId ApplicationStarted = new(9101, nameof(ApplicationStarted));
|
||||
public static readonly EventId ApplicationStopping = new(9102, nameof(ApplicationStopping));
|
||||
public static readonly EventId ManagedConfigConfFileLineSet = new(9110, nameof(ManagedConfigConfFileLineSet));
|
||||
public static readonly EventId ManagedConfigCreatedAtStartup = new(9111, nameof(ManagedConfigCreatedAtStartup));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Ring buffer of recent app log lines. Filled by ILoggerProvider, drained for SignalR push.
|
||||
/// </summary>
|
||||
public interface IAppLogsBuffer
|
||||
{
|
||||
/// <summary>Returns up to maxLines most recent lines (oldest first).</summary>
|
||||
IReadOnlyList<string> GetRecent(int maxLines = 500);
|
||||
|
||||
/// <summary>Enqueues a formatted log line. Called by the logging provider.</summary>
|
||||
void Enqueue(string line);
|
||||
|
||||
/// <summary>Returns and clears pending lines for streaming push. Called by pusher.</summary>
|
||||
IReadOnlyList<string> DrainPending();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Runs LogsCommand, diffs output, chunks by lines, and pushes via SignalR.
|
||||
/// Triggered by client (RequestDnsmasqLogs) or file watcher when LogsPath changes.
|
||||
/// App logs: event-driven push when provider writes; snapshot on connect; poll fallback.
|
||||
/// </summary>
|
||||
public interface ILogsService
|
||||
{
|
||||
/// <summary>
|
||||
/// Runs LogsCommand, diffs against cache, and pushes DnsmasqLogsUpdate to all connected clients.
|
||||
/// </summary>
|
||||
Task RunAndPushDnsmasqLogsAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes recent app logs from buffer to all clients (replace). Called when client connects.
|
||||
/// </summary>
|
||||
Task PushAppLogsSnapshotAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Pushes pending app log lines as append to all clients. Event-driven from provider; poll fallback.
|
||||
/// </summary>
|
||||
Task PushAppLogsPendingAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -7,4 +7,7 @@ public interface IProcessRunner : IApplicationScopedService
|
||||
{
|
||||
/// <summary>Runs a shell command with timeout and cancellation. Returns exit code (null if timed out or failed to start), stdout, stderr, and whether the run timed out.</summary>
|
||||
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Same as RunAsync but caps stdout/stderr length. When exceeded, truncates and appends a notice.</summary>
|
||||
Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped service to open the app-level settings modal from anywhere (e.g. NavMenu, pages).
|
||||
/// MainLayout subscribes to <see cref="OpenRequested"/> and hosts the modal.
|
||||
/// Subscribe to <see cref="SettingsChanged"/> to refresh when settings are saved/closed.
|
||||
/// </summary>
|
||||
public interface ISettingsModalService
|
||||
{
|
||||
/// <summary>Raised when <see cref="Open"/> is called. MainLayout subscribes and shows the modal.</summary>
|
||||
event Action<SettingsModalContext, string>? OpenRequested;
|
||||
|
||||
/// <summary>Raised when the modal is closed (save or cancel). Pages subscribe to reload their intervals.</summary>
|
||||
event Action? SettingsChanged;
|
||||
|
||||
/// <summary>Opens the settings modal with the given context and title.</summary>
|
||||
void Open(SettingsModalContext context, string title);
|
||||
|
||||
/// <summary>Called when the modal closes. Raises <see cref="SettingsChanged"/>.</summary>
|
||||
void NotifySettingsChanged();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <inheritdoc cref="Abstractions.IAppLogsBuffer" />
|
||||
public sealed class AppLogsBuffer : Abstractions.IAppLogsBuffer
|
||||
{
|
||||
private const int MaxLines = 1000;
|
||||
private readonly List<string> _lines = [];
|
||||
private readonly List<string> _pending = [];
|
||||
private readonly object _lock = new();
|
||||
|
||||
public IReadOnlyList<string> GetRecent(int maxLines = 500)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lines.Count <= maxLines)
|
||||
return _lines.ToList();
|
||||
return _lines.TakeLast(maxLines).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(string line)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_lines.Add(line);
|
||||
_pending.Add(line);
|
||||
while (_lines.Count > MaxLines)
|
||||
_lines.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> DrainPending()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_pending.Count == 0)
|
||||
return [];
|
||||
var copy = _pending.ToList();
|
||||
_pending.Clear();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Threading.Channels;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Listens for signals from AppLogsLoggerProvider; drains pending app logs and pushes via LogsService.
|
||||
/// Event-driven: push happens when logs are written, with a short debounce to batch rapid writes.
|
||||
/// </summary>
|
||||
public sealed class AppLogsPushTriggeredHostedService : IApplicationHostedService
|
||||
{
|
||||
private const int DebounceMs = 50;
|
||||
|
||||
private readonly Channel<byte> _channel;
|
||||
private readonly ILogsService _logsService;
|
||||
private readonly ILogger<AppLogsPushTriggeredHostedService> _logger;
|
||||
|
||||
public AppLogsPushTriggeredHostedService(
|
||||
Channel<byte> appLogsPushChannel,
|
||||
ILogsService logsService,
|
||||
ILogger<AppLogsPushTriggeredHostedService> logger)
|
||||
{
|
||||
_channel = appLogsPushChannel;
|
||||
_logsService = logsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_ = RunLoopAsync(ct);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var _ in _channel.Reader.ReadAllAsync(ct))
|
||||
{
|
||||
await Task.Delay(DebounceMs, ct);
|
||||
while (_channel.Reader.TryRead(out byte discarded)) { } // Drain extra signals during debounce
|
||||
try
|
||||
{
|
||||
await _logsService.PushAppLogsPendingAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error pushing app logs");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutdown; ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <summary>Logs application started and stopping so the app logs viewer shows lifecycle events.</summary>
|
||||
public sealed class ApplicationLifecycleLoggingHostedService : IApplicationHostedService
|
||||
{
|
||||
private readonly ILogger<ApplicationLifecycleLoggingHostedService> _logger;
|
||||
|
||||
public ApplicationLifecycleLoggingHostedService(ILogger<ApplicationLifecycleLoggingHostedService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(LogEvents.ApplicationStarted, "Application started");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(LogEvents.ApplicationStopping, "Application stopping");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
public sealed class ClientSettingsService : IClientSettingsService, IAsyncDisposable
|
||||
{
|
||||
private readonly IJSRuntime _jsRuntime;
|
||||
private readonly ILogger<ClientSettingsService> _logger;
|
||||
private IJSObjectReference? _module;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@@ -20,13 +21,23 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public ClientSettingsService(IJSRuntime jsRuntime) => _jsRuntime = jsRuntime;
|
||||
public ClientSettingsService(IJSRuntime jsRuntime, ILogger<ClientSettingsService> logger)
|
||||
{
|
||||
_jsRuntime = jsRuntime;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<IJSObjectReference> GetModuleAsync()
|
||||
private async Task<IJSObjectReference?> GetModuleAsync()
|
||||
{
|
||||
if (_module != null) return _module;
|
||||
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/client-settings-storage.js");
|
||||
try
|
||||
{
|
||||
_module = await _jsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/client-settings-storage.js");
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -34,6 +45,7 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
public async Task<ClientSettings> LoadSettingsAsync()
|
||||
{
|
||||
var module = await GetModuleAsync();
|
||||
if (module == null) return new ClientSettings();
|
||||
var json = await module.InvokeAsync<string?>("getItem");
|
||||
if (string.IsNullOrWhiteSpace(json)) return new ClientSettings();
|
||||
try
|
||||
@@ -49,8 +61,9 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
/// <inheritdoc />
|
||||
public async Task SaveSettingsAsync(ClientSettings settings)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings, JsonOptions);
|
||||
var module = await GetModuleAsync();
|
||||
if (module == null) return;
|
||||
var json = JsonSerializer.Serialize(settings, JsonOptions);
|
||||
await module.InvokeVoidAsync("setItem", json);
|
||||
}
|
||||
|
||||
@@ -60,13 +73,10 @@ public sealed class ClientSettingsService : IClientSettingsService, IAsyncDispos
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_module == null) return;
|
||||
try
|
||||
{
|
||||
await _module.DisposeAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_module = null;
|
||||
}
|
||||
try { await _module.DisposeAsync(); }
|
||||
catch (InvalidOperationException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped (prerender)"); }
|
||||
catch (JSDisconnectedException ex) { _logger.LogDebug(ex, "JS module DisposeAsync skipped: circuit disconnected"); }
|
||||
catch (JSException ex) { _logger.LogDebug(ex, "JS module DisposeAsync failed"); }
|
||||
finally { _module = null; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
@@ -34,7 +36,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
var snapshot = await _configSetCache.GetSnapshotAsync(ct);
|
||||
if (string.IsNullOrEmpty(snapshot.Set.ManagedFilePath))
|
||||
{
|
||||
_logger.LogDebug("No managed file path (no conf-dir in main config); returning empty dhcp hosts");
|
||||
_logger.LogDebug(LogEvents.ConfigNoManagedFilePath, "No managed file path (no conf-dir in main config); returning empty dhcp hosts");
|
||||
return Array.Empty<DhcpHostEntry>();
|
||||
}
|
||||
var allEntries = snapshot.DhcpHostEntries.ToList();
|
||||
@@ -149,7 +151,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
|
||||
var effectiveHostsPathDhcp = configLines.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
|
||||
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(configLines, effectiveHostsPathDhcp));
|
||||
_logger.LogInformation("Wrote managed config file: {Path}", path);
|
||||
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
|
||||
}
|
||||
|
||||
/// <summary>Returns MAC -> source file path for all dhcp-host MACs in non-managed config files (for duplicate validation).</summary>
|
||||
@@ -204,7 +206,7 @@ public class DnsmasqConfigService : IDnsmasqConfigService
|
||||
EnsureManagedHostsFileExists(set.ManagedHostsFilePath);
|
||||
var effectiveHostsPath = list.OfType<AddnHostsLine>().FirstOrDefault()?.AddnHostsPath ?? "";
|
||||
_configSetCache.NotifyWeWroteManagedConfig(new ManagedConfigContent(list, effectiveHostsPath));
|
||||
_logger.LogInformation("Wrote managed config file: {Path}", path);
|
||||
_logger.LogInformation(LogEvents.ConfigWroteManagedFile, "Wrote managed config file: {Path}", path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Dnsmasq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
@@ -79,7 +81,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
|
||||
lines.Add("");
|
||||
lines.Add(confFileLine);
|
||||
await File.WriteAllLinesAsync(mainFull, lines, DnsmasqFileEncoding.Utf8NoBom, cancellationToken);
|
||||
_logger.LogInformation("Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
|
||||
_logger.LogInformation(LogEvents.ManagedConfigConfFileLineSet, "Set {Line} as the last line of main config {Path} so the managed file is included only by conf-file=.", confFileLine, mainFull);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -91,7 +93,7 @@ public class EnsureManagedConfigHostedService : IApplicationHostedService
|
||||
if (File.Exists(set.ManagedFilePath))
|
||||
return;
|
||||
|
||||
_logger.LogInformation("Creating managed config file at startup: {Path}", set.ManagedFilePath);
|
||||
_logger.LogInformation(LogEvents.ManagedConfigCreatedAtStartup, "Creating managed config file at startup: {Path}", set.ManagedFilePath);
|
||||
await configService.WriteManagedConfigAsync(Array.Empty<DnsmasqConfLine>(), cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Config;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Parsers;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Hosts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
@@ -39,6 +41,6 @@ public class HostsFileService : IHostsFileService
|
||||
await File.WriteAllLinesAsync(tmpPath, lines, DnsmasqFileEncoding.Utf8NoBom, ct);
|
||||
File.Move(tmpPath, path, overwrite: true);
|
||||
_hostsCache.NotifyWeWroteManagedHosts(entries);
|
||||
_logger.LogInformation("Wrote managed hosts file: {Path}", path);
|
||||
_logger.LogInformation(LogEvents.HostsWroteManagedFile, "Wrote managed hosts file: {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Dhcp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
public class LeasesFileService : ILeasesFileService
|
||||
{
|
||||
private readonly ILeasesCache _cache;
|
||||
private readonly ILogger<LeasesFileService> _logger;
|
||||
|
||||
public LeasesFileService(ILeasesCache cache)
|
||||
public LeasesFileService(ILeasesCache cache, ILogger<LeasesFileService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<LeaseEntry>> ReadAsync(CancellationToken ct = default)
|
||||
@@ -19,5 +23,10 @@ public class LeasesFileService : ILeasesFileService
|
||||
}
|
||||
|
||||
public async Task<(bool Available, IReadOnlyList<LeaseEntry>? Entries)> TryReadAsync(CancellationToken ct = default)
|
||||
=> await _cache.GetOrRefreshAsync(ct);
|
||||
{
|
||||
var (available, entries) = await _cache.GetOrRefreshAsync(ct);
|
||||
if (available && entries != null)
|
||||
_logger.LogInformation(LogEvents.LeasesReadSuccess, "Leases read, count={Count}", entries.Count);
|
||||
return (available, entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using DnsmasqWebUI.Models.Dnsmasq.EffectiveConfig;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// When LogsPath is set in effective config, watches that file and triggers LogsService push on change.
|
||||
/// Polling remains the primary mechanism; this is a best-effort optimization.
|
||||
/// </summary>
|
||||
public sealed class LogsFileWatcherHostedService : IApplicationHostedService
|
||||
{
|
||||
private const int ResolveIntervalSeconds = 60;
|
||||
|
||||
private readonly IDnsmasqConfigSetService _configSetService;
|
||||
private readonly ILogsService _logsService;
|
||||
private readonly ILogger<LogsFileWatcherHostedService> _logger;
|
||||
private FileSystemWatcher? _watcher;
|
||||
private string? _currentPath;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public LogsFileWatcherHostedService(
|
||||
IDnsmasqConfigSetService configSetService,
|
||||
ILogsService logsService,
|
||||
ILogger<LogsFileWatcherHostedService> logger)
|
||||
{
|
||||
_configSetService = configSetService;
|
||||
_logsService = logsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken ct)
|
||||
{
|
||||
_ = RunLoopAsync(ct);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (effectiveConfig, _) = _configSetService.GetEffectiveConfigWithSources();
|
||||
var logsPath = EffectiveDnsmasqConfig.GetLogsPath(effectiveConfig);
|
||||
UpdateWatcher(logsPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error resolving LogsPath for file watcher");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(ResolveIntervalSeconds), ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
_watcher = null;
|
||||
_currentPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateWatcher(string? logsPath)
|
||||
{
|
||||
var normalized = string.IsNullOrEmpty(logsPath) ? null : Path.GetFullPath(logsPath.Trim());
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (string.Equals(_currentPath, normalized, StringComparison.Ordinal))
|
||||
return;
|
||||
|
||||
_watcher?.Dispose();
|
||||
_watcher = null;
|
||||
_currentPath = normalized;
|
||||
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
{
|
||||
_logger.LogDebug("Logs path not set; no file watcher");
|
||||
return;
|
||||
}
|
||||
|
||||
var dir = Path.GetDirectoryName(normalized);
|
||||
var fileName = Path.GetFileName(normalized);
|
||||
if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
_logger.LogDebug("Invalid logs path for watcher: {Path}", normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
_logger.LogDebug("Logs directory does not exist yet: {Dir}", dir);
|
||||
return;
|
||||
}
|
||||
|
||||
_watcher = new FileSystemWatcher(dir)
|
||||
{
|
||||
Filter = fileName,
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size
|
||||
};
|
||||
_watcher.Changed += OnFileChanged;
|
||||
_watcher.EnableRaisingEvents = true;
|
||||
_logger.LogDebug("Watching logs file: {Path}", normalized);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not create file watcher for logs: {Path}", normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFileChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
_ = InvokePushAsync();
|
||||
}
|
||||
|
||||
private async Task InvokePushAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _logsService.RunAndPushDnsmasqLogsAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error pushing logs on file change");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Models.Logs;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Runs LogsCommand, diffs against cache, chunks by lines, and pushes via SignalR.
|
||||
/// </summary>
|
||||
public sealed class LogsService : ILogsService
|
||||
{
|
||||
/// <summary>Max chars from command output before truncation.</summary>
|
||||
private const int MaxCommandOutputChars = 512 * 1024;
|
||||
|
||||
/// <summary>Max bytes per SignalR message (under 32KB default).</summary>
|
||||
private const int MaxMessageContentBytes = 28 * 1024;
|
||||
|
||||
private readonly IHubContext<Hubs.LogsHub> _hubContext;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IOptions<DnsmasqOptions> _options;
|
||||
private readonly IAppLogsBuffer _appLogsBuffer;
|
||||
private readonly ILogger<LogsService> _logger;
|
||||
private readonly object _dnsmasqLock = new();
|
||||
private string _lastDnsmasqOutput = "";
|
||||
|
||||
public LogsService(
|
||||
IHubContext<Hubs.LogsHub> hubContext,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<DnsmasqOptions> options,
|
||||
IAppLogsBuffer appLogsBuffer,
|
||||
ILogger<LogsService> logger)
|
||||
{
|
||||
_hubContext = hubContext;
|
||||
_scopeFactory = scopeFactory;
|
||||
_options = options;
|
||||
_appLogsBuffer = appLogsBuffer;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunAndPushDnsmasqLogsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var cmd = _options.Value.LogsCommand?.Trim();
|
||||
if (string.IsNullOrEmpty(cmd))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var processRunner = scope.ServiceProvider.GetRequiredService<IProcessRunner>();
|
||||
var result = await processRunner.RunAsync(cmd, TimeSpan.FromSeconds(10), MaxCommandOutputChars, ct);
|
||||
var raw = result.Stdout + (result.TimedOut ? "\n(Command timed out.)" : "");
|
||||
|
||||
string mode;
|
||||
string content;
|
||||
lock (_dnsmasqLock)
|
||||
{
|
||||
var (m, c) = ComputeDelta(_lastDnsmasqOutput, raw);
|
||||
_lastDnsmasqOutput = raw;
|
||||
mode = m;
|
||||
content = c;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(content) && mode == "append")
|
||||
return;
|
||||
|
||||
await PushChunkedAsync("DnsmasqLogsUpdate", mode, content, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(LogEvents.DnsmasqLogsPushFailed, ex, "Failed to run or push dnsmasq logs");
|
||||
await PushChunkedAsync("DnsmasqLogsUpdate", "replace", $"(Error: {ex.Message})\n", ct);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PushAppLogsSnapshotAsync(CancellationToken ct = default)
|
||||
{
|
||||
var lines = _appLogsBuffer.GetRecent(maxLines: 1000);
|
||||
if (lines.Count == 0)
|
||||
return;
|
||||
|
||||
var content = string.Join("\n", lines);
|
||||
if (!string.IsNullOrEmpty(content) && !content.EndsWith('\n'))
|
||||
content += "\n";
|
||||
|
||||
await PushChunkedAsync("AppLogsUpdate", "replace", content, ct);
|
||||
}
|
||||
|
||||
public async Task PushAppLogsPendingAsync(CancellationToken ct = default)
|
||||
{
|
||||
var lines = _appLogsBuffer.DrainPending();
|
||||
if (lines.Count == 0)
|
||||
return;
|
||||
|
||||
var content = string.Join("\n", lines);
|
||||
if (!string.IsNullOrEmpty(content) && !content.EndsWith('\n'))
|
||||
content += "\n";
|
||||
|
||||
await PushChunkedAsync("AppLogsUpdate", "append", content, ct);
|
||||
}
|
||||
|
||||
private static (string mode, string content) ComputeDelta(string oldOutput, string newOutput)
|
||||
{
|
||||
if (string.IsNullOrEmpty(oldOutput))
|
||||
return ("replace", newOutput);
|
||||
|
||||
var oldLines = oldOutput.Split('\n');
|
||||
var newLines = newOutput.Split('\n');
|
||||
|
||||
// Find longest suffix of old that matches prefix of new (tail-style overlap)
|
||||
var maxOverlap = Math.Min(oldLines.Length, newLines.Length);
|
||||
var overlap = 0;
|
||||
for (var k = 1; k <= maxOverlap; k++)
|
||||
{
|
||||
var match = true;
|
||||
for (var i = 0; i < k && match; i++)
|
||||
{
|
||||
if (oldLines[oldLines.Length - k + i] != newLines[i])
|
||||
match = false;
|
||||
}
|
||||
if (match)
|
||||
overlap = k;
|
||||
}
|
||||
|
||||
if (overlap == 0 || overlap >= newLines.Length)
|
||||
return ("replace", newOutput);
|
||||
|
||||
var newOnly = newLines[overlap..];
|
||||
var content = string.Join("\n", newOnly);
|
||||
if (!string.IsNullOrEmpty(content) && !content.EndsWith('\n'))
|
||||
content += "\n";
|
||||
|
||||
return ("append", content);
|
||||
}
|
||||
|
||||
private async Task PushChunkedAsync(string method, string mode, string content, CancellationToken ct)
|
||||
{
|
||||
var chunks = ChunkByLines(content, MaxMessageContentBytes);
|
||||
for (var i = 0; i < chunks.Count; i++)
|
||||
{
|
||||
var payload = new LogsUpdatePayload(i == 0 ? mode : "append", chunks[i]);
|
||||
await _hubContext.Clients.All.SendAsync(method, payload, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ChunkByLines(string content, int maxBytesPerChunk)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content))
|
||||
return [];
|
||||
|
||||
var lines = content.Split('\n');
|
||||
var chunks = new List<string>();
|
||||
var current = new StringBuilder();
|
||||
var enc = Encoding.UTF8;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i];
|
||||
var lineWithNewline = i < lines.Length - 1 ? line + "\n" : (line.Length > 0 ? line + "\n" : line);
|
||||
var lineBytes = enc.GetByteCount(lineWithNewline);
|
||||
|
||||
if (lineBytes > maxBytesPerChunk)
|
||||
{
|
||||
if (current.Length > 0)
|
||||
{
|
||||
chunks.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
var trunc = line.Length;
|
||||
while (trunc > 0 && enc.GetByteCount(line[..trunc] + "... (line truncated)\n") > maxBytesPerChunk)
|
||||
trunc--;
|
||||
chunks.Add((trunc > 0 ? line[..trunc] : line) + "... (line truncated)\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current.Length > 0 && enc.GetByteCount(current.ToString()) + lineBytes > maxBytesPerChunk)
|
||||
{
|
||||
chunks.Add(current.ToString());
|
||||
current.Clear();
|
||||
}
|
||||
|
||||
if (i < lines.Length - 1)
|
||||
current.Append(line).Append('\n');
|
||||
else if (line.Length > 0)
|
||||
current.Append(line).Append('\n');
|
||||
}
|
||||
|
||||
if (current.Length > 0)
|
||||
chunks.Add(current.ToString());
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Contracts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
@@ -9,15 +10,24 @@ namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
/// <summary>Runs shell commands via /bin/sh with async output capture and timeout. Used by StatusController and ReloadService.</summary>
|
||||
public sealed class ProcessRunner : IProcessRunner
|
||||
{
|
||||
private const int MaxCommandPrefixLength = 80;
|
||||
|
||||
private readonly ILogger<ProcessRunner> _logger;
|
||||
|
||||
public ProcessRunner(ILogger<ProcessRunner> logger) => _logger = logger;
|
||||
|
||||
public async Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default)
|
||||
public Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, CancellationToken ct = default) =>
|
||||
RunAsync(command, timeout, null, ct);
|
||||
|
||||
public async Task<ProcessRunResult> RunAsync(string? command, TimeSpan timeout, int? maxOutputChars, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return new ProcessRunResult(null, "", "", false);
|
||||
|
||||
var trimmed = command!.Trim();
|
||||
var prefix = trimmed.Length <= MaxCommandPrefixLength ? trimmed : trimmed[..MaxCommandPrefixLength] + "...";
|
||||
_logger.LogDebug(LogEvents.CommandStarted, "Running command (length={Length}, timeout={Timeout}s): {CommandPrefix}", trimmed.Length, timeout.TotalSeconds, prefix);
|
||||
|
||||
var stdout = new StringBuilder();
|
||||
var stderr = new StringBuilder();
|
||||
using var process = new Process
|
||||
@@ -33,8 +43,31 @@ public sealed class ProcessRunner : IProcessRunner
|
||||
}
|
||||
};
|
||||
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
|
||||
var truncMsg = "\n\n(output truncated)\n";
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data == null) return;
|
||||
if (maxOutputChars.HasValue && stdout.Length >= maxOutputChars.Value) return;
|
||||
stdout.AppendLine(e.Data);
|
||||
if (maxOutputChars.HasValue && stdout.Length > maxOutputChars.Value)
|
||||
{
|
||||
var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length);
|
||||
stdout.Length = keep;
|
||||
stdout.Append(truncMsg);
|
||||
}
|
||||
};
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data == null) return;
|
||||
if (maxOutputChars.HasValue && stderr.Length >= maxOutputChars.Value) return;
|
||||
stderr.AppendLine(e.Data);
|
||||
if (maxOutputChars.HasValue && stderr.Length > maxOutputChars.Value)
|
||||
{
|
||||
var keep = Math.Max(0, maxOutputChars.Value - truncMsg.Length);
|
||||
stderr.Length = keep;
|
||||
stderr.Append(truncMsg);
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
@@ -50,7 +83,7 @@ public sealed class ProcessRunner : IProcessRunner
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Command timed out after {Timeout}s", timeout.TotalSeconds);
|
||||
_logger.LogWarning(LogEvents.CommandTimeout, "Command timed out after {Timeout}s", timeout.TotalSeconds);
|
||||
try { process.Kill(); } catch { /* best effort */ }
|
||||
var err = stderr.ToString();
|
||||
if (!string.IsNullOrEmpty(err)) err += "\n";
|
||||
@@ -58,15 +91,17 @@ public sealed class ProcessRunner : IProcessRunner
|
||||
return new ProcessRunResult(null, stdout.ToString(), err, true);
|
||||
}
|
||||
|
||||
var exitCode = process.HasExited ? process.ExitCode : -1;
|
||||
_logger.LogDebug(LogEvents.CommandCompleted, "Command completed, exit code={ExitCode}", exitCode);
|
||||
return new ProcessRunResult(
|
||||
process.HasExited ? process.ExitCode : -1,
|
||||
exitCode,
|
||||
stdout.ToString(),
|
||||
stderr.ToString(),
|
||||
false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to run command");
|
||||
_logger.LogError(LogEvents.CommandFailed, ex, "Failed to run command");
|
||||
return new ProcessRunResult(null, "", "", false, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Infrastructure.Logging;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
@@ -22,13 +24,13 @@ public class ReloadService : IReloadService
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_options.ReloadCommand))
|
||||
{
|
||||
_logger.LogDebug("Reload command not configured");
|
||||
_logger.LogDebug(LogEvents.ReloadCommandNotConfigured, "Reload command not configured");
|
||||
return new ReloadResult(true, 0, null, "Reload command not configured");
|
||||
}
|
||||
|
||||
if (!await _reloadLock.WaitAsync(TimeSpan.Zero, ct))
|
||||
{
|
||||
_logger.LogDebug("Reload already in progress, rejecting concurrent request");
|
||||
_logger.LogDebug(LogEvents.ReloadRejectedConcurrent, "Reload already in progress, rejecting concurrent request");
|
||||
return new ReloadResult(false, -1, null, "Reload already in progress.");
|
||||
}
|
||||
|
||||
@@ -42,9 +44,9 @@ public class ReloadService : IReloadService
|
||||
stderr = (string.IsNullOrEmpty(stderr) ? "" : stderr + "\n") + result.ExceptionMessage;
|
||||
|
||||
if (result.ExitCode != 0 && result.ExitCode.HasValue)
|
||||
_logger.LogWarning("Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
|
||||
_logger.LogWarning(LogEvents.ReloadNonZeroExit, "Reload command exited with {ExitCode}: {Stderr}", result.ExitCode.Value, stderr);
|
||||
else if (result.ExitCode == 0)
|
||||
_logger.LogInformation("Reload command succeeded");
|
||||
_logger.LogInformation(LogEvents.ReloadSucceeded, "Reload command succeeded");
|
||||
|
||||
return new ReloadResult(
|
||||
result.ExitCode == 0,
|
||||
@@ -54,7 +56,7 @@ public class ReloadService : IReloadService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to run reload command");
|
||||
_logger.LogError(LogEvents.ReloadFailed, ex, "Failed to run reload command");
|
||||
return new ReloadResult(false, -1, null, ex.Message);
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using DnsmasqWebUI.Models.Client;
|
||||
|
||||
namespace DnsmasqWebUI.Infrastructure.Services;
|
||||
|
||||
/// <inheritdoc cref="ISettingsModalService" />
|
||||
public sealed class SettingsModalService : ISettingsModalService
|
||||
{
|
||||
public event Action<SettingsModalContext, string>? OpenRequested;
|
||||
public event Action? SettingsChanged;
|
||||
|
||||
public void Open(SettingsModalContext context, string title) => OpenRequested?.Invoke(context, title);
|
||||
|
||||
public void NotifySettingsChanged() => SettingsChanged?.Invoke();
|
||||
}
|
||||
@@ -8,9 +8,24 @@ public class ClientSettings
|
||||
/// <summary>Polling interval for the service status block (seconds).</summary>
|
||||
public int ServiceStatusPollingIntervalSeconds { get; set; } = 15;
|
||||
|
||||
/// <summary>Polling interval for the recent logs block (seconds).</summary>
|
||||
/// <summary>Polling interval for the recent dnsmasq logs block (seconds).</summary>
|
||||
public int RecentLogsPollingIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Polling interval for the app logs block fallback (seconds).</summary>
|
||||
public int AppLogsPollingIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Polling interval for the DHCP leases list (seconds).</summary>
|
||||
public int LeasesPollingIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Maximum number of lines in the dnsmasq (recent) logs panel. Older lines are truncated.</summary>
|
||||
public int RecentLogsMaxLines { get; set; } = 500;
|
||||
|
||||
/// <summary>Whether to auto-scroll the dnsmasq logs panel when new lines arrive.</summary>
|
||||
public bool RecentLogsAutoScroll { get; set; } = true;
|
||||
|
||||
/// <summary>Maximum number of lines in the app logs panel. Older lines are truncated.</summary>
|
||||
public int AppLogsMaxLines { get; set; } = 500;
|
||||
|
||||
/// <summary>Whether to auto-scroll the app logs panel when new lines arrive.</summary>
|
||||
public bool AppLogsAutoScroll { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace DnsmasqWebUI.Models.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Per-field metadata for client settings: bounds, display names for validation errors.
|
||||
/// Used by SettingsModal (validation on save) and by pages/components (defensive clamp when loading from storage).
|
||||
/// </summary>
|
||||
public static class ClientSettingsFields
|
||||
{
|
||||
public sealed record FieldBounds(int Min, int Max, string DisplayName)
|
||||
{
|
||||
public bool IsValid(int value) => value >= Min && value <= Max;
|
||||
public int Clamp(int value) => Math.Clamp(value, Min, Max);
|
||||
public string? Validate(int value) =>
|
||||
IsValid(value) ? null : $"{DisplayName} must be between {Min} and {Max} seconds. You entered {value}.";
|
||||
}
|
||||
|
||||
public static readonly FieldBounds ServiceStatusPollingInterval = new(5, 300, "Service status polling interval");
|
||||
public static readonly FieldBounds RecentLogsPollingInterval = new(5, 300, "Recent logs polling interval");
|
||||
public static readonly FieldBounds AppLogsPollingInterval = new(5, 300, "App logs polling interval");
|
||||
public static readonly FieldBounds LeasesPollingInterval = new(5, 300, "DHCP leases refresh interval");
|
||||
public static readonly FieldBounds RecentLogsMaxLines = new(100, 2000, "Recent logs max lines");
|
||||
public static readonly FieldBounds AppLogsMaxLines = new(100, 2000, "App logs max lines");
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
namespace DnsmasqWebUI.Models.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Which subset of client settings to show in the settings modal.
|
||||
/// </summary>
|
||||
public enum SettingsModalContext
|
||||
{
|
||||
/// <summary>Show all settings with optional search.</summary>
|
||||
All,
|
||||
|
||||
/// <summary>Show only service status polling interval.</summary>
|
||||
ServicePolling,
|
||||
|
||||
/// <summary>Show only recent logs polling interval.</summary>
|
||||
LogsPolling,
|
||||
|
||||
/// <summary>Show only DHCP leases refresh interval.</summary>
|
||||
LeasesPolling
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
namespace DnsmasqWebUI.Models.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Which subset of client settings to show in the settings modal.
|
||||
/// </summary>
|
||||
public enum SettingsModalContext
|
||||
{
|
||||
/// <summary>Show all settings with optional search.</summary>
|
||||
All,
|
||||
|
||||
/// <summary>Show only service status polling interval.</summary>
|
||||
ServicePolling,
|
||||
|
||||
/// <summary>Show only recent dnsmasq logs polling interval.</summary>
|
||||
LogsPolling,
|
||||
|
||||
/// <summary>Show only app logs polling interval.</summary>
|
||||
AppLogsPolling,
|
||||
|
||||
/// <summary>Show only DHCP leases refresh interval.</summary>
|
||||
LeasesPolling
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Central definition of settings modal sections: keys, display names, field labels, and searchable text.
|
||||
/// </summary>
|
||||
public static class SettingsModalSections
|
||||
{
|
||||
public const string ServiceStatus = "ServiceStatus";
|
||||
public const string Logs = "Logs";
|
||||
public const string AppLogs = "AppLogs";
|
||||
public const string Leases = "Leases";
|
||||
public const string RecentLogsDisplay = "RecentLogsDisplay";
|
||||
public const string AppLogsDisplay = "AppLogsDisplay";
|
||||
|
||||
/// <summary>Section metadata: display name (header), field label (for input), and searchable text for filtering.</summary>
|
||||
public sealed record SectionMeta(string DisplayName, string FieldLabel, string SearchableText);
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, SectionMeta> All = new Dictionary<string, SectionMeta>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[ServiceStatus] = new("Service status", "Polling interval (seconds)", "service status polling interval seconds"),
|
||||
[Logs] = new("Recent logs", "Polling interval (seconds)", "recent dnsmasq logs polling interval seconds"),
|
||||
[AppLogs] = new("App logs", "Polling interval (seconds)", "app logs polling interval seconds"),
|
||||
[Leases] = new("DHCP leases", "Refresh interval (seconds)", "dhcp leases refresh interval seconds"),
|
||||
[RecentLogsDisplay] = new("Recent logs display", "Max lines / Auto-scroll", "recent logs display max lines auto scroll"),
|
||||
[AppLogsDisplay] = new("App logs display", "Max lines / Auto-scroll", "app logs display max lines auto scroll"),
|
||||
};
|
||||
|
||||
/// <summary>Maps a focused context (single-section view) to its section keys. Returns null for All (search mode).</summary>
|
||||
public static string[]? GetSectionKeysForContext(SettingsModalContext context) => context switch
|
||||
{
|
||||
SettingsModalContext.ServicePolling => [ServiceStatus],
|
||||
SettingsModalContext.LogsPolling => [Logs, RecentLogsDisplay],
|
||||
SettingsModalContext.AppLogsPolling => [AppLogs, AppLogsDisplay],
|
||||
SettingsModalContext.LeasesPolling => [Leases],
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>Legacy: returns first section key for context. Use GetSectionKeysForContext for multi-section support.</summary>
|
||||
public static string? GetSectionKeyForContext(SettingsModalContext context)
|
||||
{
|
||||
var keys = GetSectionKeysForContext(context);
|
||||
return keys is { Length: > 0 } ? keys[0] : null;
|
||||
}
|
||||
|
||||
public static bool MatchesSearch(string key, string searchTerm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchTerm)) return true;
|
||||
if (!All.TryGetValue(key, out var meta)) return false;
|
||||
return meta.SearchableText.Contains(searchTerm.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the app logs UI: which logger categories to exclude from the live display.
|
||||
/// Bound from "AppLogs" config section (appsettings.json, appsettings.Overrides.json, or AppLogs__* env vars).
|
||||
/// </summary>
|
||||
public class AppLogsOptions
|
||||
{
|
||||
/// <summary>Configuration section name.</summary>
|
||||
public const string SectionName = "AppLogs";
|
||||
|
||||
/// <summary>
|
||||
/// Logger category name prefixes to exclude from the app logs display.
|
||||
/// Any category that starts with one of these strings will not be forwarded to the buffer.
|
||||
/// Defaults exclude noisy framework categories: Components, Kestrel, HttpClient, etc.
|
||||
/// </summary>
|
||||
public List<string> ExcludedCategoryPrefixes { get; set; } =
|
||||
[
|
||||
"Microsoft.AspNetCore.Components.",
|
||||
"Microsoft.AspNetCore.Server.Kestrel.Transport.",
|
||||
"Microsoft.AspNetCore.Server.Kestrel.Connections",
|
||||
"System.Net.Http.",
|
||||
"Microsoft.Extensions.Http.",
|
||||
"Microsoft.AspNetCore.Hosting.",
|
||||
"Microsoft.AspNetCore.Routing.",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace DnsmasqWebUI.Models.Config;
|
||||
|
||||
/// <summary>
|
||||
/// Path for the app settings overrides file used to persist runtime config changes
|
||||
/// (e.g. log level). Bound from "RuntimeOverrides" config section.
|
||||
/// </summary>
|
||||
public class RuntimeOverridesOptions
|
||||
{
|
||||
public const string SectionName = "RuntimeOverrides";
|
||||
|
||||
/// <summary>Default filename when FilePath is not set (resolved relative to application base directory).</summary>
|
||||
public const string DefaultFileName = "appsettings.Overrides.json";
|
||||
|
||||
/// <summary>
|
||||
/// Full path to the JSON file the app reads/writes for runtime overrides.
|
||||
/// If not set, defaults to <see cref="DefaultFileName"/> in the application base directory.
|
||||
/// </summary>
|
||||
public string? FilePath { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace DnsmasqWebUI.Models.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// Payload sent via SignalR for log updates. Content is chunked by complete lines, max ~28KB per message.
|
||||
/// </summary>
|
||||
public sealed record LogsUpdatePayload(string Mode, string Content)
|
||||
{
|
||||
/// <summary>"append" = add to existing; "replace" = clear then set.</summary>
|
||||
public string Mode { get; } = Mode;
|
||||
|
||||
/// <summary>One or more complete lines. Never split mid-line.</summary>
|
||||
public string Content { get; } = Content;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using DnsmasqWebUI.Components;
|
||||
using DnsmasqWebUI.Hubs;
|
||||
using DnsmasqWebUI.Models.Config;
|
||||
using DnsmasqWebUI.Extensions;
|
||||
using DnsmasqWebUI.Infrastructure.Helpers.Http;
|
||||
using DnsmasqWebUI.Infrastructure.Services;
|
||||
using DnsmasqWebUI.Infrastructure.Services.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
// When not in Development, use the app's directory (not CWD) so static assets work when run via symlink or from any CWD.
|
||||
@@ -10,9 +13,19 @@ var builder = isDevelopment
|
||||
? WebApplication.CreateBuilder(args)
|
||||
: WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), Args = args });
|
||||
|
||||
// Runtime overrides file (Logging.LogLevel.Default, etc.) — add as config source so startup uses actual log level.
|
||||
var overridesPath = builder.Configuration["RuntimeOverrides:FilePath"]?.Trim();
|
||||
if (string.IsNullOrEmpty(overridesPath))
|
||||
overridesPath = Path.Combine(AppContext.BaseDirectory, RuntimeOverridesOptions.DefaultFileName);
|
||||
builder.Configuration.AddJsonFile(overridesPath, optional: true, reloadOnChange: true);
|
||||
|
||||
// ---- Application options (title, etc.) ----
|
||||
builder.Services.AddOptions<ApplicationOptions>()
|
||||
.Bind(builder.Configuration.GetSection(ApplicationOptions.SectionName));
|
||||
builder.Services.AddOptions<AppLogsOptions>()
|
||||
.Bind(builder.Configuration.GetSection(AppLogsOptions.SectionName));
|
||||
builder.Services.AddOptions<RuntimeOverridesOptions>()
|
||||
.Bind(builder.Configuration.GetSection(RuntimeOverridesOptions.SectionName));
|
||||
|
||||
// ---- Dnsmasq options (required paths validated at startup) ----
|
||||
builder.Services.AddOptions<DnsmasqOptions>()
|
||||
@@ -22,14 +35,17 @@ builder.Services.AddSingleton<IValidateOptions<DnsmasqOptions>, DnsmasqOptionsVa
|
||||
|
||||
// ---- Application services ----
|
||||
builder.Services.AddApplicationServices();
|
||||
builder.Services.AddScoped<ISettingsModalService, SettingsModalService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddDnsmasqApiHttpClients();
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o => ApiJsonOptions.ConfigureServer(o.JsonSerializerOptions));
|
||||
builder.Services.AddSignalR();
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
builder.Logging.AddAppLogs(builder.Configuration);
|
||||
builder.Services.AddCorsFromConfiguration(builder.Configuration);
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -56,6 +72,7 @@ app.UseAntiforgery();
|
||||
|
||||
// ---- Endpoints ----
|
||||
app.MapControllers();
|
||||
app.MapHub<LogsHub>("/hubs/logs");
|
||||
// UseStaticFiles: MapStaticAssets returns 0-byte responses for fingerprinted assets (known bug). Serve from wwwroot directly.
|
||||
app.UseStaticFiles();
|
||||
app.MapRazorComponents<App>()
|
||||
|
||||
@@ -5,9 +5,22 @@
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.Hosting.Lifetime": "Information",
|
||||
"System.Net.Http.HttpClient": "Warning"
|
||||
}
|
||||
},
|
||||
"AppLogs": {
|
||||
"ExcludedCategoryPrefixes": [
|
||||
"Microsoft.AspNetCore.Components.",
|
||||
"Microsoft.AspNetCore.Server.Kestrel.Transport.",
|
||||
"Microsoft.AspNetCore.Server.Kestrel.Connections",
|
||||
"System.Net.Http.",
|
||||
"Microsoft.Extensions.Http.",
|
||||
"Microsoft.AspNetCore.Hosting.",
|
||||
"Microsoft.AspNetCore.Routing."
|
||||
]
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ForwardedHeaders": {
|
||||
"Enabled": false
|
||||
@@ -33,4 +46,4 @@
|
||||
"StatusShowCommand": "systemctl status dnsmasq --no-pager",
|
||||
"LogsCommand": "journalctl -u dnsmasq -n 100 --no-pager"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Logs terminal: append/replace content with optional max lines and auto-scroll. Used by LogsSection for dnsmasq and app logs.
|
||||
* @param {string} elementId - id of the pre element
|
||||
* @param {string} content - log content to append or replace
|
||||
* @param {{ maxLines?: number, autoScroll?: boolean }} [options] - maxLines: truncate to last N lines; autoScroll: scroll to bottom (default true)
|
||||
*/
|
||||
export function appendLogs(elementId, content, options = {}) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
el.textContent += content;
|
||||
truncateToMaxLines(el, options.maxLines);
|
||||
if (options.autoScroll !== false)
|
||||
scrollLogsToBottom(elementId);
|
||||
}
|
||||
|
||||
export function replaceLogs(elementId, content, options = {}) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
el.textContent = content;
|
||||
truncateToMaxLines(el, options.maxLines);
|
||||
if (options.autoScroll !== false)
|
||||
scrollLogsToBottom(elementId);
|
||||
}
|
||||
|
||||
function truncateToMaxLines(el, maxLines) {
|
||||
if (maxLines == null || maxLines <= 0) return;
|
||||
const text = el.textContent || '';
|
||||
const lines = text.split('\n');
|
||||
if (lines.length <= maxLines) return;
|
||||
el.textContent = lines.slice(-maxLines).join('\n');
|
||||
}
|
||||
|
||||
export function scrollLogsToBottom(elementId) {
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
Vendored
+1
-3
@@ -1,4 +1,4 @@
|
||||
# Config for the dnsmasq test container only (docker-compose.test.yml).
|
||||
# Config for the dnsmasq test container only (docker-compose.test.yml).
|
||||
# DNS + DHCP: forwards DNS to Cloudflare/Google with caching; hands out DHCP on testnet.
|
||||
# Uses same testdata layout so app and dnsmasq share hosts, dhcp.conf, leases.
|
||||
# Richer config so UI shows multi-value lists (server, addn-hosts, address, dhcp-range) and readonly sources.
|
||||
@@ -32,5 +32,3 @@ addn-hosts=/data/hosts.extra
|
||||
# Log to file so the harness can show real logs (LogsCommand tails this)
|
||||
log-facility=/data/dnsmasq.log
|
||||
log-queries
|
||||
|
||||
conf-file=zz-dnsmasq-webui.conf
|
||||
|
||||
Reference in New Issue
Block a user