diff --git a/board/common/rootfs/etc/default/webui b/board/common/rootfs/etc/default/webui
new file mode 100644
index 00000000..765ba175
--- /dev/null
+++ b/board/common/rootfs/etc/default/webui
@@ -0,0 +1,2 @@
+RESTCONF_URL=https://127.0.0.1/restconf
+INSECURE_TLS=1
diff --git a/board/common/rootfs/etc/nginx/app/restconf.conf b/board/common/rootfs/etc/nginx/app/restconf.conf
new file mode 120000
index 00000000..01182dc2
--- /dev/null
+++ b/board/common/rootfs/etc/nginx/app/restconf.conf
@@ -0,0 +1 @@
+../restconf.app
\ No newline at end of file
diff --git a/board/common/rootfs/etc/nginx/restconf-access-local.conf b/board/common/rootfs/etc/nginx/restconf-access-local.conf
new file mode 100644
index 00000000..ec361e7d
--- /dev/null
+++ b/board/common/rootfs/etc/nginx/restconf-access-local.conf
@@ -0,0 +1,3 @@
+allow 127.0.0.1;
+allow ::1;
+deny all;
diff --git a/board/common/rootfs/etc/nginx/restconf-access.conf b/board/common/rootfs/etc/nginx/restconf-access.conf
new file mode 120000
index 00000000..3a404cd8
--- /dev/null
+++ b/board/common/rootfs/etc/nginx/restconf-access.conf
@@ -0,0 +1 @@
+restconf-access-local.conf
\ No newline at end of file
diff --git a/board/common/rootfs/etc/nginx/restconf.app b/board/common/rootfs/etc/nginx/restconf.app
index 7c8cadda..1d261741 100644
--- a/board/common/rootfs/etc/nginx/restconf.app
+++ b/board/common/rootfs/etc/nginx/restconf.app
@@ -1,5 +1,6 @@
# /telemetry/optics is for streaming (not used atm)
location ~ ^/(restconf|yang|.well-known)/ {
+ include /etc/nginx/restconf-access.conf;
grpc_pass grpc://[::1]:10080;
grpc_set_header Host $host;
grpc_set_header X-Real-IP $remote_addr;
diff --git a/configs/aarch64_defconfig b/configs/aarch64_defconfig
index d28e9ed5..447424ea 100644
--- a/configs/aarch64_defconfig
+++ b/configs/aarch64_defconfig
@@ -175,7 +175,6 @@ BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_IITO=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
-BR2_PACKAGE_LANDING=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_MDNS_ALIAS=y
@@ -187,6 +186,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y
BR2_PACKAGE_PODMAN_DRIVER_VFS=y
BR2_PACKAGE_TETRIS=y
BR2_PACKAGE_ROUSETTE=y
+# BR2_PACKAGE_LANDING is not set
+BR2_PACKAGE_WEBUI=y
BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y
BR2_PACKAGE_HOST_PYTHON_YANGDOC=y
BR2_PACKAGE_PCIUTILS=y
diff --git a/configs/aarch64_minimal_defconfig b/configs/aarch64_minimal_defconfig
index 9ba5cb7c..c00579d0 100644
--- a/configs/aarch64_minimal_defconfig
+++ b/configs/aarch64_minimal_defconfig
@@ -144,6 +144,7 @@ BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_IITO=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
+BR2_PACKAGE_LANDING=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_MDNS_ALIAS=y
diff --git a/configs/arm_defconfig b/configs/arm_defconfig
index be928860..c08ba158 100644
--- a/configs/arm_defconfig
+++ b/configs/arm_defconfig
@@ -162,7 +162,6 @@ BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_IITO=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
-BR2_PACKAGE_LANDING=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_MDNS_ALIAS=y
@@ -170,6 +169,8 @@ BR2_PACKAGE_NETBROWSE=y
BR2_PACKAGE_ONIEPROM=y
BR2_PACKAGE_TETRIS=y
BR2_PACKAGE_ROUSETTE=y
+# BR2_PACKAGE_LANDING is not set
+BR2_PACKAGE_WEBUI=y
BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y
BR2_PACKAGE_HOST_PYTHON_YANGDOC=y
IMAGE_ITB_AUX=y
diff --git a/configs/arm_minimal_defconfig b/configs/arm_minimal_defconfig
index d1cd8025..b9090d2f 100644
--- a/configs/arm_minimal_defconfig
+++ b/configs/arm_minimal_defconfig
@@ -142,6 +142,7 @@ BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_IITO=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
+BR2_PACKAGE_LANDING=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_MDNS_ALIAS=y
diff --git a/configs/riscv64_defconfig b/configs/riscv64_defconfig
index 30d59e69..b687676f 100644
--- a/configs/riscv64_defconfig
+++ b/configs/riscv64_defconfig
@@ -194,7 +194,6 @@ BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_IITO=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
-BR2_PACKAGE_LANDING=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_MDNS_ALIAS=y
@@ -206,6 +205,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y
BR2_PACKAGE_PODMAN_DRIVER_VFS=y
BR2_PACKAGE_TETRIS=y
BR2_PACKAGE_ROUSETTE=y
+# BR2_PACKAGE_LANDING is not set
+BR2_PACKAGE_WEBUI=y
BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y
BR2_PACKAGE_HOST_PYTHON_YANGDOC=y
BR2_PACKAGE_PCIUTILS=y
diff --git a/configs/x86_64_defconfig b/configs/x86_64_defconfig
index 903533b5..981cabf5 100644
--- a/configs/x86_64_defconfig
+++ b/configs/x86_64_defconfig
@@ -169,7 +169,6 @@ BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_IITO=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
-BR2_PACKAGE_LANDING=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_MDNS_ALIAS=y
@@ -181,6 +180,8 @@ BR2_PACKAGE_PODMAN_DRIVER_DEVICEMAPPER=y
BR2_PACKAGE_PODMAN_DRIVER_VFS=y
BR2_PACKAGE_TETRIS=y
BR2_PACKAGE_ROUSETTE=y
+# BR2_PACKAGE_LANDING is not set
+BR2_PACKAGE_WEBUI=y
BR2_PACKAGE_RAUC_INSTALLATION_STATUS=y
BR2_PACKAGE_HOST_PYTHON_YANGDOC=y
BR2_PACKAGE_PCIUTILS=y
diff --git a/configs/x86_64_minimal_defconfig b/configs/x86_64_minimal_defconfig
index 816cdd0f..893163ec 100644
--- a/configs/x86_64_minimal_defconfig
+++ b/configs/x86_64_minimal_defconfig
@@ -141,6 +141,7 @@ BR2_PACKAGE_FIREWALL=y
BR2_PACKAGE_IITO=y
BR2_PACKAGE_KEYACK=y
BR2_PACKAGE_KLISH_PLUGIN_INFIX=y
+BR2_PACKAGE_LANDING=y
BR2_PACKAGE_LOWDOWN=y
BR2_PACKAGE_MCD=y
BR2_PACKAGE_MDNS_ALIAS=y
diff --git a/package/Config.in b/package/Config.in
index 110d247b..6998f580 100644
--- a/package/Config.in
+++ b/package/Config.in
@@ -42,6 +42,7 @@ source "$BR2_EXTERNAL_INFIX_PATH/package/tetris/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/libyang-cpp/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/sysrepo-cpp/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/rousette/Config.in"
+source "$BR2_EXTERNAL_INFIX_PATH/package/webui/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/nghttp2-asio/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/date-cpp/Config.in"
source "$BR2_EXTERNAL_INFIX_PATH/package/rauc-installation-status/Config.in"
diff --git a/board/common/rootfs/etc/nginx/available/default.conf b/package/landing/default.conf
similarity index 100%
rename from board/common/rootfs/etc/nginx/available/default.conf
rename to package/landing/default.conf
diff --git a/package/landing/landing.mk b/package/landing/landing.mk
index 6a18d67c..076a8deb 100644
--- a/package/landing/landing.mk
+++ b/package/landing/landing.mk
@@ -14,6 +14,8 @@ define LANDING_INSTALL_TARGET_CMDS
mkdir -p $(TARGET_DIR)/usr/html/
cp $(@D)/*.html $(TARGET_DIR)/usr/html/
cp $(@D)/*.png $(TARGET_DIR)/usr/html/
+ $(INSTALL) -D -m 0644 $(LANDING_PKGDIR)/default.conf \
+ $(TARGET_DIR)/etc/nginx/available/default.conf
endef
$(eval $(generic-package))
diff --git a/package/webui/50x.html b/package/webui/50x.html
new file mode 100644
index 00000000..b2b334aa
--- /dev/null
+++ b/package/webui/50x.html
@@ -0,0 +1,30 @@
+
+
+
+Loading…
+
+
+
+
+
+Loading…
+The device is finishing its startup. This page refreshes automatically.
+
+
+
diff --git a/package/webui/Config.in b/package/webui/Config.in
new file mode 100644
index 00000000..cbf1a354
--- /dev/null
+++ b/package/webui/Config.in
@@ -0,0 +1,9 @@
+config BR2_PACKAGE_WEBUI
+ bool "webui"
+ depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS
+ depends on BR2_PACKAGE_ROUSETTE
+ depends on !BR2_PACKAGE_LANDING
+ help
+ Web management interface for Infix, a Go+HTMX application
+ that provides browser-based configuration and monitoring
+ via RESTCONF.
diff --git a/package/webui/default.conf b/package/webui/default.conf
new file mode 100644
index 00000000..1e66a2e7
--- /dev/null
+++ b/package/webui/default.conf
@@ -0,0 +1,24 @@
+server {
+ listen 80;
+ listen [::]:80;
+ server_name _;
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name _;
+ include ssl.conf;
+
+ # 404 also points at /50x.html: the page is a "Loading…" screen
+ # with a meta-refresh, so the early-boot window where the Go
+ # backend isn't up yet (and any other transient 404 / 5xx) self-
+ # recovers as soon as upstream comes back.
+ error_page 404 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root html;
+ }
+
+ include /etc/nginx/app/*.conf;
+}
diff --git a/package/webui/webui.conf b/package/webui/webui.conf
new file mode 100644
index 00000000..bcedda91
--- /dev/null
+++ b/package/webui/webui.conf
@@ -0,0 +1,9 @@
+location / {
+ proxy_pass http://127.0.0.1:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_redirect off;
+}
diff --git a/package/webui/webui.mk b/package/webui/webui.mk
new file mode 100644
index 00000000..aaf49c2e
--- /dev/null
+++ b/package/webui/webui.mk
@@ -0,0 +1,27 @@
+################################################################################
+#
+# webui
+#
+################################################################################
+
+WEBUI_VERSION = 1.0
+WEBUI_SITE_METHOD = local
+WEBUI_SITE = $(BR2_EXTERNAL_INFIX_PATH)/src/webui
+WEBUI_GOMOD = github.com/kernelkit/webui
+WEBUI_LICENSE = MIT
+WEBUI_LICENSE_FILES = LICENSE
+WEBUI_REDISTRIBUTE = NO
+
+define WEBUI_INSTALL_EXTRA
+ $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui.svc \
+ $(FINIT_D)/available/webui.conf
+ $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/webui.conf \
+ $(TARGET_DIR)/etc/nginx/app/webui.conf
+ $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/default.conf \
+ $(TARGET_DIR)/etc/nginx/available/default.conf
+ $(INSTALL) -D -m 0644 $(WEBUI_PKGDIR)/50x.html \
+ $(TARGET_DIR)/usr/html/50x.html
+endef
+WEBUI_POST_INSTALL_TARGET_HOOKS += WEBUI_INSTALL_EXTRA
+
+$(eval $(golang-package))
diff --git a/package/webui/webui.svc b/package/webui/webui.svc
new file mode 100644
index 00000000..9a8979e3
--- /dev/null
+++ b/package/webui/webui.svc
@@ -0,0 +1,3 @@
+service name:webui log:prio:daemon.info,tag:webui \
+ [2345] env:-/etc/default/webui webui -listen 127.0.0.1:10000 \
+ -- Web management interface
diff --git a/src/confd/src/services.c b/src/confd/src/services.c
index 0981f80d..b1db7caf 100644
--- a/src/confd/src/services.c
+++ b/src/confd/src/services.c
@@ -19,6 +19,8 @@
#define GENERATE_STRING(STRING) #STRING,
#define NGINX_SSL_CONF "/etc/nginx/ssl.conf"
+#define NGINX_RESTCONF_ACCESS "/etc/nginx/restconf-access.conf"
+#define NGINX_RESTCONF_ACCESS_LOCAL "restconf-access-local.conf"
#define AVAHI_SVC_PATH "/etc/avahi/services"
#define LLDP_CONFIG "/etc/lldpd.d/confd.conf"
@@ -557,7 +559,30 @@ static int restconf_change(sr_session_ctx_t *session, struct lyd_node *config, s
ena = lydx_is_enabled(srv, "enabled") &&
lydx_is_enabled(lydx_get_xpathf(config, WEB_XPATH), "enabled");
- svc_enable(ena, restconf, "restconf");
+
+ /*
+ * restconf.app is permanently installed in nginx/app/ so rousette is
+ * always reachable from loopback (required by the WebUI). External
+ * access is gated by an `include` of restconf-access.conf — empty
+ * means no allow/deny rules apply and nginx's default of `allow all`
+ * takes effect; populated means loopback-only.
+ *
+ * The shipped state is a symlink to restconf-access-local.conf (which
+ * holds the loopback rules), so the rules are defined in exactly one
+ * place. erase() before re-creating because fopen("w") on a symlink
+ * follows the link and would clobber the template; the empty-file
+ * path needs a real file, and the loopback path needs a fresh
+ * symlink that may have been replaced on a previous transition.
+ */
+ erase(NGINX_RESTCONF_ACCESS);
+ if (ena) {
+ FILE *fp = fopen(NGINX_RESTCONF_ACCESS, "w");
+ if (fp)
+ fclose(fp);
+ } else if (symlink(NGINX_RESTCONF_ACCESS_LOCAL, NGINX_RESTCONF_ACCESS)) {
+ ERRNO("failed restoring %s", NGINX_RESTCONF_ACCESS);
+ }
+ mdns_records(ena ? MDNS_ADD : MDNS_DELETE, restconf);
finit_reload("nginx");
return put(cfg);
@@ -703,13 +728,16 @@ static int web_change(sr_session_ctx_t *session, struct lyd_node *config, struct
/* Web master on/off: propagate to nginx and all sub-services */
if (lydx_get_xpathf(diff, WEB_XPATH "/enabled")) {
+ int rc_ena = ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_RESTCONF_XPATH), "enabled");
int nb_ena = ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_NETBROWSE_XPATH), "enabled");
svc_enable(ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_CONSOLE_XPATH), "enabled"),
ttyd, "ttyd");
svc_enable(nb_ena, netbrowse, "netbrowse");
- svc_enable(ena && lydx_is_enabled(lydx_get_xpathf(config, WEB_RESTCONF_XPATH), "enabled"),
- restconf, "restconf");
+ /* Rousette follows web/enabled; external access is gated separately via restconf/enabled */
+ ena ? finit_enable("restconf") : finit_disable("restconf");
+ ena ? finit_enable("webui") : finit_disable("webui");
+ mdns_records(rc_ena ? MDNS_ADD : MDNS_DELETE, restconf);
svc_enable(ena, web, "nginx");
mdns_alias_conf(nb_ena);
finit_reload("mdns-alias");
diff --git a/src/webui/.gitignore b/src/webui/.gitignore
new file mode 100644
index 00000000..859dc423
--- /dev/null
+++ b/src/webui/.gitignore
@@ -0,0 +1 @@
+webui
diff --git a/src/webui/LICENSE b/src/webui/LICENSE
new file mode 100644
index 00000000..364389fb
--- /dev/null
+++ b/src/webui/LICENSE
@@ -0,0 +1,19 @@
+Copyright (c) 2026 The KernelKit Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/src/webui/Makefile b/src/webui/Makefile
new file mode 100644
index 00000000..e21f50dd
--- /dev/null
+++ b/src/webui/Makefile
@@ -0,0 +1,15 @@
+BINARY = webui
+GOARCH ?= $(shell go env GOARCH)
+GOOS ?= $(shell go env GOOS)
+
+build:
+ CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
+ go build -ldflags="-s -w" -o $(BINARY) .
+
+dev: build
+ go run . --listen :10000 --session-key /tmp/webui-session.key --insecure-tls $(ARGS)
+
+clean:
+ rm -f $(BINARY)
+
+.PHONY: build dev clean
diff --git a/src/webui/README.md b/src/webui/README.md
new file mode 100644
index 00000000..d847f94b
--- /dev/null
+++ b/src/webui/README.md
@@ -0,0 +1,120 @@
+# Infix WebUI
+
+A lightweight web management interface for [Infix][1] network devices,
+built with Go and [htmx][2].
+
+The WebUI communicates with the device over [RESTCONF][3] (RFC 8040),
+presenting the same operational data available through the Infix CLI in
+a browser-friendly format.
+
+## Features
+
+- **Dashboard** -- system info, hardware, sensors, and interface summary
+ with bridge member grouping
+- **Interfaces** -- list with status, addresses, and per-type detail;
+ click through to a detail page with live-updating counters, WiFi
+ station table, scan results, WireGuard peers, and ethernet frame
+ statistics
+- **Firewall** -- zone-to-zone policy matrix
+- **Keystore** -- symmetric and asymmetric key display
+- **Firmware** -- slot overview, install from URL with live progress
+- **Reboot** -- two-phase status polling (wait down, wait up)
+- **Config download** -- startup datastore as JSON
+
+
+## Building
+
+Requires Go 1.22 or later.
+
+```sh
+make build
+```
+
+Produces a statically linked `webui` binary with all templates,
+CSS, and JS embedded.
+
+Cross-compile for the target:
+
+```sh
+GOOS=linux GOARCH=arm64 make build
+```
+
+
+## Running
+
+```sh
+./webui --restconf https://192.168.0.1/restconf --listen :10000
+```
+
+| **Flag** | **Default** | **Description** |
+|-------------------|-----------------------------------|-------------------------------------------|
+| `--listen` | `:10000` | Address to listen on |
+| `--restconf` | `http://localhost:8080/restconf` | RESTCONF base URL of the device |
+| `--session-key` | `/var/lib/misc/webui-session.key` | Path to persistent session encryption key |
+| `--insecure-tls` | `false` | Disable TLS certificate verification |
+
+The RESTCONF URL can also be set via the `RESTCONF_URL` environment
+variable.
+
+
+## Development
+
+Point `RESTCONF_URL` at a running Infix device and start the dev
+server:
+
+```sh
+make dev ARGS="--restconf https://192.168.0.1/restconf"
+```
+
+This runs `go run .` on port 10000 with `--insecure-tls` already set.
+
+
+## Architecture
+
+```
+Browser ──htmx──▶ Go server ──RESTCONF──▶ Infix device (rousette/sysrepo)
+```
+
+- **Single binary** -- templates, CSS, JS, and images are embedded via
+ `go:embed`
+- **Server-side rendering** -- Go `html/template` with per-page parsing
+ to avoid `{{define "content"}}` collisions
+- **htmx SPA navigation** -- sidebar links use `hx-get` / `hx-target`
+ for partial page updates with `hx-push-url` for browser history
+- **Stateless sessions** -- AES-256-GCM encrypted cookies carry
+ credentials (needed for every RESTCONF call); no server-side session
+ store
+- **Live polling** -- counters update every 5s, firmware progress every
+ 3s, all via htmx triggers
+
+```
+main.go Entry point, flags, embedded FS
+internal/
+ auth/ Login, logout, session (AES-GCM cookies)
+ restconf/ HTTP client (Get, GetRaw, Post, PostJSON)
+ handlers/ Page handlers
+ dashboard.go Dashboard, hardware, sensors
+ interfaces.go Interface list, detail, counters
+ firewall.go Zone matrix
+ keystore.go Key display
+ system.go Firmware, reboot, config download
+ server/
+ server.go Route registration, template wiring, middleware
+templates/
+ layouts/ base.html (shell), sidebar.html
+ pages/ Per-page templates (one per route)
+ fragments/ htmx partial fragments
+static/
+ css/style.css All styles
+ js/htmx.min.js htmx library
+ img/ Logo, favicon
+```
+
+
+## License
+
+See [LICENSE](LICENSE).
+
+[1]: https://github.com/kernelkit/infix
+[2]: https://htmx.org
+[3]: https://datatracker.ietf.org/doc/html/rfc8040
diff --git a/src/webui/go.mod b/src/webui/go.mod
new file mode 100644
index 00000000..63966d74
--- /dev/null
+++ b/src/webui/go.mod
@@ -0,0 +1,3 @@
+module github.com/kernelkit/webui
+
+go 1.22
diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go
new file mode 100644
index 00000000..17d75f79
--- /dev/null
+++ b/src/webui/internal/auth/login.go
@@ -0,0 +1,131 @@
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "errors"
+ "html/template"
+ "log"
+ "net/http"
+
+ "github.com/kernelkit/webui/internal/handlers"
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+const cookieName = "session"
+
+// LoginHandler serves the login page and processes login/logout requests.
+type LoginHandler struct {
+ Store *SessionStore
+ RC *restconf.Client
+ Template *template.Template
+}
+
+type loginData struct {
+ Error string
+ CsrfToken string
+}
+
+// ShowLogin renders the login page (GET /login).
+func (h *LoginHandler) ShowLogin(w http.ResponseWriter, r *http.Request) {
+ h.renderLogin(w, r, "")
+}
+
+// DoLogin validates credentials against RESTCONF and creates a session (POST /login).
+func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ h.renderLogin(w, r, "Invalid request.")
+ return
+ }
+
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ if username == "" || password == "" {
+ h.renderLogin(w, r, "Username and password are required.")
+ return
+ }
+
+ // Verify credentials by making a RESTCONF call with Basic Auth.
+ err := h.RC.CheckAuth(username, password)
+ if err != nil {
+ log.Printf("login failed for %q: %v", username, err)
+ var authErr *restconf.AuthError
+ if errors.As(err, &authErr) {
+ h.renderLogin(w, r, "Invalid username or password.")
+ } else {
+ h.renderLogin(w, r, "Unable to reach the device. Please try again later.")
+ }
+ return
+ }
+
+ // Probe optional features once at login and bake into the session.
+ ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{
+ Username: username,
+ Password: password,
+ })
+ caps := handlers.DetectCapabilities(ctx, h.RC)
+
+ token, csrfToken, err := h.Store.Create(username, password, caps.Features())
+ if err != nil {
+ log.Printf("session create error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ Secure: security.IsSecureRequest(r),
+ SameSite: http.SameSiteLaxMode,
+ })
+ security.EnsureToken(w, r, csrfToken)
+
+ fullRedirect(w, r, "/")
+}
+
+// DoLogout destroys the session and redirects to the login page (POST /logout).
+func (h *LoginHandler) DoLogout(w http.ResponseWriter, r *http.Request) {
+ if c, err := r.Cookie(cookieName); err == nil {
+ h.Store.Delete(c.Value)
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: security.IsSecureRequest(r),
+ SameSite: http.SameSiteLaxMode,
+ })
+ security.ClearToken(w, r)
+
+ fullRedirect(w, r, "/login")
+}
+
+// fullRedirect forces a full page navigation. When the request comes
+// from htmx (boosted form) we use HX-Redirect so the browser does a
+// real page load instead of an AJAX swap — this is essential for the
+// login/logout transition where the page layout changes completely.
+func fullRedirect(w http.ResponseWriter, r *http.Request, url string) {
+ if r.Header.Get("HX-Request") == "true" {
+ w.Header().Set("HX-Redirect", url)
+ return
+ }
+ http.Redirect(w, r, url, http.StatusSeeOther)
+}
+
+func (h *LoginHandler) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) {
+ data := loginData{
+ Error: errMsg,
+ CsrfToken: security.TokenFromContext(r.Context()),
+ }
+ if err := h.Template.ExecuteTemplate(w, "login.html", data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
diff --git a/src/webui/internal/auth/session.go b/src/webui/internal/auth/session.go
new file mode 100644
index 00000000..f8d87aab
--- /dev/null
+++ b/src/webui/internal/auth/session.go
@@ -0,0 +1,3 @@
+// SPDX-License-Identifier: MIT
+
+package auth
diff --git a/src/webui/internal/auth/store.go b/src/webui/internal/auth/store.go
new file mode 100644
index 00000000..25051ce9
--- /dev/null
+++ b/src/webui/internal/auth/store.go
@@ -0,0 +1,156 @@
+// SPDX-License-Identifier: MIT
+
+package auth
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+const sessionTimeout = 1 * time.Hour
+
+type tokenPayload struct {
+ Username string `json:"u"`
+ Password string `json:"p"`
+ CsrfToken string `json:"c"`
+ CreatedAt int64 `json:"t"`
+ Features map[string]bool `json:"f,omitempty"`
+}
+
+// SessionStore issues and validates stateless encrypted tokens.
+// The cookie value is a base64url-encoded AES-256-GCM sealed blob
+// containing the user's credentials and a creation timestamp.
+// No server-side session map is needed — only the AES key must
+// persist across restarts.
+type SessionStore struct {
+ aead cipher.AEAD
+}
+
+// NewSessionStore creates a store. If keyFile is non-empty, the AES
+// key is read from that path (or generated and written there on first
+// run). If keyFile is empty, a random ephemeral key is used.
+func NewSessionStore(keyFile string) (*SessionStore, error) {
+ key, err := loadOrCreateKey(keyFile)
+ if err != nil {
+ return nil, err
+ }
+
+ block, err := aes.NewCipher(key[:])
+ if err != nil {
+ return nil, err
+ }
+ aead, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+
+ return &SessionStore{aead: aead}, nil
+}
+
+// Create returns an encrypted token carrying the user's credentials and capabilities.
+func (s *SessionStore) Create(username, password string, features map[string]bool) (string, string, error) {
+ csrf := randomToken()
+ token, err := s.CreateWithCSRF(username, password, csrf, features)
+ return token, csrf, err
+}
+
+// CreateWithCSRF returns an encrypted token carrying the user's credentials,
+// capabilities, and a bound CSRF token.
+func (s *SessionStore) CreateWithCSRF(username, password, csrf string, features map[string]bool) (string, error) {
+ payload, err := json.Marshal(tokenPayload{
+ Username: username,
+ Password: password,
+ CsrfToken: csrf,
+ CreatedAt: time.Now().Unix(),
+ Features: features,
+ })
+ if err != nil {
+ return "", err
+ }
+
+ nonce := make([]byte, s.aead.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", err
+ }
+
+ sealed := s.aead.Seal(nonce, nonce, payload, nil)
+ return base64.RawURLEncoding.EncodeToString(sealed), nil
+}
+
+// Lookup decrypts a token and returns the credentials and capabilities if valid.
+func (s *SessionStore) Lookup(token string) (username, password, csrf string, features map[string]bool, ok bool) {
+ raw, err := base64.RawURLEncoding.DecodeString(token)
+ if err != nil {
+ return "", "", "", nil, false
+ }
+
+ ns := s.aead.NonceSize()
+ if len(raw) < ns {
+ return "", "", "", nil, false
+ }
+
+ plaintext, err := s.aead.Open(nil, raw[:ns], raw[ns:], nil)
+ if err != nil {
+ return "", "", "", nil, false
+ }
+
+ var p tokenPayload
+ if err := json.Unmarshal(plaintext, &p); err != nil {
+ return "", "", "", nil, false
+ }
+
+ if time.Since(time.Unix(p.CreatedAt, 0)) > sessionTimeout {
+ return "", "", "", nil, false
+ }
+
+ return p.Username, p.Password, p.CsrfToken, p.Features, true
+}
+
+// Delete is a no-op for stateless tokens (the cookie is cleared by
+// the caller), but kept to satisfy the existing logout flow.
+func (s *SessionStore) Delete(token string) {}
+
+// loadOrCreateKey returns a 32-byte AES key. When path is non-empty
+// the key is persisted so sessions survive restarts.
+func loadOrCreateKey(path string) ([32]byte, error) {
+ var key [32]byte
+
+ if path != "" {
+ data, err := os.ReadFile(path)
+ if err == nil && len(data) == 32 {
+ copy(key[:], data)
+ return key, nil
+ }
+ }
+
+ if _, err := io.ReadFull(rand.Reader, key[:]); err != nil {
+ return key, fmt.Errorf("generate session key: %w", err)
+ }
+
+ if path != "" {
+ if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
+ return key, fmt.Errorf("create key directory: %w", err)
+ }
+ if err := os.WriteFile(path, key[:], 0600); err != nil {
+ return key, fmt.Errorf("write session key: %w", err)
+ }
+ }
+
+ return key, nil
+}
+
+func randomToken() string {
+ var b [32]byte
+ if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
+ return ""
+ }
+ return base64.RawURLEncoding.EncodeToString(b[:])
+}
diff --git a/src/webui/internal/handlers/capabilities.go b/src/webui/internal/handlers/capabilities.go
new file mode 100644
index 00000000..5472894b
--- /dev/null
+++ b/src/webui/internal/handlers/capabilities.go
@@ -0,0 +1,98 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "log"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+type feature struct {
+ Name string // key used in Has() and session cookie
+ Module string // YANG module that carries the feature
+ Feature string // YANG feature name in the module's feature array
+}
+
+// optionalFeatures maps UI capabilities to YANG module features. Extend here.
+var optionalFeatures = []feature{
+ {Name: "wifi", Module: "infix-interfaces", Feature: "wifi"},
+ {Name: "containers", Module: "infix-interfaces", Feature: "containers"},
+}
+
+// Capabilities tracks which optional features are present on the device.
+// Use Has("feature-name") in templates and Go code.
+type Capabilities struct {
+ features map[string]bool
+}
+
+func NewCapabilities(features map[string]bool) *Capabilities {
+ if features == nil {
+ features = make(map[string]bool)
+ }
+ return &Capabilities{features: features}
+}
+
+func (c *Capabilities) Has(name string) bool {
+ return c != nil && c.features[name]
+}
+
+func (c *Capabilities) Features() map[string]bool {
+ if c == nil {
+ return nil
+ }
+ return c.features
+}
+
+type capsCtxKey struct{}
+
+func ContextWithCapabilities(ctx context.Context, caps *Capabilities) context.Context {
+ return context.WithValue(ctx, capsCtxKey{}, caps)
+}
+
+func CapabilitiesFromContext(ctx context.Context) *Capabilities {
+ caps, _ := ctx.Value(capsCtxKey{}).(*Capabilities)
+ if caps == nil {
+ return NewCapabilities(nil)
+ }
+ return caps
+}
+
+type yangLibrary struct {
+ YangLibrary struct {
+ ModuleSet []struct {
+ Module []struct {
+ Name string `json:"name"`
+ Feature []string `json:"feature"`
+ } `json:"module"`
+ } `json:"module-set"`
+ } `json:"ietf-yang-library:yang-library"`
+}
+
+func DetectCapabilities(ctx context.Context, rc restconf.Fetcher) *Capabilities {
+ var lib yangLibrary
+ if err := rc.Get(ctx, "/data/ietf-yang-library:yang-library", &lib); err != nil {
+ log.Printf("yang-library: %v (ignored, no optional features)", err)
+ return NewCapabilities(nil)
+ }
+
+ // Build index: module name → set of YANG features advertised.
+ modFeatures := make(map[string]map[string]bool)
+ for _, ms := range lib.YangLibrary.ModuleSet {
+ for _, m := range ms.Module {
+ fs := make(map[string]bool, len(m.Feature))
+ for _, f := range m.Feature {
+ fs[f] = true
+ }
+ modFeatures[m.Name] = fs
+ }
+ }
+
+ result := make(map[string]bool, len(optionalFeatures))
+ for _, f := range optionalFeatures {
+ result[f.Name] = modFeatures[f.Module][f.Feature]
+ }
+
+ return NewCapabilities(result)
+}
diff --git a/src/webui/internal/handlers/capabilities_test.go b/src/webui/internal/handlers/capabilities_test.go
new file mode 100644
index 00000000..18841492
--- /dev/null
+++ b/src/webui/internal/handlers/capabilities_test.go
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/kernelkit/webui/internal/testutil"
+)
+
+func yangLibraryResponse(modules ...map[string]interface{}) map[string]interface{} {
+ mods := make([]interface{}, len(modules))
+ for i, m := range modules {
+ mods[i] = m
+ }
+ return map[string]interface{}{
+ "ietf-yang-library:yang-library": map[string]interface{}{
+ "module-set": []interface{}{
+ map[string]interface{}{"module": mods},
+ },
+ },
+ }
+}
+
+func module(name string, features ...string) map[string]interface{} {
+ m := map[string]interface{}{"name": name}
+ if len(features) > 0 {
+ m["feature"] = features
+ }
+ return m
+}
+
+func TestDetectCapabilities_NoModules(t *testing.T) {
+ mock := testutil.NewMockFetcher()
+ mock.SetResponse("/data/ietf-yang-library:yang-library", yangLibraryResponse())
+
+ caps := DetectCapabilities(context.Background(), mock)
+ if caps.Has("wifi") || caps.Has("containers") {
+ t.Errorf("expected no features, got wifi=%v containers=%v",
+ caps.Has("wifi"), caps.Has("containers"))
+ }
+}
+
+func TestDetectCapabilities_YangLibraryError(t *testing.T) {
+ mock := testutil.NewMockFetcher()
+ mock.SetError("/data/ietf-yang-library:yang-library", errors.New("unreachable"))
+
+ caps := DetectCapabilities(context.Background(), mock)
+ if caps.Has("wifi") || caps.Has("containers") {
+ t.Errorf("expected no features on error, got wifi=%v containers=%v",
+ caps.Has("wifi"), caps.Has("containers"))
+ }
+}
+
+func TestDetectCapabilities_ContainersModule(t *testing.T) {
+ mock := testutil.NewMockFetcher()
+ mock.SetResponse("/data/ietf-yang-library:yang-library",
+ yangLibraryResponse(
+ module("ietf-interfaces"),
+ module("infix-interfaces", "vlan-filtering", "containers"),
+ ))
+
+ caps := DetectCapabilities(context.Background(), mock)
+ if !caps.Has("containers") {
+ t.Error("expected containers=true")
+ }
+ if caps.Has("wifi") {
+ t.Error("expected wifi=false")
+ }
+}
+
+func TestDetectCapabilities_WiFiModule(t *testing.T) {
+ mock := testutil.NewMockFetcher()
+ mock.SetResponse("/data/ietf-yang-library:yang-library",
+ yangLibraryResponse(
+ module("ietf-interfaces"),
+ module("infix-interfaces", "vlan-filtering", "wifi"),
+ ))
+
+ caps := DetectCapabilities(context.Background(), mock)
+ if !caps.Has("wifi") {
+ t.Error("expected wifi=true")
+ }
+ if caps.Has("containers") {
+ t.Error("expected containers=false")
+ }
+}
+
+func TestDetectCapabilities_BothModules(t *testing.T) {
+ mock := testutil.NewMockFetcher()
+ mock.SetResponse("/data/ietf-yang-library:yang-library",
+ yangLibraryResponse(
+ module("ietf-interfaces"),
+ module("infix-interfaces", "vlan-filtering", "containers", "wifi"),
+ ))
+
+ caps := DetectCapabilities(context.Background(), mock)
+ if !caps.Has("wifi") || !caps.Has("containers") {
+ t.Errorf("expected both features, got wifi=%v containers=%v",
+ caps.Has("wifi"), caps.Has("containers"))
+ }
+}
+
+func TestCapabilitiesFromContext_NilReturnsEmpty(t *testing.T) {
+ caps := CapabilitiesFromContext(context.Background())
+ if caps == nil {
+ t.Fatal("expected non-nil Capabilities")
+ }
+ if caps.Has("wifi") || caps.Has("containers") {
+ t.Error("expected no features for empty context")
+ }
+}
+
+func TestCapabilitiesFromContext_RoundTrip(t *testing.T) {
+ orig := NewCapabilities(map[string]bool{"wifi": true, "containers": true})
+ ctx := ContextWithCapabilities(context.Background(), orig)
+ got := CapabilitiesFromContext(ctx)
+ if !got.Has("wifi") || !got.Has("containers") {
+ t.Errorf("expected wifi=true containers=true, got wifi=%v containers=%v",
+ got.Has("wifi"), got.Has("containers"))
+ }
+}
diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go
new file mode 100644
index 00000000..74018bc5
--- /dev/null
+++ b/src/webui/internal/handlers/common.go
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+
+ "github.com/kernelkit/webui/internal/security"
+)
+
+// PageData is the base template data passed to every page.
+type PageData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+}
+
+func csrfToken(ctx context.Context) string {
+ return security.TokenFromContext(ctx)
+}
diff --git a/src/webui/internal/handlers/containers.go b/src/webui/internal/handlers/containers.go
new file mode 100644
index 00000000..19807383
--- /dev/null
+++ b/src/webui/internal/handlers/containers.go
@@ -0,0 +1,186 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "net/url"
+ "sync"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// containerJSON matches the RESTCONF JSON for a single container entry.
+type containerJSON struct {
+ Name string `json:"name"`
+ Image string `json:"image"`
+ Running yangBool `json:"running"`
+ Status string `json:"status"`
+ Network struct {
+ Publish []string `json:"publish"`
+ } `json:"network"`
+ ResourceUsage containerResourceUsageJSON `json:"resource-usage"`
+ ResourceLimit containerResourceLimitJSON `json:"resource-limit"`
+}
+
+// containerResourceUsageJSON matches the RESTCONF JSON for resource-usage.
+type containerResourceUsageJSON struct {
+ Memory yangInt64 `json:"memory"` // KiB
+ CPU yangFloat64 `json:"cpu"` // percent
+}
+
+// containerResourceLimitJSON matches the RESTCONF JSON for resource-limit.
+type containerResourceLimitJSON struct {
+ Memory yangInt64 `json:"memory"` // KiB
+}
+
+// containerListWrapper wraps the top-level RESTCONF containers response.
+// The server returns the full "containers" object; the list lives inside it.
+type containerListWrapper struct {
+ Containers struct {
+ Container []containerJSON `json:"container"`
+ } `json:"infix-containers:containers"`
+}
+
+// containerResourceUsageWrapper wraps the RESTCONF resource-usage response.
+type containerResourceUsageWrapper struct {
+ ResourceUsage containerResourceUsageJSON `json:"infix-containers:resource-usage"`
+}
+
+// ContainerEntry holds display-ready data for a single container row.
+type ContainerEntry struct {
+ Name string
+ Image string
+ Status string
+ Running bool
+ CPUPct int
+ MemUsed string
+ MemLimit string
+ MemPct int
+ Uptime string
+ Ports []string
+}
+
+// containersData is the template data for the containers page.
+type containersData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+ Containers []ContainerEntry
+ Error string
+}
+
+// ContainersHandler serves the containers status page.
+type ContainersHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the containers list page.
+func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := containersData{
+ CsrfToken: csrfToken(r.Context()),
+ PageTitle: "Containers",
+ ActivePage: "containers",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ // Detach from the request context so that RESTCONF calls survive
+ // browser connection resets.
+ ctx := context.WithoutCancel(r.Context())
+
+ var listResp containerListWrapper
+ if err := h.RC.Get(ctx, "/data/infix-containers:containers", &listResp); err != nil {
+ log.Printf("restconf containers list: %v", err)
+ data.Error = "Could not fetch container information"
+ } else {
+ containers := listResp.Containers.Container
+
+ // Fetch resource-usage for each container concurrently.
+ usages := make([]containerResourceUsageJSON, len(containers))
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ for i, c := range containers {
+ wg.Add(1)
+ go func(idx int, name string) {
+ defer wg.Done()
+ path := fmt.Sprintf("/data/infix-containers:containers/container=%s/resource-usage",
+ url.PathEscape(name))
+ var w containerResourceUsageWrapper
+ if err := h.RC.Get(ctx, path, &w); err != nil {
+ log.Printf("restconf resource-usage %s: %v", name, err)
+ return
+ }
+ mu.Lock()
+ usages[idx] = w.ResourceUsage
+ mu.Unlock()
+ }(i, c.Name)
+ }
+ wg.Wait()
+
+ for i, c := range containers {
+ entry := ContainerEntry{
+ Name: c.Name,
+ Image: c.Image,
+ Status: c.Status,
+ Running: bool(c.Running),
+ Ports: c.Network.Publish,
+ }
+
+ // CPU usage — round to int.
+ entry.CPUPct = int(float64(usages[i].CPU) + 0.5)
+ if entry.CPUPct > 100 {
+ entry.CPUPct = 100
+ }
+
+ // Memory usage — resource-usage.memory is in KiB.
+ memUsedKiB := int64(usages[i].Memory)
+ if memUsedKiB > 0 {
+ entry.MemUsed = humanBytes(memUsedKiB * 1024)
+ }
+
+ // Memory limit — resource-limit.memory is in KiB.
+ memLimitKiB := int64(c.ResourceLimit.Memory)
+ if memLimitKiB > 0 {
+ entry.MemLimit = humanBytes(memLimitKiB * 1024)
+ if memUsedKiB > 0 {
+ entry.MemPct = int(float64(memUsedKiB) / float64(memLimitKiB) * 100)
+ if entry.MemPct > 100 {
+ entry.MemPct = 100
+ }
+ }
+ }
+
+ // Uptime: extract from status string (e.g., "Up About a minute", "Up 3 hours").
+ entry.Uptime = extractUptime(c.Status)
+
+ data.Containers = append(data.Containers, entry)
+ }
+ }
+
+ tmplName := "containers.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// extractUptime returns the uptime portion of a container status string.
+// E.g., "Up About a minute" → "About a minute", "Up 3 hours" → "3 hours",
+// "Exited (0) 2 hours ago" → "".
+func extractUptime(status string) string {
+ const prefix = "Up "
+ if len(status) > len(prefix) && status[:len(prefix)] == prefix {
+ return status[len(prefix):]
+ }
+ return ""
+}
diff --git a/src/webui/internal/handlers/containers_test.go b/src/webui/internal/handlers/containers_test.go
new file mode 100644
index 00000000..1f3ec95e
--- /dev/null
+++ b/src/webui/internal/handlers/containers_test.go
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+var minimalContainersTmpl = template.Must(template.New("containers.html").Parse(
+ `{{define "containers.html"}}count={{len .Containers}}{{end}}` +
+ `{{define "content"}}{{len .Containers}}{{end}}`,
+))
+
+func TestContainersOverview_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &ContainersHandler{Template: minimalContainersTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/containers", nil)
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestContainersOverview_HTMXPartial(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &ContainersHandler{Template: minimalContainersTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/containers", nil)
+ req.Header.Set("HX-Request", "true")
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body for htmx partial")
+ }
+}
diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go
new file mode 100644
index 00000000..de0f2248
--- /dev/null
+++ b/src/webui/internal/handlers/dashboard.go
@@ -0,0 +1,469 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// yangInt64 unmarshals a YANG numeric value that RESTCONF encodes as a
+// JSON string (e.g. "1024000") or, occasionally, as a bare number.
+type yangInt64 int64
+
+func (y *yangInt64) UnmarshalJSON(b []byte) error {
+ var s string
+ if json.Unmarshal(b, &s) == nil {
+ v, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return err
+ }
+ *y = yangInt64(v)
+ return nil
+ }
+ var v int64
+ if err := json.Unmarshal(b, &v); err != nil {
+ return err
+ }
+ *y = yangInt64(v)
+ return nil
+}
+
+// yangBool unmarshals a YANG boolean that RESTCONF may encode as a
+// JSON string ("true"/"false") or as a bare boolean.
+type yangBool bool
+
+func (y *yangBool) UnmarshalJSON(b []byte) error {
+ var s string
+ if json.Unmarshal(b, &s) == nil {
+ v, err := strconv.ParseBool(s)
+ if err != nil {
+ return err
+ }
+ *y = yangBool(v)
+ return nil
+ }
+ var v bool
+ if err := json.Unmarshal(b, &v); err != nil {
+ return err
+ }
+ *y = yangBool(v)
+ return nil
+}
+
+// yangFloat64 unmarshals a YANG decimal value that RESTCONF may encode
+// as a JSON string (e.g. "0.12") or as a bare number.
+type yangFloat64 float64
+
+func (y *yangFloat64) UnmarshalJSON(b []byte) error {
+ var s string
+ if json.Unmarshal(b, &s) == nil {
+ v, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return err
+ }
+ *y = yangFloat64(v)
+ return nil
+ }
+ var v float64
+ if err := json.Unmarshal(b, &v); err != nil {
+ return err
+ }
+ *y = yangFloat64(v)
+ return nil
+}
+
+// RESTCONF JSON structures for ietf-system:system-state.
+
+type systemStateWrapper struct {
+ SystemState systemState `json:"ietf-system:system-state"`
+}
+
+type systemState struct {
+ Platform platform `json:"platform"`
+ Clock clock `json:"clock"`
+ Software software `json:"infix-system:software"`
+ Resource resourceUsage `json:"infix-system:resource-usage"`
+}
+
+type platform struct {
+ OSName string `json:"os-name"`
+ OSVersion string `json:"os-version"`
+ Machine string `json:"machine"`
+}
+
+type clock struct {
+ BootDatetime string `json:"boot-datetime"`
+ CurrentDatetime string `json:"current-datetime"`
+}
+
+type software struct {
+ Booted string `json:"booted"`
+ Slot []softwareSlot `json:"slot"`
+}
+
+type softwareSlot struct {
+ Name string `json:"name"`
+ Version string `json:"version"`
+}
+
+type resourceUsage struct {
+ Memory memoryInfo `json:"memory"`
+ LoadAverage loadAverage `json:"load-average"`
+ Filesystem []filesystemFS `json:"filesystem"`
+}
+
+type memoryInfo struct {
+ Total yangInt64 `json:"total"`
+ Free yangInt64 `json:"free"`
+ Available yangInt64 `json:"available"`
+}
+
+type loadAverage struct {
+ Load1min yangFloat64 `json:"load-1min"`
+ Load5min yangFloat64 `json:"load-5min"`
+ Load15min yangFloat64 `json:"load-15min"`
+}
+
+type filesystemFS struct {
+ MountPoint string `json:"mount-point"`
+ Size yangInt64 `json:"size"`
+ Used yangInt64 `json:"used"`
+ Available yangInt64 `json:"available"`
+}
+
+// RESTCONF JSON structures for ietf-hardware:hardware.
+
+type hardwareWrapper struct {
+ Hardware struct {
+ Component []hwComponentJSON `json:"component"`
+ } `json:"ietf-hardware:hardware"`
+}
+
+type hwComponentJSON struct {
+ Name string `json:"name"`
+ Class string `json:"class"`
+ Description string `json:"description"`
+ Parent string `json:"parent"`
+ MfgName string `json:"mfg-name"`
+ ModelName string `json:"model-name"`
+ SerialNum string `json:"serial-num"`
+ HardwareRev string `json:"hardware-rev"`
+ PhysAddress string `json:"infix-hardware:phys-address"`
+ WiFiRadio *wifiRadioJSON `json:"infix-hardware:wifi-radio"`
+ SensorData *struct {
+ ValueType string `json:"value-type"`
+ Value yangInt64 `json:"value"`
+ ValueScale string `json:"value-scale"`
+ OperStatus string `json:"oper-status"`
+ } `json:"sensor-data"`
+ State *struct {
+ AdminState string `json:"admin-state"`
+ OperState string `json:"oper-state"`
+ } `json:"state"`
+}
+
+// Template data structures.
+
+type dashboardData struct {
+ CsrfToken string
+ Username string
+ ActivePage string
+ PageTitle string
+ Capabilities *Capabilities
+ Hostname string
+ OSName string
+ OSVersion string
+ Machine string
+ Firmware string
+ Uptime string
+ MemTotal int64
+ MemUsed int64
+ MemPercent int
+ MemClass string
+ Load1 string
+ Load5 string
+ Load15 string
+ CPUClass string
+ Disks []diskEntry
+ Board boardInfo
+ Sensors []sensorEntry
+ Error string
+}
+
+type boardInfo struct {
+ Model string
+ Manufacturer string
+ SerialNum string
+ HardwareRev string
+ BaseMAC string
+}
+
+type sensorEntry struct {
+ Name string
+ Value string
+ Type string // "temperature", "fan", "voltage", etc.
+}
+
+type diskEntry struct {
+ Mount string
+ Size string
+ Used string
+ Available string
+ Percent int
+}
+
+// DashboardHandler serves the main dashboard page.
+type DashboardHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Index renders the dashboard (GET /).
+func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
+ creds := restconf.CredentialsFromContext(r.Context())
+ data := dashboardData{
+ Username: creds.Username,
+ CsrfToken: csrfToken(r.Context()),
+ ActivePage: "dashboard",
+ PageTitle: "Dashboard",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ // Detach from the request context so that RESTCONF calls survive
+ // browser connection resets (common during login redirects).
+ // The RESTCONF client's own 10 s timeout still bounds each call.
+ ctx := context.WithoutCancel(r.Context())
+ var (
+ state systemStateWrapper
+ hw hardwareWrapper
+ sysConf struct {
+ System struct {
+ Hostname string `json:"hostname"`
+ } `json:"ietf-system:system"`
+ }
+ stateErr, hwErr, confErr error
+ wg sync.WaitGroup
+ )
+
+ wg.Add(3)
+ go func() {
+ defer wg.Done()
+ stateErr = h.RC.Get(ctx, "/data/ietf-system:system-state", &state)
+ }()
+ go func() {
+ defer wg.Done()
+ hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw)
+ }()
+ go func() {
+ defer wg.Done()
+ confErr = h.RC.Get(ctx, "/data/ietf-system:system", &sysConf)
+ }()
+ wg.Wait()
+
+ if stateErr != nil {
+ log.Printf("restconf system-state: %v", stateErr)
+ data.Error = "Could not fetch system information"
+ } else {
+ ss := state.SystemState
+ data.OSName = ss.Platform.OSName
+ data.OSVersion = ss.Platform.OSVersion
+ data.Machine = ss.Platform.Machine
+ data.Firmware = firmwareVersion(ss.Software)
+ data.Uptime = computeUptime(ss.Clock.BootDatetime, ss.Clock.CurrentDatetime)
+
+ total := int64(ss.Resource.Memory.Total)
+ avail := int64(ss.Resource.Memory.Available)
+ data.MemTotal = total / 1024 // KiB → MiB
+ data.MemUsed = (total - avail) / 1024
+ if total > 0 {
+ data.MemPercent = int(float64(total-avail) / float64(total) * 100)
+ }
+
+ switch {
+ case data.MemPercent >= 90:
+ data.MemClass = "is-crit"
+ case data.MemPercent >= 70:
+ data.MemClass = "is-warn"
+ default:
+ data.MemClass = ""
+ }
+
+ la := ss.Resource.LoadAverage
+ if la1 := float64(la.Load1min); la1 >= 0.9 {
+ data.CPUClass = "is-crit"
+ } else if la1 >= 0.7 {
+ data.CPUClass = "is-warn"
+ }
+
+ data.Load1 = strconv.FormatFloat(float64(la.Load1min), 'f', 2, 64)
+ data.Load5 = strconv.FormatFloat(float64(la.Load5min), 'f', 2, 64)
+ data.Load15 = strconv.FormatFloat(float64(la.Load15min), 'f', 2, 64)
+
+ for _, fs := range ss.Resource.Filesystem {
+ size := int64(fs.Size)
+ used := int64(fs.Used)
+ pct := 0
+ if size > 0 {
+ pct = int(float64(used) / float64(size) * 100)
+ }
+ data.Disks = append(data.Disks, diskEntry{
+ Mount: fs.MountPoint,
+ Size: humanKiB(size),
+ Used: humanKiB(used),
+ Available: humanKiB(int64(fs.Available)),
+ Percent: pct,
+ })
+ }
+ }
+
+ if hwErr != nil {
+ log.Printf("restconf hardware: %v", hwErr)
+ } else {
+ for _, c := range hw.Hardware.Component {
+ class := shortClass(c.Class)
+ if class == "chassis" {
+ data.Board = boardInfo{
+ Model: c.ModelName,
+ Manufacturer: c.MfgName,
+ SerialNum: c.SerialNum,
+ HardwareRev: c.HardwareRev,
+ BaseMAC: c.PhysAddress,
+ }
+ }
+ if c.SensorData != nil && c.SensorData.OperStatus == "ok" {
+ data.Sensors = append(data.Sensors, sensorEntry{
+ Name: c.Name,
+ Value: formatSensor(c.SensorData.ValueType, int64(c.SensorData.Value), c.SensorData.ValueScale),
+ Type: c.SensorData.ValueType,
+ })
+ }
+ }
+ }
+
+ if confErr != nil {
+ log.Printf("restconf system config: %v", confErr)
+ } else {
+ data.Hostname = sysConf.System.Hostname
+ }
+
+ tmplName := "dashboard.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// firmwareVersion returns the version string for the booted software slot.
+func firmwareVersion(sw software) string {
+ for _, slot := range sw.Slot {
+ if slot.Name == sw.Booted {
+ return slot.Version
+ }
+ }
+ return ""
+}
+
+// computeUptime returns a human-readable uptime string from RFC3339 timestamps.
+func computeUptime(boot, now string) string {
+ bootT, err := time.Parse(time.RFC3339, boot)
+ if err != nil {
+ return ""
+ }
+ nowT, err := time.Parse(time.RFC3339, now)
+ if err != nil {
+ nowT = time.Now()
+ }
+
+ d := nowT.Sub(bootT)
+ days := int(d.Hours()) / 24
+ hours := int(d.Hours()) % 24
+ mins := int(d.Minutes()) % 60
+
+ switch {
+ case days > 0:
+ return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
+ case hours > 0:
+ return fmt.Sprintf("%dh %dm", hours, mins)
+ default:
+ return fmt.Sprintf("%dm", mins)
+ }
+}
+
+// shortClass strips the YANG module prefix from a hardware class identity.
+func shortClass(full string) string {
+ if i := strings.LastIndex(full, ":"); i >= 0 {
+ return full[i+1:]
+ }
+ return full
+}
+
+// formatSensor converts a raw sensor value to a human-readable string,
+// matching the formatting used by cli_pretty.
+func formatSensor(valueType string, value int64, scale string) string {
+ v := float64(value)
+ switch scale {
+ case "milli":
+ v /= 1000
+ case "micro":
+ v /= 1000000
+ }
+ switch valueType {
+ case "celsius":
+ return fmt.Sprintf("%.1f\u00b0C", v)
+ case "rpm":
+ return fmt.Sprintf("%.0f RPM", v)
+ case "volts-DC":
+ return fmt.Sprintf("%.2f VDC", v)
+ case "amperes":
+ return fmt.Sprintf("%.2f A", v)
+ case "watts":
+ return fmt.Sprintf("%.2f W", v)
+ default:
+ return fmt.Sprintf("%.1f", v)
+ }
+}
+
+// humanBytes converts bytes to a human-readable string (B, KiB, MiB, GiB, TiB).
+func humanBytes(b int64) string {
+ v := float64(b)
+ for _, unit := range []string{"B", "KiB", "MiB", "GiB", "TiB"} {
+ if v < 1024 || unit == "TiB" {
+ if v == math.Trunc(v) {
+ return fmt.Sprintf("%.0f %s", v, unit)
+ }
+ return fmt.Sprintf("%.1f %s", v, unit)
+ }
+ v /= 1024
+ }
+ return fmt.Sprintf("%.1f PiB", v)
+}
+
+// humanKiB converts KiB to a human-readable string (K, M, G, T).
+func humanKiB(kib int64) string {
+ v := float64(kib)
+ for _, unit := range []string{"K", "M", "G", "T"} {
+ if v < 1024 || unit == "T" {
+ if v == math.Trunc(v) {
+ return fmt.Sprintf("%.0f%s", v, unit)
+ }
+ return fmt.Sprintf("%.1f%s", v, unit)
+ }
+ v /= 1024
+ }
+ return fmt.Sprintf("%.1fP", v)
+}
diff --git a/src/webui/internal/handlers/dashboard_test.go b/src/webui/internal/handlers/dashboard_test.go
new file mode 100644
index 00000000..d996ad84
--- /dev/null
+++ b/src/webui/internal/handlers/dashboard_test.go
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+var minimalDashTmpl = template.Must(template.New("dashboard.html").Parse(
+ `{{define "dashboard.html"}}hostname={{.Hostname}} error={{.Error}}{{end}}` +
+ `{{define "content"}}{{.Hostname}}{{end}}`,
+))
+
+func TestDashboardIndex_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &DashboardHandler{Template: minimalDashTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "testuser",
+ Password: "testpass",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Index(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d", w.Code)
+ }
+}
+
+func TestDashboardIndex_ShowsErrorOnRESTCONFFailure(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &DashboardHandler{Template: minimalDashTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "tok")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Index(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestDashboardIndex_HTMXPartial(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &DashboardHandler{Template: minimalDashTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ req.Header.Set("HX-Request", "true")
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "tok")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Index(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+}
diff --git a/src/webui/internal/handlers/dhcp.go b/src/webui/internal/handlers/dhcp.go
new file mode 100644
index 00000000..4c81731d
--- /dev/null
+++ b/src/webui/internal/handlers/dhcp.go
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// ─── DHCP types ──────────────────────────────────────────────────────────────
+
+// DHCPLease is a single active DHCP lease.
+type DHCPLease struct {
+ Address string
+ MAC string
+ Hostname string
+ Expires string // relative or "never"
+ ClientID string
+}
+
+// DHCPStats holds DHCP packet counters.
+type DHCPStats struct {
+ InDiscoveries int64
+ InRequests int64
+ InReleases int64
+ OutOffers int64
+ OutAcks int64
+ OutNaks int64
+}
+
+// DHCPData is the parsed DHCP server state.
+type DHCPData struct {
+ Enabled bool
+ Leases []DHCPLease
+ Stats DHCPStats
+}
+
+// ─── Page data ───────────────────────────────────────────────────────────────
+
+type dhcpPageData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+ DHCP *DHCPData
+ Error string
+}
+
+// ─── Handler ─────────────────────────────────────────────────────────────────
+
+// DHCPHandler serves the DHCP status page.
+type DHCPHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the DHCP page (GET /dhcp).
+func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := dhcpPageData{
+ CsrfToken: csrfToken(r.Context()),
+ PageTitle: "DHCP Server",
+ ActivePage: "dhcp",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ ctx := context.WithoutCancel(r.Context())
+
+ var raw struct {
+ DHCP struct {
+ Enabled yangBool `json:"enabled"`
+ Leases struct {
+ Lease []struct {
+ Address string `json:"address"`
+ PhysAddr string `json:"phys-address"`
+ Hostname string `json:"hostname"`
+ Expires string `json:"expires"`
+ ClientID string `json:"client-id"`
+ } `json:"lease"`
+ } `json:"leases"`
+ Statistics struct {
+ OutOffers yangInt64 `json:"out-offers"`
+ OutAcks yangInt64 `json:"out-acks"`
+ OutNaks yangInt64 `json:"out-naks"`
+ InDiscoveries yangInt64 `json:"in-discovers"`
+ InRequests yangInt64 `json:"in-requests"`
+ InReleases yangInt64 `json:"in-releases"`
+ } `json:"statistics"`
+ } `json:"infix-dhcp-server:dhcp-server"`
+ }
+ if err := h.RC.Get(ctx, "/data/infix-dhcp-server:dhcp-server", &raw); err != nil {
+ log.Printf("restconf dhcp-server: %v", err)
+ data.Error = "Failed to fetch DHCP data"
+ } else {
+ d := raw.DHCP
+ dhcp := &DHCPData{
+ Enabled: bool(d.Enabled),
+ Stats: DHCPStats{
+ InDiscoveries: int64(d.Statistics.InDiscoveries),
+ InRequests: int64(d.Statistics.InRequests),
+ InReleases: int64(d.Statistics.InReleases),
+ OutOffers: int64(d.Statistics.OutOffers),
+ OutAcks: int64(d.Statistics.OutAcks),
+ OutNaks: int64(d.Statistics.OutNaks),
+ },
+ }
+ for _, l := range d.Leases.Lease {
+ dhcp.Leases = append(dhcp.Leases, DHCPLease{
+ Address: l.Address,
+ MAC: l.PhysAddr,
+ Hostname: l.Hostname,
+ Expires: formatDHCPExpiry(l.Expires),
+ ClientID: l.ClientID,
+ })
+ }
+ data.DHCP = dhcp
+ }
+
+ tmplName := "dhcp.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+// formatDHCPExpiry converts a YANG date-and-time or "never" string to a
+// human-readable relative expiry string.
+func formatDHCPExpiry(s string) string {
+ if s == "" || s == "never" {
+ return "never"
+ }
+ t, err := time.Parse(time.RFC3339, s)
+ if err != nil {
+ // Try without timezone
+ t, err = time.Parse("2006-01-02T15:04:05", s)
+ if err != nil {
+ return s
+ }
+ }
+ d := time.Until(t)
+ if d < 0 {
+ d = -d
+ return "expired " + formatRelDuration(d) + " ago"
+ }
+ return "in " + formatRelDuration(d)
+}
+
+// formatRelDuration formats a time.Duration in a compact human-readable form.
+func formatRelDuration(d time.Duration) string {
+ switch {
+ case d >= 24*time.Hour:
+ return fmt.Sprintf("%dd", int(d.Hours())/24)
+ case d >= time.Hour:
+ return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
+ case d >= time.Minute:
+ return fmt.Sprintf("%dm", int(d.Minutes()))
+ default:
+ return fmt.Sprintf("%ds", int(d.Seconds()))
+ }
+}
diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go
new file mode 100644
index 00000000..64c48582
--- /dev/null
+++ b/src/webui/internal/handlers/firewall.go
@@ -0,0 +1,281 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "html/template"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// RESTCONF JSON structures for infix-firewall:firewall.
+
+type firewallWrapper struct {
+ Firewall firewallJSON `json:"infix-firewall:firewall"`
+}
+
+type firewallJSON struct {
+ Enabled *yangBool `json:"enabled"` // YANG default: true; nil means enabled
+ Default string `json:"default"`
+ Logging string `json:"logging"`
+ Lockdown yangBool `json:"lockdown"`
+ Zone []zoneJSON `json:"zone"`
+ Policy []policyJSON `json:"policy"`
+}
+
+type zoneJSON struct {
+ Name string `json:"name"`
+ Action string `json:"action"`
+ Interface []string `json:"interface"`
+ Network []string `json:"network"`
+ Service []string `json:"service"`
+ PortForward []portForwardJSON `json:"port-forward"`
+ Immutable bool `json:"immutable"`
+}
+
+type portForwardJSON struct {
+ Port string `json:"port"`
+ Protocol string `json:"protocol"`
+ ToAddr string `json:"to-addr"`
+ ToPort string `json:"to-port"`
+}
+
+type policyJSON struct {
+ Name string `json:"name"`
+ Action string `json:"action"`
+ Priority yangInt64 `json:"priority"`
+ Ingress []string `json:"ingress"`
+ Egress []string `json:"egress"`
+ Service []string `json:"service"`
+ Masquerade bool `json:"masquerade"`
+ Immutable bool `json:"immutable"`
+}
+
+// Template data structures.
+
+type firewallData struct {
+ CsrfToken string
+ Username string
+ ActivePage string
+ PageTitle string
+ Capabilities *Capabilities
+ Enabled bool
+ EnabledText string
+ DefaultZone string
+ Lockdown bool
+ Logging string
+ ZoneNames []string
+ Matrix []matrixRow
+ Zones []zoneEntry
+ Policies []policyEntry
+ Error string
+}
+
+type matrixRow struct {
+ Zone string
+ Cells []matrixCell
+}
+
+type matrixCell struct {
+ Class string
+ Symbol string
+}
+
+type zoneEntry struct {
+ Name string
+ Action string
+ Interfaces string
+ Networks string
+ Services string
+}
+
+type policyEntry struct {
+ Name string
+ Action string
+ Priority int64
+ Ingress string
+ Egress string
+ Services string
+ Masquerade bool
+}
+
+// FirewallHandler serves the firewall overview page.
+type FirewallHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the firewall overview (GET /firewall).
+func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ creds := restconf.CredentialsFromContext(r.Context())
+ data := firewallData{
+ Username: creds.Username,
+ CsrfToken: csrfToken(r.Context()),
+ ActivePage: "firewall",
+ PageTitle: "Firewall",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ var fw firewallWrapper
+ if err := h.RC.Get(r.Context(), "/data/infix-firewall:firewall", &fw); err != nil {
+ log.Printf("restconf firewall: %v", err)
+ data.Error = "Could not fetch firewall configuration"
+ } else {
+ f := fw.Firewall
+ data.Enabled = f.Enabled == nil || bool(*f.Enabled)
+ if data.Enabled {
+ data.EnabledText = "Active"
+ } else {
+ data.EnabledText = "Inactive"
+ }
+ data.DefaultZone = f.Default
+ data.Lockdown = bool(f.Lockdown)
+ data.Logging = f.Logging
+ if data.Logging == "" {
+ data.Logging = "off"
+ }
+
+ for _, z := range f.Zone {
+ data.Zones = append(data.Zones, zoneEntry{
+ Name: z.Name,
+ Action: z.Action,
+ Interfaces: strings.Join(z.Interface, ", "),
+ Networks: strings.Join(z.Network, ", "),
+ Services: strings.Join(z.Service, ", "),
+ })
+ }
+
+ for _, p := range f.Policy {
+ data.Policies = append(data.Policies, policyEntry{
+ Name: p.Name,
+ Action: p.Action,
+ Priority: int64(p.Priority),
+ Ingress: strings.Join(p.Ingress, ", "),
+ Egress: strings.Join(p.Egress, ", "),
+ Services: strings.Join(p.Service, ", "),
+ Masquerade: p.Masquerade,
+ })
+ }
+
+ sort.Slice(data.Policies, func(i, j int) bool {
+ return data.Policies[i].Priority < data.Policies[j].Priority
+ })
+
+ data.ZoneNames, data.Matrix = buildMatrix(f.Zone, f.Policy)
+ }
+
+ tmplName := "firewall.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// buildMatrix creates the zone-to-zone traffic flow matrix.
+// Zones are listed along both axes with HOST (the device itself) prepended.
+// Each cell shows whether traffic from the row zone to the column zone
+// is allowed, denied, or conditional.
+func buildMatrix(zones []zoneJSON, policies []policyJSON) ([]string, []matrixRow) {
+ if len(zones) == 0 {
+ return nil, nil
+ }
+
+ names := []string{"HOST"}
+ zoneAction := map[string]string{}
+ for _, z := range zones {
+ names = append(names, z.Name)
+ zoneAction[z.Name] = z.Action
+ }
+
+ // Sort policies by priority for evaluation.
+ sorted := make([]policyJSON, len(policies))
+ copy(sorted, policies)
+ sort.Slice(sorted, func(i, j int) bool {
+ return int64(sorted[i].Priority) < int64(sorted[j].Priority)
+ })
+
+ rows := make([]matrixRow, len(names))
+ for i, src := range names {
+ rows[i] = matrixRow{Zone: src, Cells: make([]matrixCell, len(names))}
+ for j, dst := range names {
+ switch {
+ case src == "HOST" && dst == "HOST":
+ rows[i].Cells[j] = matrixCell{Class: "matrix-self", Symbol: "\u2014"}
+ case src == "HOST":
+ // Traffic originating from the device is always allowed.
+ rows[i].Cells[j] = matrixCell{Class: "matrix-allow", Symbol: "\u2713"}
+ case src == dst:
+ rows[i].Cells[j] = matrixCell{Class: "matrix-self", Symbol: "\u2014"}
+ case dst == "HOST":
+ // Input to device: governed by zone action + policies.
+ rows[i].Cells[j] = zoneToHost(src, zoneAction, sorted)
+ default:
+ // Forwarding between zones: governed by policies.
+ rows[i].Cells[j] = evalForward(src, dst, sorted)
+ }
+ }
+ }
+
+ return names, rows
+}
+
+// zoneToHost determines traffic flow from a zone to the device (HOST).
+// Policies are checked first; if none gives a verdict, the zone's
+// default action is used.
+func zoneToHost(zone string, zoneAction map[string]string, policies []policyJSON) matrixCell {
+ if v := evalPolicies(zone, "HOST", policies); v != "" {
+ return makeCell(v)
+ }
+ if zoneAction[zone] == "accept" {
+ return matrixCell{Class: "matrix-allow", Symbol: "\u2713"}
+ }
+ return matrixCell{Class: "matrix-deny", Symbol: "\u2717"}
+}
+
+// evalForward determines traffic flow between two different zones.
+func evalForward(src, dst string, policies []policyJSON) matrixCell {
+ if v := evalPolicies(src, dst, policies); v != "" {
+ return makeCell(v)
+ }
+ return matrixCell{Class: "matrix-deny", Symbol: "\u2717"}
+}
+
+// evalPolicies walks the sorted policy list and returns the first terminal
+// verdict (accept/reject/drop) for traffic from src to dst.
+// "continue" policies are skipped (they don't produce a final verdict).
+func evalPolicies(src, dst string, policies []policyJSON) string {
+ for _, p := range policies {
+ if !matchesZone(src, p.Ingress) || !matchesZone(dst, p.Egress) {
+ continue
+ }
+ if p.Action == "continue" {
+ continue
+ }
+ return p.Action
+ }
+ return ""
+}
+
+// matchesZone checks whether zone appears in list, treating "ANY" as a wildcard.
+func matchesZone(zone string, list []string) bool {
+ for _, z := range list {
+ if z == zone || z == "ANY" {
+ return true
+ }
+ }
+ return false
+}
+
+func makeCell(verdict string) matrixCell {
+ if verdict == "accept" {
+ return matrixCell{Class: "matrix-allow", Symbol: "\u2713"}
+ }
+ return matrixCell{Class: "matrix-deny", Symbol: "\u2717"}
+}
diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go
new file mode 100644
index 00000000..58687bc5
--- /dev/null
+++ b/src/webui/internal/handlers/interfaces.go
@@ -0,0 +1,988 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "log"
+ "math"
+ "net/http"
+ "sort"
+ "strings"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// RESTCONF JSON structures for ietf-interfaces:interfaces.
+
+type interfacesWrapper struct {
+ Interfaces struct {
+ Interface []ifaceJSON `json:"interface"`
+ } `json:"ietf-interfaces:interfaces"`
+}
+
+type ifaceJSON struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ OperStatus string `json:"oper-status"`
+ PhysAddress string `json:"phys-address"`
+ IfIndex int `json:"if-index"`
+ IPv4 *ipCfg `json:"ietf-ip:ipv4"`
+ IPv6 *ipCfg `json:"ietf-ip:ipv6"`
+ Statistics *ifaceStats `json:"statistics"`
+ Ethernet *ethernetJSON `json:"ieee802-ethernet-interface:ethernet"`
+ BridgePort *bridgePortJSON `json:"infix-interfaces:bridge-port"`
+ WiFi *wifiJSON `json:"infix-interfaces:wifi"`
+ WireGuard *wireGuardJSON `json:"infix-interfaces:wireguard"`
+}
+
+type bridgePortJSON struct {
+ Bridge string `json:"bridge"`
+ STP *struct {
+ CIST *struct {
+ State string `json:"state"`
+ } `json:"cist"`
+ } `json:"stp"`
+}
+
+type wifiJSON struct {
+ Radio string `json:"radio"`
+ AccessPoint *wifiAPJSON `json:"access-point"`
+ Station *wifiStationJSON `json:"station"`
+}
+
+type wifiAPJSON struct {
+ SSID string `json:"ssid"`
+ Stations struct {
+ Station []wifiStaJSON `json:"station"`
+ } `json:"stations"`
+}
+
+type wifiStaJSON struct {
+ MACAddress string `json:"mac-address"`
+ SignalStrength *int `json:"signal-strength"`
+ ConnectedTime yangInt64 `json:"connected-time"`
+ RxPackets yangInt64 `json:"rx-packets"`
+ TxPackets yangInt64 `json:"tx-packets"`
+ RxBytes yangInt64 `json:"rx-bytes"`
+ TxBytes yangInt64 `json:"tx-bytes"`
+ RxSpeed yangInt64 `json:"rx-speed"`
+ TxSpeed yangInt64 `json:"tx-speed"`
+}
+
+type wifiStationJSON struct {
+ SSID string `json:"ssid"`
+ SignalStrength *int `json:"signal-strength"`
+ RxSpeed yangInt64 `json:"rx-speed"`
+ TxSpeed yangInt64 `json:"tx-speed"`
+ ScanResults []wifiScanResultJSON `json:"scan-results"`
+}
+
+type wifiScanResultJSON struct {
+ SSID string `json:"ssid"`
+ BSSID string `json:"bssid"`
+ SignalStrength *int `json:"signal-strength"`
+ Channel int `json:"channel"`
+ Encryption []string `json:"encryption"`
+}
+
+// WiFi radio survey RESTCONF structures (from ietf-hardware:hardware).
+
+type wifiRadioJSON struct {
+ Survey *wifiSurveyJSON `json:"survey"`
+}
+
+type wifiSurveyJSON struct {
+ Channel []surveyChanJSON `json:"channel"`
+}
+
+type surveyChanJSON struct {
+ Frequency int `json:"frequency"`
+ InUse yangBool `json:"in-use"`
+ Noise int `json:"noise"`
+ ActiveTime int `json:"active-time"`
+ BusyTime int `json:"busy-time"`
+ ReceiveTime int `json:"receive-time"`
+ TransmitTime int `json:"transmit-time"`
+}
+
+type wireGuardJSON struct {
+ PeerStatus *struct {
+ Peer []wgPeerJSON `json:"peer"`
+ } `json:"peer-status"`
+}
+
+type wgPeerJSON struct {
+ PublicKey string `json:"public-key"`
+ ConnectionStatus string `json:"connection-status"`
+ EndpointAddress string `json:"endpoint-address"`
+ EndpointPort int `json:"endpoint-port"`
+ LatestHandshake string `json:"latest-handshake"`
+ Transfer *struct {
+ TxBytes yangInt64 `json:"tx-bytes"`
+ RxBytes yangInt64 `json:"rx-bytes"`
+ } `json:"transfer"`
+}
+
+type ipCfg struct {
+ Address []ipAddr `json:"address"`
+ MTU int `json:"mtu"`
+}
+
+type ipAddr struct {
+ IP string `json:"ip"`
+ PrefixLength yangInt64 `json:"prefix-length"`
+ Origin string `json:"origin"`
+}
+
+type ifaceStats struct {
+ InOctets yangInt64 `json:"in-octets"`
+ OutOctets yangInt64 `json:"out-octets"`
+ InUnicastPkts yangInt64 `json:"in-unicast-pkts"`
+ InBroadcastPkts yangInt64 `json:"in-broadcast-pkts"`
+ InMulticastPkts yangInt64 `json:"in-multicast-pkts"`
+ InDiscards yangInt64 `json:"in-discards"`
+ InErrors yangInt64 `json:"in-errors"`
+ OutUnicastPkts yangInt64 `json:"out-unicast-pkts"`
+ OutBroadcastPkts yangInt64 `json:"out-broadcast-pkts"`
+ OutMulticastPkts yangInt64 `json:"out-multicast-pkts"`
+ OutDiscards yangInt64 `json:"out-discards"`
+ OutErrors yangInt64 `json:"out-errors"`
+}
+
+type ethernetJSON struct {
+ Speed string `json:"speed"`
+ Duplex string `json:"duplex"`
+ AutoNegotiation *struct {
+ Enable bool `json:"enable"`
+ } `json:"auto-negotiation"`
+ Statistics *struct {
+ Frame *ethFrameStats `json:"frame"`
+ } `json:"statistics"`
+}
+
+type ethFrameStats struct {
+ InTotalPkts yangInt64 `json:"in-total-pkts"`
+ InTotalOctets yangInt64 `json:"in-total-octets"`
+ InGoodPkts yangInt64 `json:"in-good-pkts"`
+ InGoodOctets yangInt64 `json:"in-good-octets"`
+ InBroadcast yangInt64 `json:"in-broadcast"`
+ InMulticast yangInt64 `json:"in-multicast"`
+ InErrorFCS yangInt64 `json:"in-error-fcs"`
+ InErrorUndersize yangInt64 `json:"in-error-undersize"`
+ InErrorOversize yangInt64 `json:"in-error-oversize"`
+ InErrorMACInternal yangInt64 `json:"in-error-mac-internal"`
+ OutTotalPkts yangInt64 `json:"out-total-pkts"`
+ OutTotalOctets yangInt64 `json:"out-total-octets"`
+ OutGoodPkts yangInt64 `json:"out-good-pkts"`
+ OutGoodOctets yangInt64 `json:"out-good-octets"`
+ OutBroadcast yangInt64 `json:"out-broadcast"`
+ OutMulticast yangInt64 `json:"out-multicast"`
+}
+
+// Template data structures.
+
+type interfacesData struct {
+ CsrfToken string
+ Username string
+ ActivePage string
+ Capabilities *Capabilities
+ PageTitle string
+ Interfaces []ifaceEntry
+ Error string
+}
+
+type ifaceEntry struct {
+ Indent string // tree prefix for bridge/LAG members
+ Name string
+ Type string
+ Status string
+ StatusUp bool
+ PhysAddr string
+ Addresses []addrEntry
+ Detail string // extra info: wifi AP, wireguard peers, etc.
+ RxBytes string
+ TxBytes string
+}
+
+type addrEntry struct {
+ Address string
+ Origin string
+}
+
+// InterfacesHandler serves the interfaces pages.
+type InterfacesHandler struct {
+ Template *template.Template
+ DetailTemplate *template.Template
+ CountersTemplate *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the interfaces page (GET /interfaces).
+func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ creds := restconf.CredentialsFromContext(r.Context())
+ data := interfacesData{
+ Username: creds.Username,
+ CsrfToken: csrfToken(r.Context()),
+ ActivePage: "interfaces",
+ PageTitle: "Interfaces",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ var ifaces interfacesWrapper
+ if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &ifaces); err != nil {
+ log.Printf("restconf interfaces: %v", err)
+ data.Error = "Could not fetch interface information"
+ } else {
+ data.Interfaces = buildIfaceList(ifaces.Interfaces.Interface)
+ }
+
+ tmplName := "interfaces.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// prettyIfType converts a YANG interface type identity to the display
+// name used by the Infix CLI (cli_pretty).
+func prettyIfType(full string) string {
+ pretty := map[string]string{
+ "bridge": "bridge",
+ "dummy": "dummy",
+ "ethernet": "ethernet",
+ "gre": "gre",
+ "gretap": "gretap",
+ "vxlan": "vxlan",
+ "wireguard": "wireguard",
+ "lag": "lag",
+ "loopback": "loopback",
+ "veth": "veth",
+ "vlan": "vlan",
+ "wifi": "wifi",
+ "other": "other",
+ "ethernetCsmacd": "ethernet",
+ "softwareLoopback": "loopback",
+ "l2vlan": "vlan",
+ "ieee8023adLag": "lag",
+ "ieee80211": "wifi",
+ "ilan": "veth",
+ }
+
+ if i := strings.LastIndex(full, ":"); i >= 0 {
+ full = full[i+1:]
+ }
+ if name, ok := pretty[full]; ok {
+ return name
+ }
+ return full
+}
+
+// buildIfaceList converts raw RESTCONF interface data into a flat,
+// hierarchically ordered display list matching the cli_pretty style.
+// Bridge members are grouped under their parent with tree indicators.
+func buildIfaceList(raw []ifaceJSON) []ifaceEntry {
+ byName := map[string]*ifaceJSON{}
+ children := map[string][]string{}
+ childSet := map[string]bool{}
+
+ for i := range raw {
+ iface := &raw[i]
+ byName[iface.Name] = iface
+ if iface.BridgePort != nil && iface.BridgePort.Bridge != "" {
+ parent := iface.BridgePort.Bridge
+ children[parent] = append(children[parent], iface.Name)
+ childSet[iface.Name] = true
+ }
+ }
+
+ var result []ifaceEntry
+
+ for _, iface := range raw {
+ if childSet[iface.Name] {
+ continue
+ }
+
+ result = append(result, makeIfaceEntry(iface, ""))
+
+ members := children[iface.Name]
+ for i, childName := range members {
+ child, ok := byName[childName]
+ if !ok {
+ continue
+ }
+ prefix := "\u251c\u00a0" // ├
+ if i == len(members)-1 {
+ prefix = "\u2514\u00a0" // └
+ }
+ e := makeIfaceEntry(*child, prefix)
+ if child.BridgePort != nil && child.BridgePort.STP != nil &&
+ child.BridgePort.STP.CIST != nil && child.BridgePort.STP.CIST.State != "" {
+ e.Status = child.BridgePort.STP.CIST.State
+ e.StatusUp = e.Status == "forwarding"
+ }
+ result = append(result, e)
+ }
+ }
+
+ return result
+}
+
+func makeIfaceEntry(iface ifaceJSON, indent string) ifaceEntry {
+ e := ifaceEntry{
+ Indent: indent,
+ Name: iface.Name,
+ Type: prettyIfType(iface.Type),
+ Status: iface.OperStatus,
+ StatusUp: iface.OperStatus == "up",
+ PhysAddr: iface.PhysAddress,
+ }
+
+ if iface.Statistics != nil {
+ e.RxBytes = humanBytes(int64(iface.Statistics.InOctets))
+ e.TxBytes = humanBytes(int64(iface.Statistics.OutOctets))
+ }
+
+ if iface.IPv4 != nil {
+ for _, a := range iface.IPv4.Address {
+ e.Addresses = append(e.Addresses, addrEntry{
+ Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)),
+ Origin: a.Origin,
+ })
+ }
+ }
+ if iface.IPv6 != nil {
+ for _, a := range iface.IPv6.Address {
+ e.Addresses = append(e.Addresses, addrEntry{
+ Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)),
+ Origin: a.Origin,
+ })
+ }
+ }
+
+ if iface.WiFi != nil {
+ if ap := iface.WiFi.AccessPoint; ap != nil {
+ n := len(ap.Stations.Station)
+ e.Detail = fmt.Sprintf("AP, ssid: %s, stations: %d", ap.SSID, n)
+ } else if st := iface.WiFi.Station; st != nil {
+ e.Detail = fmt.Sprintf("Station, ssid: %s", st.SSID)
+ }
+ }
+
+ if wg := iface.WireGuard; wg != nil && wg.PeerStatus != nil {
+ total := len(wg.PeerStatus.Peer)
+ up := 0
+ for _, p := range wg.PeerStatus.Peer {
+ if p.ConnectionStatus == "up" {
+ up++
+ }
+ }
+ e.Detail = fmt.Sprintf("%d peers (%d up)", total, up)
+ }
+
+ return e
+}
+
+// Template data for the interface detail page.
+type ifaceDetailData struct {
+ CsrfToken string
+ Username string
+ ActivePage string
+ Capabilities *Capabilities
+ PageTitle string
+ Name string
+ Type string
+ Status string
+ StatusUp bool
+ PhysAddr string
+ IfIndex int
+ MTU int
+ Speed string
+ Duplex string
+ AutoNeg string
+ Addresses []addrEntry
+ WiFiMode string // "Access Point" or "Station"
+ WiFiSSID string
+ WiFiSignal string
+ WiFiRxSpeed string
+ WiFiTxSpeed string
+ WiFiStationCount string // e.g. "3" for AP mode
+ WGPeerSummary string // e.g. "3 peers (2 up)"
+ Counters ifaceCounters
+ EthFrameStats []kvEntry
+ WGPeers []wgPeerEntry
+ WiFiStations []wifiStaEntry
+ ScanResults []wifiScanEntry
+}
+
+type ifaceCounters struct {
+ RxBytes string
+ RxUnicast string
+ RxBroadcast string
+ RxMulticast string
+ RxDiscards string
+ RxErrors string
+ TxBytes string
+ TxUnicast string
+ TxBroadcast string
+ TxMulticast string
+ TxDiscards string
+ TxErrors string
+}
+
+type kvEntry struct {
+ Key string
+ Value string
+}
+
+type wgPeerEntry struct {
+ PublicKey string
+ Status string
+ StatusUp bool
+ Endpoint string
+ Handshake string
+ TxBytes string
+ RxBytes string
+}
+
+type wifiStaEntry struct {
+ MAC string
+ Signal string
+ SignalCSS string // "excellent", "good", "poor", "bad"
+ Time string
+ RxPkts string
+ TxPkts string
+ RxBytes string
+ TxBytes string
+ RxSpeed string
+ TxSpeed string
+}
+
+type wifiScanEntry struct {
+ SSID string
+ BSSID string
+ Signal string
+ SignalCSS string
+ Channel string
+ Encryption string
+}
+
+// fetchInterface retrieves a single interface by name from RESTCONF.
+func (h *InterfacesHandler) fetchInterface(r *http.Request, name string) (*ifaceJSON, error) {
+ var all interfacesWrapper
+ if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &all); err != nil {
+ return nil, err
+ }
+ for i := range all.Interfaces.Interface {
+ if all.Interfaces.Interface[i].Name == name {
+ return &all.Interfaces.Interface[i], nil
+ }
+ }
+ return nil, fmt.Errorf("interface %q not found", name)
+}
+
+// buildDetailData converts raw RESTCONF interface data to template data.
+func buildDetailData(username, csrf string, iface *ifaceJSON) ifaceDetailData {
+ d := ifaceDetailData{
+ Username: username,
+ CsrfToken: csrf,
+ Name: iface.Name,
+ Type: prettyIfType(iface.Type),
+ Status: iface.OperStatus,
+ StatusUp: iface.OperStatus == "up",
+ PhysAddr: iface.PhysAddress,
+ IfIndex: iface.IfIndex,
+ }
+
+ if iface.IPv4 != nil {
+ if iface.IPv4.MTU > 0 {
+ d.MTU = iface.IPv4.MTU
+ }
+ for _, a := range iface.IPv4.Address {
+ d.Addresses = append(d.Addresses, addrEntry{
+ Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)),
+ Origin: a.Origin,
+ })
+ }
+ }
+ if iface.IPv6 != nil {
+ for _, a := range iface.IPv6.Address {
+ d.Addresses = append(d.Addresses, addrEntry{
+ Address: fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)),
+ Origin: a.Origin,
+ })
+ }
+ }
+
+ if iface.Ethernet != nil {
+ d.Speed = prettySpeed(iface.Ethernet.Speed)
+ d.Duplex = iface.Ethernet.Duplex
+ if iface.Ethernet.AutoNegotiation != nil {
+ if iface.Ethernet.AutoNegotiation.Enable {
+ d.AutoNeg = "on"
+ } else {
+ d.AutoNeg = "off"
+ }
+ }
+ if iface.Ethernet.Statistics != nil && iface.Ethernet.Statistics.Frame != nil {
+ d.EthFrameStats = buildEthFrameStats(iface.Ethernet.Statistics.Frame)
+ }
+ }
+
+ if iface.Statistics != nil {
+ d.Counters = buildCounters(iface.Statistics)
+ }
+
+ if iface.WiFi != nil {
+ if ap := iface.WiFi.AccessPoint; ap != nil {
+ d.WiFiMode = "Access Point"
+ d.WiFiSSID = ap.SSID
+ d.WiFiStationCount = fmt.Sprintf("%d", len(ap.Stations.Station))
+ for _, s := range ap.Stations.Station {
+ d.WiFiStations = append(d.WiFiStations, buildWifiStaEntry(s))
+ }
+ } else if st := iface.WiFi.Station; st != nil {
+ d.WiFiMode = "Station"
+ d.WiFiSSID = st.SSID
+ if st.SignalStrength != nil {
+ d.WiFiSignal = fmt.Sprintf("%d dBm", *st.SignalStrength)
+ }
+ if st.RxSpeed > 0 {
+ d.WiFiRxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.RxSpeed)/10)
+ }
+ if st.TxSpeed > 0 {
+ d.WiFiTxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.TxSpeed)/10)
+ }
+ for _, sr := range st.ScanResults {
+ d.ScanResults = append(d.ScanResults, buildWifiScanEntry(sr))
+ }
+ }
+ }
+
+ if wg := iface.WireGuard; wg != nil && wg.PeerStatus != nil {
+ total := len(wg.PeerStatus.Peer)
+ up := 0
+ for _, p := range wg.PeerStatus.Peer {
+ pe := wgPeerEntry{
+ PublicKey: p.PublicKey,
+ Status: p.ConnectionStatus,
+ StatusUp: p.ConnectionStatus == "up",
+ }
+ if p.EndpointAddress != "" {
+ pe.Endpoint = fmt.Sprintf("%s:%d", p.EndpointAddress, p.EndpointPort)
+ }
+ if p.LatestHandshake != "" {
+ pe.Handshake = p.LatestHandshake
+ }
+ if p.Transfer != nil {
+ pe.TxBytes = humanBytes(int64(p.Transfer.TxBytes))
+ pe.RxBytes = humanBytes(int64(p.Transfer.RxBytes))
+ }
+ if p.ConnectionStatus == "up" {
+ up++
+ }
+ d.WGPeers = append(d.WGPeers, pe)
+ }
+ d.WGPeerSummary = fmt.Sprintf("%d peers (%d up)", total, up)
+ }
+
+ return d
+}
+
+func buildCounters(s *ifaceStats) ifaceCounters {
+ return ifaceCounters{
+ RxBytes: humanBytes(int64(s.InOctets)),
+ RxUnicast: formatCount(int64(s.InUnicastPkts)),
+ RxBroadcast: formatCount(int64(s.InBroadcastPkts)),
+ RxMulticast: formatCount(int64(s.InMulticastPkts)),
+ RxDiscards: formatCount(int64(s.InDiscards)),
+ RxErrors: formatCount(int64(s.InErrors)),
+ TxBytes: humanBytes(int64(s.OutOctets)),
+ TxUnicast: formatCount(int64(s.OutUnicastPkts)),
+ TxBroadcast: formatCount(int64(s.OutBroadcastPkts)),
+ TxMulticast: formatCount(int64(s.OutMulticastPkts)),
+ TxDiscards: formatCount(int64(s.OutDiscards)),
+ TxErrors: formatCount(int64(s.OutErrors)),
+ }
+}
+
+func buildEthFrameStats(f *ethFrameStats) []kvEntry {
+ return []kvEntry{
+ {"eth-in-frames", formatCount(int64(f.InTotalPkts))},
+ {"eth-in-octets", humanBytes(int64(f.InTotalOctets))},
+ {"eth-in-good-frames", formatCount(int64(f.InGoodPkts))},
+ {"eth-in-good-octets", humanBytes(int64(f.InGoodOctets))},
+ {"eth-in-broadcast", formatCount(int64(f.InBroadcast))},
+ {"eth-in-multicast", formatCount(int64(f.InMulticast))},
+ {"eth-in-fcs-error", formatCount(int64(f.InErrorFCS))},
+ {"eth-in-undersize", formatCount(int64(f.InErrorUndersize))},
+ {"eth-in-oversize", formatCount(int64(f.InErrorOversize))},
+ {"eth-in-mac-error", formatCount(int64(f.InErrorMACInternal))},
+ {"eth-out-frames", formatCount(int64(f.OutTotalPkts))},
+ {"eth-out-octets", humanBytes(int64(f.OutTotalOctets))},
+ {"eth-out-good-frames", formatCount(int64(f.OutGoodPkts))},
+ {"eth-out-good-octets", humanBytes(int64(f.OutGoodOctets))},
+ {"eth-out-broadcast", formatCount(int64(f.OutBroadcast))},
+ {"eth-out-multicast", formatCount(int64(f.OutMulticast))},
+ }
+}
+
+// prettySpeed converts YANG ethernet speed identities to display strings.
+func prettySpeed(s string) string {
+ if i := strings.LastIndex(s, ":"); i >= 0 {
+ s = s[i+1:]
+ }
+ return s
+}
+
+func buildWifiStaEntry(s wifiStaJSON) wifiStaEntry {
+ e := wifiStaEntry{
+ MAC: s.MACAddress,
+ Time: formatDuration(int64(s.ConnectedTime)),
+ RxPkts: formatCount(int64(s.RxPackets)),
+ TxPkts: formatCount(int64(s.TxPackets)),
+ RxBytes: humanBytes(int64(s.RxBytes)),
+ TxBytes: humanBytes(int64(s.TxBytes)),
+ RxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.RxSpeed)/10),
+ TxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.TxSpeed)/10),
+ }
+ if s.SignalStrength != nil {
+ sig := *s.SignalStrength
+ e.Signal = fmt.Sprintf("%d dBm", sig)
+ switch {
+ case sig >= -50:
+ e.SignalCSS = "excellent"
+ case sig >= -60:
+ e.SignalCSS = "good"
+ case sig >= -70:
+ e.SignalCSS = "poor"
+ default:
+ e.SignalCSS = "bad"
+ }
+ }
+ return e
+}
+
+func buildWifiScanEntry(sr wifiScanResultJSON) wifiScanEntry {
+ e := wifiScanEntry{
+ SSID: sr.SSID,
+ BSSID: sr.BSSID,
+ Channel: fmt.Sprintf("%d", sr.Channel),
+ }
+ if len(sr.Encryption) > 0 {
+ e.Encryption = strings.Join(sr.Encryption, ", ")
+ } else {
+ e.Encryption = "Open"
+ }
+ if sr.SignalStrength != nil {
+ sig := *sr.SignalStrength
+ e.Signal = fmt.Sprintf("%d dBm", sig)
+ switch {
+ case sig >= -50:
+ e.SignalCSS = "excellent"
+ case sig >= -60:
+ e.SignalCSS = "good"
+ case sig >= -70:
+ e.SignalCSS = "poor"
+ default:
+ e.SignalCSS = "bad"
+ }
+ }
+ return e
+}
+
+func formatDuration(secs int64) string {
+ if secs < 60 {
+ return fmt.Sprintf("%ds", secs)
+ }
+ if secs < 3600 {
+ return fmt.Sprintf("%dm %ds", secs/60, secs%60)
+ }
+ h := secs / 3600
+ m := (secs % 3600) / 60
+ return fmt.Sprintf("%dh %dm", h, m)
+}
+
+// formatCount formats a packet/frame count with thousand separators.
+func formatCount(n int64) string {
+ if n == 0 {
+ return "0"
+ }
+ s := fmt.Sprintf("%d", n)
+ // Insert thousand separators from the right.
+ var result []byte
+ for i, c := range s {
+ if i > 0 && (len(s)-i)%3 == 0 {
+ result = append(result, ',')
+ }
+ result = append(result, byte(c))
+ }
+ return string(result)
+}
+
+// Detail renders the interface detail page (GET /interfaces/{name}).
+func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ creds := restconf.CredentialsFromContext(r.Context())
+
+ iface, err := h.fetchInterface(r, name)
+ if err != nil {
+ log.Printf("restconf interface %s: %v", name, err)
+ http.Error(w, "Interface not found", http.StatusNotFound)
+ return
+ }
+
+ data := buildDetailData(creds.Username, csrfToken(r.Context()), iface)
+ data.ActivePage = "interfaces"
+ data.PageTitle = "Interface " + name
+ data.Capabilities = CapabilitiesFromContext(r.Context())
+
+ tmplName := "iface-detail.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.DetailTemplate.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// Counters renders the counters fragment for htmx polling (GET /interfaces/{name}/counters).
+func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("name")
+ creds := restconf.CredentialsFromContext(r.Context())
+
+ iface, err := h.fetchInterface(r, name)
+ if err != nil {
+ log.Printf("restconf interface %s counters: %v", name, err)
+ http.Error(w, "Interface not found", http.StatusNotFound)
+ return
+ }
+
+ data := buildDetailData(creds.Username, csrfToken(r.Context()), iface)
+
+ if err := h.CountersTemplate.ExecuteTemplate(w, "iface-counters", data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// freqToChannel converts a WiFi center frequency (MHz) to a channel number.
+func freqToChannel(freq int) int {
+ switch {
+ case freq == 2484:
+ return 14
+ case freq >= 2412 && freq <= 2472:
+ return (freq - 2407) / 5
+ case freq >= 5180 && freq <= 5885:
+ return (freq - 5000) / 5
+ case freq >= 5955 && freq <= 7115:
+ return (freq - 5950) / 5
+ default:
+ return 0
+ }
+}
+
+// renderSurveySVG generates an inline SVG bar chart visualizing WiFi channel
+// survey data. Each channel gets a stacked bar showing receive, transmit,
+// and other busy time as a percentage of active time. A dashed noise-floor
+// line is overlaid with a right-side dBm axis. The in-use channel is marked
+// with a triangle.
+func renderSurveySVG(channels []surveyChanJSON) template.HTML {
+ n := len(channels)
+ if n == 0 {
+ return ""
+ }
+
+ sorted := make([]surveyChanJSON, n)
+ copy(sorted, channels)
+ sort.Slice(sorted, func(i, j int) bool {
+ return sorted[i].Frequency < sorted[j].Frequency
+ })
+
+ // Layout constants.
+ const chartH = 200
+ padL, padR, padT, padB := 44, 48, 28, 58
+
+ slotW := 600.0 / float64(n)
+ if slotW > 44 {
+ slotW = 44
+ }
+ if slotW < 16 {
+ slotW = 16
+ }
+ barW := slotW * 0.65
+ chartW := slotW * float64(n)
+ svgW := int(chartW) + padL + padR
+ svgH := chartH + padT + padB
+
+ // Noise range for right axis.
+ hasNoise := false
+ noiseMin, noiseMax := 0, 0
+ for i, ch := range sorted {
+ if ch.Noise != 0 {
+ if !hasNoise {
+ noiseMin, noiseMax = ch.Noise, ch.Noise
+ hasNoise = true
+ }
+ if ch.Noise < noiseMin {
+ noiseMin = ch.Noise
+ }
+ if ch.Noise > noiseMax {
+ noiseMax = ch.Noise
+ }
+ }
+ _ = i
+ }
+ nFloor := int(math.Floor(float64(noiseMin)/5))*5 - 5
+ nCeil := int(math.Ceil(float64(noiseMax)/5))*5 + 5
+ nRange := float64(nCeil - nFloor)
+ if nRange == 0 {
+ nRange = 10
+ }
+
+ var b strings.Builder
+
+ fmt.Fprintf(&b, ``)
+ return template.HTML(b.String())
+}
diff --git a/src/webui/internal/handlers/keystore.go b/src/webui/internal/handlers/keystore.go
new file mode 100644
index 00000000..f76f0631
--- /dev/null
+++ b/src/webui/internal/handlers/keystore.go
@@ -0,0 +1,190 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "encoding/base64"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// RESTCONF JSON structures for ietf-keystore:keystore.
+
+type keystoreWrapper struct {
+ Keystore keystoreJSON `json:"ietf-keystore:keystore"`
+}
+
+type keystoreJSON struct {
+ SymmetricKeys symmetricKeysJSON `json:"symmetric-keys"`
+ AsymmetricKeys asymmetricKeysJSON `json:"asymmetric-keys"`
+}
+
+type symmetricKeysJSON struct {
+ SymmetricKey []symmetricKeyJSON `json:"symmetric-key"`
+}
+
+type symmetricKeyJSON struct {
+ Name string `json:"name"`
+ KeyFormat string `json:"key-format"`
+ CleartextSymmetricKey string `json:"cleartext-symmetric-key"`
+}
+
+type asymmetricKeysJSON struct {
+ AsymmetricKey []asymmetricKeyJSON `json:"asymmetric-key"`
+}
+
+type asymmetricKeyJSON struct {
+ Name string `json:"name"`
+ PrivateKeyFormat string `json:"private-key-format"`
+ PublicKeyFormat string `json:"public-key-format"`
+ PublicKey string `json:"public-key"`
+ CleartextPrivateKey string `json:"cleartext-private-key"`
+ Certificates certificatesJSON `json:"certificates"`
+}
+
+type certificatesJSON struct {
+ Certificate []certificateJSON `json:"certificate"`
+}
+
+type certificateJSON struct {
+ Name string `json:"name"`
+ CertData string `json:"cert-data"`
+}
+
+// Template data structures.
+
+type keystoreData struct {
+ CsrfToken string
+ Username string
+ ActivePage string
+ PageTitle string
+ Capabilities *Capabilities
+ SymmetricKeys []symKeyEntry
+ AsymmetricKeys []asymKeyEntry
+ Empty bool
+ Error string
+}
+
+type symKeyEntry struct {
+ Name string
+ Format string
+ Value string
+}
+
+type asymKeyEntry struct {
+ Name string
+ Algorithm string
+ PublicKey string
+ PublicKeyFull string
+ PrivateKey string
+ PrivateKeyFull string
+ Certificates []string
+}
+
+// KeystoreHandler serves the keystore overview page.
+type KeystoreHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the keystore overview (GET /keystore).
+func (h *KeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ creds := restconf.CredentialsFromContext(r.Context())
+ data := keystoreData{
+ Username: creds.Username,
+ CsrfToken: csrfToken(r.Context()),
+ ActivePage: "keystore",
+ PageTitle: "Keystore",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ var ks keystoreWrapper
+ if err := h.RC.Get(r.Context(), "/data/ietf-keystore:keystore", &ks); err != nil {
+ log.Printf("restconf keystore: %v", err)
+ data.Error = "Could not fetch keystore"
+ } else {
+ for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey {
+ data.SymmetricKeys = append(data.SymmetricKeys, symKeyEntry{
+ Name: k.Name,
+ Format: shortFormat(k.KeyFormat),
+ Value: decodeSymmetricValue(k),
+ })
+ }
+
+ for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey {
+ entry := asymKeyEntry{
+ Name: k.Name,
+ Algorithm: asymAlgorithm(k),
+ PublicKeyFull: k.PublicKey,
+ PublicKey: truncate(k.PublicKey, 40),
+ PrivateKeyFull: k.CleartextPrivateKey,
+ PrivateKey: truncate(k.CleartextPrivateKey, 40),
+ }
+ for _, c := range k.Certificates.Certificate {
+ entry.Certificates = append(entry.Certificates, c.Name)
+ }
+ data.AsymmetricKeys = append(data.AsymmetricKeys, entry)
+ }
+
+ data.Empty = len(data.SymmetricKeys) == 0 && len(data.AsymmetricKeys) == 0
+ }
+
+ tmplName := "keystore.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// shortFormat strips the YANG module prefix and "-key-format" suffix.
+// e.g. "ietf-crypto-types:octet-string-key-format" → "octet-string"
+func shortFormat(full string) string {
+ if i := strings.LastIndex(full, ":"); i >= 0 {
+ full = full[i+1:]
+ }
+ full = strings.TrimSuffix(full, "-key-format")
+ full = strings.TrimSuffix(full, "-private-key-format")
+ full = strings.TrimSuffix(full, "-public-key-format")
+ return full
+}
+
+// asymAlgorithm derives the key algorithm from the format fields.
+func asymAlgorithm(k asymmetricKeyJSON) string {
+ for _, fmt := range []string{k.PrivateKeyFormat, k.PublicKeyFormat} {
+ name := shortFormat(fmt)
+ if name != "" {
+ return name
+ }
+ }
+ return ""
+}
+
+// decodeSymmetricValue returns the displayable value for a symmetric key.
+// Passphrases are base64-decoded to plaintext; others shown as-is.
+func decodeSymmetricValue(k symmetricKeyJSON) string {
+ val := k.CleartextSymmetricKey
+ if val == "" {
+ return "-"
+ }
+ if shortFormat(k.KeyFormat) == "passphrase" {
+ if decoded, err := base64.StdEncoding.DecodeString(val); err == nil {
+ return string(decoded)
+ }
+ }
+ return val
+}
+
+// truncate shortens s to max characters, adding "..." if truncated.
+func truncate(s string, max int) string {
+ if len(s) <= max {
+ return s
+ }
+ return s[:max-3] + "..."
+}
diff --git a/src/webui/internal/handlers/lldp.go b/src/webui/internal/handlers/lldp.go
new file mode 100644
index 00000000..c3d9e19b
--- /dev/null
+++ b/src/webui/internal/handlers/lldp.go
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// ─── LLDP types ──────────────────────────────────────────────────────────────
+
+// LLDPNeighbor is a remote system seen via LLDP.
+type LLDPNeighbor struct {
+ LocalPort string
+ ChassisID string
+ SystemName string
+ PortID string
+ PortDesc string
+ SystemDesc string
+ Capabilities string // comma-separated
+ MgmtAddress string
+}
+
+// ─── Page data ───────────────────────────────────────────────────────────────
+
+type lldpPageData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+ Neighbors []LLDPNeighbor
+ Error string
+}
+
+// ─── Handler ─────────────────────────────────────────────────────────────────
+
+// LLDPHandler serves the LLDP neighbors page.
+type LLDPHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the LLDP page (GET /lldp).
+func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := lldpPageData{
+ CsrfToken: csrfToken(r.Context()),
+ PageTitle: "LLDP Neighbors",
+ ActivePage: "lldp",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ ctx := context.WithoutCancel(r.Context())
+
+ var raw struct {
+ LLDP struct {
+ Port []struct {
+ Name string `json:"name"`
+ DestMACAddress string `json:"dest-mac-address"`
+ RemoteSystemsData []struct {
+ ChassisID string `json:"chassis-id"`
+ PortID string `json:"port-id"`
+ PortDesc string `json:"port-desc"`
+ SystemName string `json:"system-name"`
+ SystemDescription string `json:"system-description"`
+ SystemCapabilitiesEnabled json.RawMessage `json:"system-capabilities-enabled"`
+ ManagementAddress []struct {
+ Address string `json:"address"`
+ } `json:"management-address"`
+ } `json:"remote-systems-data"`
+ } `json:"port"`
+ } `json:"ieee802-dot1ab-lldp:lldp"`
+ }
+ if err := h.RC.Get(ctx, "/data/ieee802-dot1ab-lldp:lldp", &raw); err != nil {
+ log.Printf("restconf lldp: %v", err)
+ data.Error = "Failed to fetch LLDP data"
+ } else {
+ for _, port := range raw.LLDP.Port {
+ for _, rs := range port.RemoteSystemsData {
+ mgmt := ""
+ if len(rs.ManagementAddress) > 0 {
+ mgmt = rs.ManagementAddress[0].Address
+ }
+ data.Neighbors = append(data.Neighbors, LLDPNeighbor{
+ LocalPort: port.Name,
+ ChassisID: rs.ChassisID,
+ SystemName: rs.SystemName,
+ PortID: rs.PortID,
+ PortDesc: rs.PortDesc,
+ SystemDesc: rs.SystemDescription,
+ Capabilities: parseLLDPCapabilities(rs.SystemCapabilitiesEnabled),
+ MgmtAddress: mgmt,
+ })
+ }
+ }
+ }
+
+ tmplName := "lldp.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+// parseLLDPCapabilities turns the YANG system-capabilities-enabled bits
+// value into a readable comma-separated string.
+func parseLLDPCapabilities(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ // Try plain string first (some implementations encode as "bridge router")
+ var s string
+ if json.Unmarshal(raw, &s) == nil {
+ parts := strings.Fields(s)
+ return strings.Join(parts, ", ")
+ }
+ // Try array of strings
+ var arr []string
+ if json.Unmarshal(raw, &arr) == nil {
+ return strings.Join(arr, ", ")
+ }
+ // Fallback: return raw minus braces
+ trimmed := strings.TrimSpace(string(raw))
+ if trimmed == "{}" || trimmed == "null" || trimmed == "[]" {
+ return ""
+ }
+ return trimmed
+}
diff --git a/src/webui/internal/handlers/ntp.go b/src/webui/internal/handlers/ntp.go
new file mode 100644
index 00000000..8e4a40a1
--- /dev/null
+++ b/src/webui/internal/handlers/ntp.go
@@ -0,0 +1,147 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// ─── NTP types ───────────────────────────────────────────────────────────────
+
+// NTPAssoc is a single NTP association/peer.
+type NTPAssoc struct {
+ Address string
+ Stratum int
+ RefID string
+ Reach string // octal string
+ Poll int
+ Offset string // ms
+ Delay string // ms
+}
+
+// NTPData is the parsed NTP state.
+type NTPData struct {
+ Synchronized bool
+ Stratum int
+ RefID string
+ Offset string // ms
+ RootDelay string // ms
+ Associations []NTPAssoc
+}
+
+// ─── Page data ───────────────────────────────────────────────────────────────
+
+type ntpPageData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+ NTP *NTPData
+ Error string
+}
+
+// ─── Handler ─────────────────────────────────────────────────────────────────
+
+// NTPHandler serves the NTP status page.
+type NTPHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the NTP page (GET /ntp).
+func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := ntpPageData{
+ CsrfToken: csrfToken(r.Context()),
+ PageTitle: "NTP",
+ ActivePage: "ntp",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ ctx := context.WithoutCancel(r.Context())
+
+ var raw struct {
+ NTP struct {
+ ClockState struct {
+ SystemStatus struct {
+ ClockState string `json:"clock-state"`
+ ClockStratum int `json:"clock-stratum"`
+ ClockRefID json.RawMessage `json:"clock-refid"`
+ ClockOffset yangFloat64 `json:"clock-offset"`
+ RootDelay yangFloat64 `json:"root-delay"`
+ } `json:"system-status"`
+ } `json:"clock-state"`
+ Associations struct {
+ Association []struct {
+ Address string `json:"address"`
+ Stratum int `json:"stratum"`
+ RefID json.RawMessage `json:"refid"`
+ Reach uint8 `json:"reach"`
+ Poll int `json:"poll"`
+ Offset yangFloat64 `json:"offset"`
+ Delay yangFloat64 `json:"delay"`
+ } `json:"association"`
+ } `json:"associations"`
+ } `json:"ietf-ntp:ntp"`
+ }
+ if err := h.RC.Get(ctx, "/data/ietf-ntp:ntp", &raw); err != nil {
+ log.Printf("restconf ntp: %v", err)
+ data.Error = "Failed to fetch NTP data"
+ } else {
+ ss := raw.NTP.ClockState.SystemStatus
+ synced := strings.Contains(ss.ClockState, "synchronized") &&
+ !strings.Contains(ss.ClockState, "unsynchronized")
+ ntp := &NTPData{
+ Synchronized: synced,
+ Stratum: ss.ClockStratum,
+ RefID: rawJSONString(ss.ClockRefID),
+ Offset: fmt.Sprintf("%.3f ms", float64(ss.ClockOffset)),
+ RootDelay: fmt.Sprintf("%.3f ms", float64(ss.RootDelay)),
+ }
+ for _, a := range raw.NTP.Associations.Association {
+ ntp.Associations = append(ntp.Associations, NTPAssoc{
+ Address: a.Address,
+ Stratum: a.Stratum,
+ RefID: rawJSONString(a.RefID),
+ Reach: fmt.Sprintf("%o", a.Reach),
+ Poll: a.Poll,
+ Offset: fmt.Sprintf("%.3f ms", float64(a.Offset)),
+ Delay: fmt.Sprintf("%.3f ms", float64(a.Delay)),
+ })
+ }
+ data.NTP = ntp
+ }
+
+ tmplName := "ntp.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+// rawJSONString extracts the unquoted string value from a JSON raw message
+// that may be a string, number, or other scalar.
+func rawJSONString(b json.RawMessage) string {
+ if len(b) == 0 {
+ return ""
+ }
+ // Try string
+ var s string
+ if json.Unmarshal(b, &s) == nil {
+ return s
+ }
+ // Fall back to raw bytes (number, etc.)
+ return strings.Trim(string(b), `"`)
+}
diff --git a/src/webui/internal/handlers/routing.go b/src/webui/internal/handlers/routing.go
new file mode 100644
index 00000000..69a983e0
--- /dev/null
+++ b/src/webui/internal/handlers/routing.go
@@ -0,0 +1,301 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "strings"
+ "sync"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+type RouteEntry struct {
+ DestPrefix string
+ NextHopIface string
+ NextHopAddr string
+ Protocol string
+ Preference int
+ Active bool
+}
+
+type OSPFNeighbor struct {
+ RouterID string
+ State string
+ Role string
+ Interface string
+ Uptime string
+}
+
+type OSPFIface struct {
+ Name string
+ State string
+ Cost int
+ DR string
+ BDR string
+}
+
+type routingData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+ Routes []RouteEntry
+ OSPFNeighbors []OSPFNeighbor
+ OSPFIfaces []OSPFIface
+ HasOSPF bool
+ Error string
+}
+
+type RoutingHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+type ribWrapper struct {
+ Routing *struct {
+ Ribs struct {
+ Rib []ribJSON `json:"rib"`
+ } `json:"ribs"`
+ } `json:"ietf-routing:routing"`
+ Ribs *struct {
+ Rib []ribJSON `json:"rib"`
+ } `json:"ietf-routing:ribs"`
+}
+
+type ribJSON struct {
+ Name string `json:"name"`
+ Routes ribRoutesJSON `json:"routes"`
+}
+
+type ribRoutesJSON struct {
+ Route []ribRouteJSON `json:"route"`
+}
+
+type ribRouteJSON struct {
+ DestPrefix4 string `json:"ietf-ipv4-unicast-routing:destination-prefix"`
+ DestPrefix6 string `json:"ietf-ipv6-unicast-routing:destination-prefix"`
+ DestPrefix string `json:"destination-prefix"`
+ NextHop ribNextHopJSON `json:"next-hop"`
+ SourceProtocol string `json:"source-protocol"`
+ Active *json.RawMessage `json:"active"`
+ RoutePreference int `json:"route-preference"`
+}
+
+func (r ribRouteJSON) destinationPrefix() string {
+ if r.DestPrefix4 != "" {
+ return r.DestPrefix4
+ }
+ if r.DestPrefix6 != "" {
+ return r.DestPrefix6
+ }
+ return r.DestPrefix
+}
+
+type ribNextHopJSON struct {
+ OutgoingInterface string `json:"outgoing-interface"`
+ NextHopAddress string `json:"next-hop-address"`
+ NextHopList *ribNextHopList `json:"next-hop-list"`
+}
+
+type ribNextHopList struct {
+ NextHop []ribNextHopEntry `json:"next-hop"`
+}
+
+type ribNextHopEntry struct {
+ OutgoingInterface string `json:"outgoing-interface"`
+ Address4 string `json:"ietf-ipv4-unicast-routing:address"`
+ Address6 string `json:"ietf-ipv6-unicast-routing:address"`
+ NextHopAddress string `json:"next-hop-address"`
+}
+
+func (nh ribNextHopJSON) resolve() (iface, addr string) {
+ if nh.OutgoingInterface != "" || nh.NextHopAddress != "" {
+ return nh.OutgoingInterface, nh.NextHopAddress
+ }
+ if nh.NextHopList != nil && len(nh.NextHopList.NextHop) > 0 {
+ e := nh.NextHopList.NextHop[0]
+ addr = e.NextHopAddress
+ if addr == "" {
+ addr = e.Address4
+ }
+ if addr == "" {
+ addr = e.Address6
+ }
+ return e.OutgoingInterface, addr
+ }
+ return "", ""
+}
+
+type ospfCPPWrapper struct {
+ Routing struct {
+ CPP struct {
+ Protocol []ospfProtocolJSON `json:"control-plane-protocol"`
+ } `json:"control-plane-protocols"`
+ } `json:"ietf-routing:routing"`
+}
+
+type ospfProtocolJSON struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ OSPF *ospfJSON `json:"ietf-ospf:ospf"`
+}
+
+type ospfJSON struct {
+ Areas struct {
+ Area []ospfAreaJSON `json:"area"`
+ } `json:"areas"`
+}
+
+type ospfAreaJSON struct {
+ AreaID string `json:"area-id"`
+ Interfaces struct {
+ Interface []ospfIfaceJSON `json:"interface"`
+ } `json:"interfaces"`
+}
+
+type ospfIfaceJSON struct {
+ Name string `json:"name"`
+ State string `json:"state"`
+ Cost int `json:"cost"`
+ DRRouterID string `json:"dr-router-id"`
+ BDRRouterID string `json:"bdr-router-id"`
+ Neighbors struct {
+ Neighbor []ospfNeighborJSON `json:"neighbor"`
+ } `json:"neighbors"`
+}
+
+type ospfNeighborJSON struct {
+ NeighborRouterID string `json:"neighbor-router-id"`
+ State string `json:"state"`
+ Role string `json:"infix-routing:role"`
+ InterfaceName string `json:"infix-routing:interface-name"`
+ Uptime uint32 `json:"infix-routing:uptime"`
+}
+
+func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := routingData{
+ CsrfToken: csrfToken(r.Context()),
+ PageTitle: "Routing",
+ ActivePage: "routing",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ ctx := context.WithoutCancel(r.Context())
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ go func() {
+ defer wg.Done()
+ raw, err := h.RC.GetRaw(ctx, "/data/ietf-routing:routing/ribs")
+ if err != nil {
+ log.Printf("restconf rib: %v", err)
+ return
+ }
+ var rib ribWrapper
+ if err := json.Unmarshal(raw, &rib); err != nil {
+ log.Printf("restconf rib unmarshal: %v", err)
+ return
+ }
+ var ribs []ribJSON
+ if rib.Routing != nil {
+ ribs = rib.Routing.Ribs.Rib
+ } else if rib.Ribs != nil {
+ ribs = rib.Ribs.Rib
+ }
+ for _, rb := range ribs {
+ for _, route := range rb.Routes.Route {
+ iface, addr := route.NextHop.resolve()
+ data.Routes = append(data.Routes, RouteEntry{
+ DestPrefix: route.destinationPrefix(),
+ NextHopIface: iface,
+ NextHopAddr: addr,
+ Protocol: shortProto(route.SourceProtocol),
+ Preference: route.RoutePreference,
+ Active: route.Active != nil,
+ })
+ }
+ }
+ }()
+
+ go func() {
+ defer wg.Done()
+ var cpp ospfCPPWrapper
+ if err := h.RC.Get(ctx,
+ "/data/ietf-routing:routing/control-plane-protocols",
+ &cpp); err != nil {
+ log.Printf("restconf ospf (ignored): %v", err)
+ return
+ }
+ for _, proto := range cpp.Routing.CPP.Protocol {
+ if proto.OSPF == nil {
+ continue
+ }
+ data.HasOSPF = true
+ for _, area := range proto.OSPF.Areas.Area {
+ for _, iface := range area.Interfaces.Interface {
+ data.OSPFIfaces = append(data.OSPFIfaces, OSPFIface{
+ Name: iface.Name,
+ State: iface.State,
+ Cost: iface.Cost,
+ DR: iface.DRRouterID,
+ BDR: iface.BDRRouterID,
+ })
+ for _, nbr := range iface.Neighbors.Neighbor {
+ data.OSPFNeighbors = append(data.OSPFNeighbors, OSPFNeighbor{
+ RouterID: nbr.NeighborRouterID,
+ State: nbr.State,
+ Role: nbr.Role,
+ Interface: iface.Name,
+ Uptime: formatUptime(nbr.Uptime),
+ })
+ }
+ }
+ }
+ }
+ }()
+
+ wg.Wait()
+
+ tmplName := "routing.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+func shortProto(proto string) string {
+ if i := strings.LastIndex(proto, ":"); i >= 0 {
+ return proto[i+1:]
+ }
+ return proto
+}
+
+func formatUptime(sec uint32) string {
+ if sec == 0 {
+ return ""
+ }
+ days := sec / 86400
+ hours := (sec % 86400) / 3600
+ mins := (sec % 3600) / 60
+ secs := sec % 60
+ switch {
+ case days > 0:
+ return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
+ case hours > 0:
+ return fmt.Sprintf("%dh %dm", hours, mins)
+ case mins > 0:
+ return fmt.Sprintf("%dm %ds", mins, secs)
+ default:
+ return fmt.Sprintf("%ds", secs)
+ }
+}
diff --git a/src/webui/internal/handlers/routing_test.go b/src/webui/internal/handlers/routing_test.go
new file mode 100644
index 00000000..12da62a6
--- /dev/null
+++ b/src/webui/internal/handlers/routing_test.go
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+var minimalRoutingTmpl = template.Must(template.New("routing.html").Parse(
+ `{{define "routing.html"}}routes={{len .Routes}}{{end}}` +
+ `{{define "content"}}{{len .Routes}}{{end}}`,
+))
+
+func TestRoutingOverview_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &RoutingHandler{Template: minimalRoutingTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/routing", nil)
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestRoutingOverview_HTMXPartial(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &RoutingHandler{Template: minimalRoutingTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/routing", nil)
+ req.Header.Set("HX-Request", "true")
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body for htmx partial")
+ }
+}
diff --git a/src/webui/internal/handlers/services_test.go b/src/webui/internal/handlers/services_test.go
new file mode 100644
index 00000000..0c6abc6d
--- /dev/null
+++ b/src/webui/internal/handlers/services_test.go
@@ -0,0 +1,110 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+var minimalDHCPTmpl = template.Must(template.New("dhcp.html").Parse(
+ `{{define "dhcp.html"}}dhcp={{.DHCP}}{{end}}` +
+ `{{define "content"}}{{.DHCP}}{{end}}`,
+))
+
+var minimalNTPTmpl = template.Must(template.New("ntp.html").Parse(
+ `{{define "ntp.html"}}ntp={{.NTP}}{{end}}` +
+ `{{define "content"}}{{.NTP}}{{end}}`,
+))
+
+var minimalLLDPTmpl = template.Must(template.New("lldp.html").Parse(
+ `{{define "lldp.html"}}lldp={{.Neighbors}}{{end}}` +
+ `{{define "content"}}{{.Neighbors}}{{end}}`,
+))
+
+func testCtx(req *http.Request) *http.Request {
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ return req.WithContext(ctx)
+}
+
+func TestDHCPOverview_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &DHCPHandler{Template: minimalDHCPTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/dhcp", nil)
+ req = testCtx(req)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+ if w.Body.String() == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestDHCPOverview_HTMXPartial(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &DHCPHandler{Template: minimalDHCPTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/dhcp", nil)
+ req.Header.Set("HX-Request", "true")
+ req = testCtx(req)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+ if w.Body.String() == "" {
+ t.Error("expected non-empty response body for htmx partial")
+ }
+}
+
+func TestNTPOverview_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &NTPHandler{Template: minimalNTPTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/ntp", nil)
+ req = testCtx(req)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+ if w.Body.String() == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestLLDPOverview_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &LLDPHandler{Template: minimalLLDPTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/lldp", nil)
+ req = testCtx(req)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+ if w.Body.String() == "" {
+ t.Error("expected non-empty response body")
+ }
+}
diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go
new file mode 100644
index 00000000..3f9664d9
--- /dev/null
+++ b/src/webui/internal/handlers/system.go
@@ -0,0 +1,213 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// SystemHandler provides reboot, config download, and firmware update actions.
+type SystemHandler struct {
+ RC *restconf.Client
+ Template *template.Template // firmware page template
+}
+
+// DeviceStatus returns 200 if the RESTCONF device is reachable, 502 otherwise.
+// Used by the reboot spinner to detect when the device goes down and comes back.
+func (h *SystemHandler) DeviceStatus(w http.ResponseWriter, r *http.Request) {
+ var target struct{}
+ err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/platform", &target)
+ if err != nil {
+ w.WriteHeader(http.StatusBadGateway)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+// Reboot triggers a device restart via the ietf-system:system-restart RPC
+// and returns a spinner fragment that polls until the device is back.
+func (h *SystemHandler) Reboot(w http.ResponseWriter, r *http.Request) {
+ err := h.RC.Post(r.Context(), "/operations/ietf-system:system-restart")
+ if err != nil {
+ log.Printf("reboot: %v", err)
+ http.Error(w, "reboot failed", http.StatusBadGateway)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ fmt.Fprint(w, rebootSpinnerHTML)
+}
+
+const rebootSpinnerHTML = `
+
+
Rebooting…
+
Waiting for device to shut down…
+
`
+
+// DownloadConfig serves the startup datastore as a JSON file download.
+func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) {
+ data, err := h.RC.GetRaw(r.Context(), "/ds/ietf-datastores:startup")
+ if err != nil {
+ log.Printf("config download: %v", err)
+ http.Error(w, "failed to fetch config", http.StatusBadGateway)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Disposition", `attachment; filename="startup-config.json"`)
+ w.Write(data)
+}
+
+// RESTCONF JSON structures for infix-system:software state.
+
+type fwSoftwareWrapper struct {
+ SystemState struct {
+ Software fwSoftwareState `json:"infix-system:software"`
+ } `json:"ietf-system:system-state"`
+}
+
+type fwSoftwareState struct {
+ Compatible string `json:"compatible"`
+ Variant string `json:"variant"`
+ Booted string `json:"booted"`
+ Installer fwInstallerState `json:"installer"`
+ Slots []fwSlot `json:"slot"`
+}
+
+type fwInstallerState struct {
+ Operation string `json:"operation"`
+ Progress fwInstallerProgress `json:"progress"`
+ LastError string `json:"last-error"`
+}
+
+type fwInstallerProgress struct {
+ Percentage int `json:"percentage"`
+ Message string `json:"message"`
+}
+
+type fwSlot struct {
+ Name string `json:"name"`
+ BootName string `json:"bootname"`
+ Class string `json:"class"`
+ State string `json:"state"`
+ Bundle fwSlotBundle `json:"bundle"`
+}
+
+type fwSlotBundle struct {
+ Compatible string `json:"compatible"`
+ Version string `json:"version"`
+}
+
+// Template data for the firmware page.
+
+type firmwareData struct {
+ CsrfToken string
+ Username string
+ ActivePage string
+ PageTitle string
+ Capabilities *Capabilities
+ Slots []slotEntry
+ Installer *installerEntry
+ Error string
+ Message string
+}
+
+type slotEntry struct {
+ Name string
+ BootName string
+ State string
+ Version string
+ Booted bool
+}
+
+type installerEntry struct {
+ Operation string
+ Percentage int
+ Message string
+ LastError string
+ Active bool
+}
+
+// Firmware renders the firmware overview page (GET /firmware).
+func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
+ creds := restconf.CredentialsFromContext(r.Context())
+ data := firmwareData{
+ Username: creds.Username,
+ CsrfToken: csrfToken(r.Context()),
+ ActivePage: "firmware",
+ PageTitle: "Firmware",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ Message: r.URL.Query().Get("msg"),
+ }
+
+ var sw fwSoftwareWrapper
+ err := h.RC.Get(r.Context(), "/data/ietf-system:system-state", &sw)
+ if err != nil {
+ log.Printf("restconf firmware: %v", err)
+ data.Error = "Could not fetch firmware status"
+ } else {
+ for _, s := range sw.SystemState.Software.Slots {
+ data.Slots = append(data.Slots, slotEntry{
+ Name: s.Name,
+ BootName: s.BootName,
+ State: s.State,
+ Version: s.Bundle.Version,
+ Booted: s.Name == sw.SystemState.Software.Booted,
+ })
+ }
+
+ inst := sw.SystemState.Software.Installer
+ data.Installer = &installerEntry{
+ Operation: inst.Operation,
+ Percentage: inst.Progress.Percentage,
+ Message: inst.Progress.Message,
+ LastError: inst.LastError,
+ Active: inst.Operation != "" && inst.Operation != "idle",
+ }
+ }
+
+ tmplName := "firmware.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// FirmwareInstall triggers a firmware install via the install-bundle RPC (POST /firmware/install).
+func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ return
+ }
+
+ url := r.FormValue("url")
+ if url == "" {
+ http.Error(w, "url is required", http.StatusBadRequest)
+ return
+ }
+
+ body := map[string]map[string]string{
+ "infix-system:input": {
+ "url": url,
+ },
+ }
+
+ err := h.RC.PostJSON(r.Context(), "/operations/infix-system:install-bundle", body)
+ if err != nil {
+ log.Printf("firmware install: %v", err)
+ w.Header().Set("HX-Redirect", "/firmware?msg=Install+failed:+"+err.Error())
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ w.Header().Set("HX-Redirect", "/firmware?msg=Install+started")
+ w.WriteHeader(http.StatusNoContent)
+}
diff --git a/src/webui/internal/handlers/vpn.go b/src/webui/internal/handlers/vpn.go
new file mode 100644
index 00000000..6def10e9
--- /dev/null
+++ b/src/webui/internal/handlers/vpn.go
@@ -0,0 +1,230 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// wgConfigJSON is a local extension for fetching WireGuard configuration
+// fields (like listen-port) that are not in the shared wireGuardJSON type.
+type wgConfigJSON struct {
+ ListenPort int `json:"listen-port"`
+}
+
+// wgIfaceConfigWrapper is used to fetch per-interface WireGuard config.
+type wgIfaceConfigWrapper struct {
+ WireGuard *wgConfigJSON `json:"infix-interfaces:wireguard"`
+}
+
+// WGPeer holds display-ready data for a single WireGuard peer.
+type WGPeer struct {
+ PublicKey string
+ PublicKeyShort string // first 8 chars + "..."
+ Endpoint string // "IP:port" or empty
+ Status string // "up" or "down"
+ LastHandshake string // relative time, e.g. "2 min ago" or "never"
+ RxBytes string // human-readable
+ TxBytes string // human-readable
+}
+
+// WGTunnel holds display-ready data for a single WireGuard interface.
+type WGTunnel struct {
+ Name string
+ ListenPort int
+ Addresses []string // IP addresses from ietf-ip
+ OperStatus string
+ Peers []WGPeer
+}
+
+// vpnData is the template data struct for the VPN page.
+type vpnData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+ Tunnels []WGTunnel
+ Error string
+}
+
+// VPNHandler serves the VPN/WireGuard status page.
+type VPNHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the VPN page (GET /vpn).
+func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := vpnData{
+ CsrfToken: csrfToken(r.Context()),
+ PageTitle: "VPN",
+ ActivePage: "vpn",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ // Detach from the request context so that RESTCONF calls survive
+ // browser connection resets (common during login redirects).
+ ctx := context.WithoutCancel(r.Context())
+
+ var ifaces interfacesWrapper
+ if err := h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces); err != nil {
+ log.Printf("restconf interfaces (vpn): %v", err)
+ data.Error = "Could not fetch interface information"
+ h.render(w, r, data)
+ return
+ }
+
+ // Filter for WireGuard interfaces only.
+ var wgIfaces []ifaceJSON
+ for _, iface := range ifaces.Interfaces.Interface {
+ if iface.Type == "infix-if-type:wireguard" {
+ wgIfaces = append(wgIfaces, iface)
+ }
+ }
+
+ if len(wgIfaces) == 0 {
+ h.render(w, r, data)
+ return
+ }
+
+ // Fetch per-interface WireGuard config concurrently.
+ tunnels := make([]WGTunnel, len(wgIfaces))
+ var wg sync.WaitGroup
+
+ for i, iface := range wgIfaces {
+ wg.Add(1)
+ go func(idx int, iface ifaceJSON) {
+ defer wg.Done()
+ tunnels[idx] = buildWGTunnel(ctx, h.RC, iface)
+ }(i, iface)
+ }
+
+ wg.Wait()
+ data.Tunnels = tunnels
+ h.render(w, r, data)
+}
+
+// buildWGTunnel constructs a WGTunnel from an interface and optional config fetch.
+func buildWGTunnel(ctx context.Context, rc *restconf.Client, iface ifaceJSON) WGTunnel {
+ tunnel := WGTunnel{
+ Name: iface.Name,
+ OperStatus: iface.OperStatus,
+ }
+
+ // Collect IP addresses.
+ if iface.IPv4 != nil {
+ for _, a := range iface.IPv4.Address {
+ tunnel.Addresses = append(tunnel.Addresses, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)))
+ }
+ }
+ if iface.IPv6 != nil {
+ for _, a := range iface.IPv6.Address {
+ tunnel.Addresses = append(tunnel.Addresses, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength)))
+ }
+ }
+
+ // Fetch ListenPort from config endpoint (separate from oper-state).
+ var cfgWrap wgIfaceConfigWrapper
+ path := fmt.Sprintf("/data/ietf-interfaces:interfaces/interface=%s/infix-interfaces:wireguard", iface.Name)
+ if err := rc.Get(ctx, path, &cfgWrap); err == nil && cfgWrap.WireGuard != nil {
+ tunnel.ListenPort = cfgWrap.WireGuard.ListenPort
+ }
+
+ // Build peers from embedded peer-status.
+ if iface.WireGuard != nil && iface.WireGuard.PeerStatus != nil {
+ for _, p := range iface.WireGuard.PeerStatus.Peer {
+ peer := WGPeer{
+ PublicKey: p.PublicKey,
+ PublicKeyShort: shortKey(p.PublicKey),
+ Status: p.ConnectionStatus,
+ LastHandshake: relativeTime(p.LatestHandshake),
+ }
+ if p.EndpointAddress != "" {
+ peer.Endpoint = fmt.Sprintf("%s:%d", p.EndpointAddress, p.EndpointPort)
+ }
+ if p.Transfer != nil {
+ peer.RxBytes = humanBytes(int64(p.Transfer.RxBytes))
+ peer.TxBytes = humanBytes(int64(p.Transfer.TxBytes))
+ } else {
+ peer.RxBytes = "0 B"
+ peer.TxBytes = "0 B"
+ }
+ tunnel.Peers = append(tunnel.Peers, peer)
+ }
+ }
+
+ return tunnel
+}
+
+// shortKey returns the first 8 characters of a WireGuard public key followed by "...".
+func shortKey(key string) string {
+ if len(key) <= 8 {
+ return key
+ }
+ return key[:8] + "..."
+}
+
+// relativeTime converts an RFC3339 timestamp to a human-readable relative time string.
+// Returns "never" if the timestamp is empty or cannot be parsed.
+func relativeTime(ts string) string {
+ if ts == "" {
+ return "never"
+ }
+ t, err := time.Parse(time.RFC3339, ts)
+ if err != nil {
+ // Try RFC3339Nano as fallback.
+ t, err = time.Parse(time.RFC3339Nano, ts)
+ if err != nil {
+ return "never"
+ }
+ }
+ if t.IsZero() {
+ return "never"
+ }
+
+ d := time.Since(t)
+ switch {
+ case d < 0:
+ return "just now"
+ case d < time.Minute:
+ return fmt.Sprintf("%d sec ago", int(d.Seconds()))
+ case d < time.Hour:
+ mins := int(d.Minutes())
+ if mins == 1 {
+ return "1 min ago"
+ }
+ return fmt.Sprintf("%d min ago", mins)
+ case d < 24*time.Hour:
+ hours := int(d.Hours())
+ if hours == 1 {
+ return "1 hour ago"
+ }
+ return fmt.Sprintf("%d hours ago", hours)
+ default:
+ days := int(d.Hours()) / 24
+ if days == 1 {
+ return "1 day ago"
+ }
+ return fmt.Sprintf("%d days ago", days)
+ }
+}
+
+// render executes the correct template based on whether it's an htmx request.
+func (h *VPNHandler) render(w http.ResponseWriter, r *http.Request, data vpnData) {
+ tmplName := "vpn.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("template error (vpn): %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
diff --git a/src/webui/internal/handlers/vpn_test.go b/src/webui/internal/handlers/vpn_test.go
new file mode 100644
index 00000000..62ad57af
--- /dev/null
+++ b/src/webui/internal/handlers/vpn_test.go
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+var minimalVPNTmpl = template.Must(template.New("vpn.html").Parse(
+ `{{define "vpn.html"}}tunnels={{len .Tunnels}}{{end}}` +
+ `{{define "content"}}{{len .Tunnels}}{{end}}`,
+))
+
+func TestVPNOverview_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &VPNHandler{Template: minimalVPNTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/vpn", nil)
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestVPNOverview_HTMXPartial(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &VPNHandler{Template: minimalVPNTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/vpn", nil)
+ req.Header.Set("HX-Request", "true")
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body for htmx partial")
+ }
+}
diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go
new file mode 100644
index 00000000..89c80cf9
--- /dev/null
+++ b/src/webui/internal/handlers/wifi.go
@@ -0,0 +1,425 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "context"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "sync"
+
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// wifiRadioHWJSON extends the hardware component wifi-radio container with
+// operational fields from infix-hardware YANG that are not in wifiRadioJSON.
+// wifiRadioJSON (defined in interfaces.go) only covers survey data; this
+// struct captures the full operational state returned by RESTCONF.
+// wifiMaxIfJSON maps the max-interfaces container from infix-hardware YANG.
+type wifiMaxIfJSON struct {
+ AP int `json:"ap"`
+ Station int `json:"station"`
+ Monitor int `json:"monitor"`
+}
+
+type wifiRadioHWJSON struct {
+ Channel interface{} `json:"channel"` // uint16 or "auto"
+ Band string `json:"band"`
+ Frequency int `json:"frequency"` // MHz, operational
+ Noise int `json:"noise"` // dBm, operational
+ Driver string `json:"driver"`
+ Bands []wifiBandJSON `json:"bands"`
+ MaxInterfaces *wifiMaxIfJSON `json:"max-interfaces"`
+ Survey *wifiSurveyJSON `json:"survey"`
+}
+
+type wifiBandJSON struct {
+ Band string `json:"band"`
+ Name string `json:"name"`
+ HTCapable bool `json:"ht-capable"`
+ VHTCapable bool `json:"vht-capable"`
+ HECapable bool `json:"he-capable"`
+}
+
+// hwComponentWiFiJSON is a minimal hardware component used for the wifi page.
+// We reuse hardwareWrapper but need to decode wifi-radio with the richer struct.
+type hwComponentWiFiJSON struct {
+ Name string `json:"name"`
+ Class string `json:"class"`
+ MfgName string `json:"mfg-name"`
+ WiFiRadio *wifiRadioHWJSON `json:"infix-hardware:wifi-radio"`
+}
+
+type hardwareWiFiWrapper struct {
+ Hardware struct {
+ Component []hwComponentWiFiJSON `json:"component"`
+ } `json:"ietf-hardware:hardware"`
+}
+
+// WiFiRadio is the template data for a single physical radio.
+type WiFiRadio struct {
+ Name string
+ Channel string
+ Band string
+ Frequency int
+ Noise int
+ Driver string
+ Manufacturer string
+ Standards string
+ MaxAP string
+ HTCapable bool
+ VHTCapable bool
+ HECapable bool
+ Bands []WiFiBand
+ SurveySVG template.HTML
+ Interfaces []WiFiInterface
+}
+
+type WiFiBand struct {
+ Band string
+ Name string
+ HTCapable bool
+ VHTCapable bool
+ HECapable bool
+}
+
+// ChannelSurvey holds processed survey data for one channel.
+type ChannelSurvey struct {
+ Frequency int
+ Channel int
+ InUse bool
+ Noise int
+ ActiveTime int64
+ BusyTime int64
+ UtilPct int // BusyTime/ActiveTime * 100
+}
+
+// WiFiInterface is the template data for a virtual WiFi interface.
+type WiFiInterface struct {
+ Name string
+ Mode string // "ap" or "station"
+ SSID string
+ OperStatus string
+ StatusUp bool
+ // AP mode
+ APClients []WiFiClient
+ // Station mode
+ Signal string
+ SignalCSS string
+ RxSpeed string
+ TxSpeed string
+ ScanResults []WiFiScan
+}
+
+// WiFiClient is the template data for a connected station client.
+type WiFiClient struct {
+ MAC string
+ Signal string
+ SignalCSS string
+ ConnTime string
+ RxBytes string
+ TxBytes string
+ RxSpeed string
+ TxSpeed string
+}
+
+// WiFiScan is the template data for a scan result entry.
+type WiFiScan struct {
+ SSID string
+ BSSID string
+ Signal string
+ SignalCSS string
+ Channel string
+ Encryption string
+}
+
+// wifiData is the top-level template data for the WiFi page.
+type wifiData struct {
+ CsrfToken string
+ PageTitle string
+ ActivePage string
+ Capabilities *Capabilities
+ Radios []WiFiRadio
+ Error string
+}
+
+// WiFiHandler serves the WiFi status page.
+type WiFiHandler struct {
+ Template *template.Template
+ RC *restconf.Client
+}
+
+// Overview renders the WiFi page (GET /wifi).
+func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) {
+ data := wifiData{
+ CsrfToken: csrfToken(r.Context()),
+ PageTitle: "WiFi",
+ ActivePage: "wifi",
+ Capabilities: CapabilitiesFromContext(r.Context()),
+ }
+
+ // Detach from the request context so that RESTCONF calls survive
+ // browser connection resets.
+ ctx := context.WithoutCancel(r.Context())
+
+ var (
+ hw hardwareWiFiWrapper
+ ifaces interfacesWrapper
+ hwErr, ifErr error
+ wg sync.WaitGroup
+ )
+
+ wg.Add(2)
+ go func() {
+ defer wg.Done()
+ hwErr = h.RC.Get(ctx, "/data/ietf-hardware:hardware", &hw)
+ }()
+ go func() {
+ defer wg.Done()
+ ifErr = h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces)
+ }()
+ wg.Wait()
+
+ if hwErr != nil {
+ log.Printf("wifi: restconf hardware: %v", hwErr)
+ data.Error = "Could not fetch hardware information"
+ }
+ if ifErr != nil {
+ log.Printf("wifi: restconf interfaces: %v", ifErr)
+ if data.Error == "" {
+ data.Error = "Could not fetch interface information"
+ }
+ }
+
+ if hwErr == nil && ifErr == nil {
+ data.Radios = buildWiFiRadios(hw.Hardware.Component, ifaces.Interfaces.Interface)
+ }
+
+ tmplName := "wifi.html"
+ if r.Header.Get("HX-Request") == "true" {
+ tmplName = "content"
+ }
+ if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
+ log.Printf("wifi: template error: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ }
+}
+
+// buildWiFiRadios assembles the WiFiRadio slice from hardware components
+// and interface data, matching interfaces to their radio by name.
+func buildWiFiRadios(components []hwComponentWiFiJSON, ifaces []ifaceJSON) []WiFiRadio {
+ var radios []WiFiRadio
+
+ for _, c := range components {
+ if c.WiFiRadio == nil {
+ continue
+ }
+ r := c.WiFiRadio
+
+ radio := WiFiRadio{
+ Name: c.Name,
+ Band: r.Band,
+ Frequency: r.Frequency,
+ Noise: r.Noise,
+ Driver: r.Driver,
+ Channel: wifiChannelString(r.Channel),
+ Manufacturer: c.MfgName,
+ }
+
+ // Capability flags: check per-band capabilities; if any band supports
+ // HT/VHT/HE, mark the radio as capable.
+ var bandNames []string
+ for _, b := range r.Bands {
+ name := b.Name
+ if name == "" {
+ name = b.Band
+ }
+ radio.Bands = append(radio.Bands, WiFiBand{
+ Band: b.Band,
+ Name: name,
+ HTCapable: b.HTCapable,
+ VHTCapable: b.VHTCapable,
+ HECapable: b.HECapable,
+ })
+ if b.Name != "" {
+ bandNames = append(bandNames, b.Name)
+ }
+ if b.HTCapable {
+ radio.HTCapable = true
+ }
+ if b.VHTCapable {
+ radio.VHTCapable = true
+ }
+ if b.HECapable {
+ radio.HECapable = true
+ }
+ }
+
+ // Derive standards string from aggregated capabilities (matches CLI).
+ var standards []string
+ if radio.HTCapable {
+ standards = append(standards, "11n")
+ }
+ if radio.VHTCapable {
+ standards = append(standards, "11ac")
+ }
+ if radio.HECapable {
+ standards = append(standards, "11ax")
+ }
+ if len(standards) > 0 {
+ radio.Standards = joinStrings(standards, "/")
+ }
+
+ // Max AP count from max-interfaces container.
+ if r.MaxInterfaces != nil && r.MaxInterfaces.AP > 0 {
+ radio.MaxAP = fmt.Sprintf("%d", r.MaxInterfaces.AP)
+ }
+
+ // Generate channel survey SVG if survey data exists.
+ if r.Survey != nil && len(r.Survey.Channel) > 0 {
+ radio.SurveySVG = renderSurveySVG(r.Survey.Channel)
+ }
+
+ // Attach wifi interfaces that reference this radio.
+ radio.Interfaces = buildWiFiInterfaces(c.Name, ifaces)
+
+ radios = append(radios, radio)
+ }
+
+ return radios
+}
+
+// buildWiFiInterfaces returns the WiFiInterface entries for all virtual
+// interfaces that reference the given radio name.
+func buildWiFiInterfaces(radioName string, ifaces []ifaceJSON) []WiFiInterface {
+ var result []WiFiInterface
+
+ for _, iface := range ifaces {
+ if iface.WiFi == nil || iface.WiFi.Radio != radioName {
+ continue
+ }
+
+ wi := WiFiInterface{
+ Name: iface.Name,
+ OperStatus: iface.OperStatus,
+ StatusUp: iface.OperStatus == "up",
+ }
+
+ if ap := iface.WiFi.AccessPoint; ap != nil {
+ wi.Mode = "ap"
+ wi.SSID = ap.SSID
+ for _, s := range ap.Stations.Station {
+ wi.APClients = append(wi.APClients, buildWiFiClient(s))
+ }
+ } else if st := iface.WiFi.Station; st != nil {
+ wi.Mode = "station"
+ wi.SSID = st.SSID
+ if st.SignalStrength != nil {
+ sig := *st.SignalStrength
+ wi.Signal = fmt.Sprintf("%d dBm", sig)
+ wi.SignalCSS = wifiSignalCSS(sig)
+ }
+ if st.RxSpeed > 0 {
+ wi.RxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.RxSpeed)/10)
+ }
+ if st.TxSpeed > 0 {
+ wi.TxSpeed = fmt.Sprintf("%.1f Mbps", float64(st.TxSpeed)/10)
+ }
+ for _, sr := range st.ScanResults {
+ wi.ScanResults = append(wi.ScanResults, buildWiFiScan(sr))
+ }
+ }
+
+ result = append(result, wi)
+ }
+
+ return result
+}
+
+// buildWiFiClient converts a wifiStaJSON to a WiFiClient template entry.
+func buildWiFiClient(s wifiStaJSON) WiFiClient {
+ c := WiFiClient{
+ MAC: s.MACAddress,
+ ConnTime: formatDuration(int64(s.ConnectedTime)),
+ RxBytes: humanBytes(int64(s.RxBytes)),
+ TxBytes: humanBytes(int64(s.TxBytes)),
+ RxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.RxSpeed)/10),
+ TxSpeed: fmt.Sprintf("%.1f Mbps", float64(s.TxSpeed)/10),
+ }
+ if s.SignalStrength != nil {
+ sig := *s.SignalStrength
+ c.Signal = fmt.Sprintf("%d dBm", sig)
+ c.SignalCSS = wifiSignalCSS(sig)
+ }
+ return c
+}
+
+// buildWiFiScan converts a wifiScanResultJSON to a WiFiScan template entry.
+func buildWiFiScan(sr wifiScanResultJSON) WiFiScan {
+ enc := "Open"
+ if len(sr.Encryption) > 0 {
+ parts := make([]string, 0, len(sr.Encryption))
+ for _, e := range sr.Encryption {
+ parts = append(parts, e)
+ }
+ enc = joinStrings(parts, ", ")
+ }
+ s := WiFiScan{
+ SSID: sr.SSID,
+ BSSID: sr.BSSID,
+ Channel: fmt.Sprintf("%d", sr.Channel),
+ Encryption: enc,
+ }
+ if sr.SignalStrength != nil {
+ sig := *sr.SignalStrength
+ s.Signal = fmt.Sprintf("%d dBm", sig)
+ s.SignalCSS = wifiSignalCSS(sig)
+ }
+ return s
+}
+
+// wifiSignalCSS returns a CSS class based on signal strength in dBm.
+func wifiSignalCSS(sig int) string {
+ switch {
+ case sig >= -50:
+ return "signal-excellent"
+ case sig >= -60:
+ return "signal-good"
+ case sig >= -70:
+ return "signal-ok"
+ default:
+ return "signal-poor"
+ }
+}
+
+// wifiChannelString converts the YANG channel union value to a display string.
+// The JSON may arrive as a float64 (number) or string ("auto").
+func wifiChannelString(v interface{}) string {
+ if v == nil {
+ return ""
+ }
+ switch val := v.(type) {
+ case string:
+ return val
+ case float64:
+ return fmt.Sprintf("%d", int(val))
+ case int:
+ return fmt.Sprintf("%d", val)
+ default:
+ return fmt.Sprintf("%v", val)
+ }
+}
+
+// joinStrings joins a slice of strings with a separator.
+func joinStrings(parts []string, sep string) string {
+ result := ""
+ for i, p := range parts {
+ if i > 0 {
+ result += sep
+ }
+ result += p
+ }
+ return result
+}
diff --git a/src/webui/internal/handlers/wifi_test.go b/src/webui/internal/handlers/wifi_test.go
new file mode 100644
index 00000000..02947a4a
--- /dev/null
+++ b/src/webui/internal/handlers/wifi_test.go
@@ -0,0 +1,69 @@
+// SPDX-License-Identifier: MIT
+
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+var minimalWiFiTmpl = template.Must(template.New("wifi.html").Parse(
+ `{{define "wifi.html"}}radios={{len .Radios}}{{end}}` +
+ `{{define "content"}}{{len .Radios}}{{end}}`,
+))
+
+func TestWiFiOverview_ReturnsOK(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &WiFiHandler{Template: minimalWiFiTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/wifi", nil)
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body")
+ }
+}
+
+func TestWiFiOverview_HTMXPartial(t *testing.T) {
+ rc := restconf.NewClient("http://127.0.0.1:19999/restconf", false)
+ h := &WiFiHandler{Template: minimalWiFiTmpl, RC: rc}
+
+ req := httptest.NewRequest(http.MethodGet, "/wifi", nil)
+ req.Header.Set("HX-Request", "true")
+ ctx := restconf.ContextWithCredentials(req.Context(), restconf.Credentials{
+ Username: "admin",
+ Password: "admin",
+ })
+ ctx = security.WithToken(ctx, "test-csrf-token")
+ req = req.WithContext(ctx)
+
+ w := httptest.NewRecorder()
+ h.Overview(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("want 200 got %d; body: %s", w.Code, w.Body.String())
+ }
+
+ body := w.Body.String()
+ if body == "" {
+ t.Error("expected non-empty response body for htmx partial")
+ }
+}
diff --git a/src/webui/internal/restconf/client.go b/src/webui/internal/restconf/client.go
new file mode 100644
index 00000000..4c8e45b3
--- /dev/null
+++ b/src/webui/internal/restconf/client.go
@@ -0,0 +1,223 @@
+// SPDX-License-Identifier: MIT
+
+package restconf
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Credentials holds username/password for Basic Auth.
+// Stored in request contexts by the auth middleware.
+type Credentials struct {
+ Username string
+ Password string
+}
+
+type ctxKey struct{}
+
+// ContextWithCredentials returns a child context carrying creds.
+func ContextWithCredentials(ctx context.Context, c Credentials) context.Context {
+ return context.WithValue(ctx, ctxKey{}, c)
+}
+
+// CredentialsFromContext extracts credentials set by the auth middleware.
+func CredentialsFromContext(ctx context.Context) Credentials {
+ c, _ := ctx.Value(ctxKey{}).(Credentials)
+ return c
+}
+
+// Client talks to the rousette RESTCONF server.
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+}
+
+// NewClient creates a RESTCONF client pointing at baseURL
+// (e.g. "https://192.168.1.1/restconf" or "https://127.0.0.1/restconf").
+// When insecureTLS is true, TLS certificate verification is disabled.
+func NewClient(baseURL string, insecureTLS bool) *Client {
+ var tlsConfig *tls.Config
+ if insecureTLS {
+ tlsConfig = &tls.Config{InsecureSkipVerify: true}
+ }
+ return &Client{
+ baseURL: strings.TrimRight(escapeZoneID(baseURL), "/"),
+ httpClient: &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: tlsConfig,
+ },
+ },
+ }
+}
+
+// Get fetches a RESTCONF resource, decoding the JSON response into target.
+// User credentials are taken from the request context (set by auth middleware).
+func (c *Client) Get(ctx context.Context, path string, target any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Accept", "application/yang-data+json")
+
+ creds := CredentialsFromContext(ctx)
+ req.SetBasicAuth(creds.Username, creds.Password)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("restconf request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return parseError(resp)
+ }
+
+ return json.NewDecoder(resp.Body).Decode(target)
+}
+
+// Post sends a POST request to a RESTCONF RPC endpoint.
+// Used for operations like system-restart that return no body.
+func (c *Client) Post(ctx context.Context, path string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Accept", "application/yang-data+json")
+
+ creds := CredentialsFromContext(ctx)
+ req.SetBasicAuth(creds.Username, creds.Password)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("restconf request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
+ return parseError(resp)
+ }
+
+ return nil
+}
+
+// PostJSON sends a POST request with a JSON body to a RESTCONF RPC endpoint.
+// Used for RPCs that require input parameters (e.g. install-bundle).
+func (c *Client) PostJSON(ctx context.Context, path string, body any) error {
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(body); err != nil {
+ return fmt.Errorf("encoding request body: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, &buf)
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Content-Type", "application/yang-data+json")
+ req.Header.Set("Accept", "application/yang-data+json")
+
+ creds := CredentialsFromContext(ctx)
+ req.SetBasicAuth(creds.Username, creds.Password)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("restconf request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
+ return parseError(resp)
+ }
+
+ return nil
+}
+
+// GetRaw fetches a RESTCONF resource and returns the raw JSON bytes.
+func (c *Client) GetRaw(ctx context.Context, path string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Accept", "application/yang-data+json")
+
+ creds := CredentialsFromContext(ctx)
+ req.SetBasicAuth(creds.Username, creds.Password)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("restconf request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, parseError(resp)
+ }
+
+ return io.ReadAll(resp.Body)
+}
+
+// CheckAuth verifies that the given credentials are accepted by rousette.
+// It does a simple GET against /data/ietf-system:system with Basic Auth.
+func (c *Client) CheckAuth(username, password string) error {
+ req, err := http.NewRequestWithContext(
+ context.Background(),
+ http.MethodGet,
+ c.baseURL+"/data/ietf-system:system",
+ nil,
+ )
+ if err != nil {
+ return err
+ }
+
+ req.Header.Set("Accept", "application/yang-data+json")
+ req.SetBasicAuth(username, password)
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("restconf request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
+ return &AuthError{Code: resp.StatusCode}
+ }
+ if resp.StatusCode != http.StatusOK {
+ return parseError(resp)
+ }
+
+ ct := resp.Header.Get("Content-Type")
+ if !strings.Contains(ct, "yang-data+json") {
+ return fmt.Errorf("unexpected content-type from RESTCONF server: %q", ct)
+ }
+
+ return nil
+}
+
+// escapeZoneID replaces bare "%" in IPv6 zone IDs with "%25" so that
+// Go's url.Parse doesn't reject them as invalid percent-encoding.
+// e.g. "https://[ff02::1%qtap1]/restconf" → "https://[ff02::1%25qtap1]/restconf"
+func escapeZoneID(rawURL string) string {
+ open := strings.Index(rawURL, "[")
+ close := strings.Index(rawURL, "]")
+ if open < 0 || close < 0 || close < open {
+ return rawURL
+ }
+
+ host := rawURL[open:close]
+ if pct := strings.Index(host, "%"); pct >= 0 && !strings.HasPrefix(host[pct:], "%25") {
+ return rawURL[:open+pct] + "%25" + rawURL[open+pct+1:]
+ }
+ return rawURL
+}
diff --git a/src/webui/internal/restconf/errors.go b/src/webui/internal/restconf/errors.go
new file mode 100644
index 00000000..8774a67f
--- /dev/null
+++ b/src/webui/internal/restconf/errors.go
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: MIT
+
+package restconf
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+)
+
+// AuthError is returned when RESTCONF rejects credentials (401/403).
+type AuthError struct {
+ Code int
+}
+
+func (e *AuthError) Error() string {
+ return fmt.Sprintf("authentication failed (HTTP %d)", e.Code)
+}
+
+// Error represents a RESTCONF error response.
+type Error struct {
+ StatusCode int
+ Type string
+ Tag string
+ Message string
+}
+
+func (e *Error) Error() string {
+ if e.Message != "" {
+ return fmt.Sprintf("restconf %d: %s", e.StatusCode, e.Message)
+ }
+ return fmt.Sprintf("restconf %d: %s", e.StatusCode, e.Tag)
+}
+
+// parseError reads a RESTCONF error response body and returns an *Error.
+func parseError(resp *http.Response) error {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+
+ re := &Error{StatusCode: resp.StatusCode}
+
+ // Try to parse the standard RESTCONF error envelope.
+ var envelope struct {
+ Errors struct {
+ Error []struct {
+ ErrorType string `json:"error-type"`
+ ErrorTag string `json:"error-tag"`
+ ErrorMessage string `json:"error-message"`
+ } `json:"error"`
+ } `json:"ietf-restconf:errors"`
+ }
+
+ if json.Unmarshal(body, &envelope) == nil && len(envelope.Errors.Error) > 0 {
+ first := envelope.Errors.Error[0]
+ re.Type = first.ErrorType
+ re.Tag = first.ErrorTag
+ re.Message = first.ErrorMessage
+ } else {
+ re.Message = http.StatusText(resp.StatusCode)
+ }
+
+ return re
+}
diff --git a/src/webui/internal/restconf/fetcher.go b/src/webui/internal/restconf/fetcher.go
new file mode 100644
index 00000000..a06f57a6
--- /dev/null
+++ b/src/webui/internal/restconf/fetcher.go
@@ -0,0 +1,16 @@
+// SPDX-License-Identifier: MIT
+
+package restconf
+
+import "context"
+
+// Fetcher is the RESTCONF client interface used by handlers.
+// *Client satisfies this interface; testutil.MockFetcher provides a test double.
+type Fetcher interface {
+ Get(ctx context.Context, path string, target any) error
+ GetRaw(ctx context.Context, path string) ([]byte, error)
+ Post(ctx context.Context, path string) error
+ PostJSON(ctx context.Context, path string, body any) error
+}
+
+var _ Fetcher = (*Client)(nil)
diff --git a/src/webui/internal/security/csrf.go b/src/webui/internal/security/csrf.go
new file mode 100644
index 00000000..91a0232a
--- /dev/null
+++ b/src/webui/internal/security/csrf.go
@@ -0,0 +1,102 @@
+// SPDX-License-Identifier: MIT
+
+package security
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "net/http"
+ "strings"
+)
+
+const csrfCookieName = "csrf"
+
+type csrfKey struct{}
+
+// EnsureToken sets a CSRF cookie if missing (or preferred is provided)
+// and returns the current token.
+func EnsureToken(w http.ResponseWriter, r *http.Request, preferred string) string {
+ if token := strings.TrimSpace(preferred); token != "" {
+ http.SetCookie(w, &http.Cookie{
+ Name: csrfCookieName,
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ Secure: IsSecureRequest(r),
+ SameSite: http.SameSiteStrictMode,
+ })
+ return token
+ }
+
+ if c, err := r.Cookie(csrfCookieName); err == nil {
+ if token := strings.TrimSpace(c.Value); validToken(token) {
+ return token
+ }
+ }
+
+ token := randomToken()
+ http.SetCookie(w, &http.Cookie{
+ Name: csrfCookieName,
+ Value: token,
+ Path: "/",
+ HttpOnly: true,
+ Secure: IsSecureRequest(r),
+ SameSite: http.SameSiteStrictMode,
+ })
+ return token
+}
+
+// WithToken stores the token in the request context.
+func WithToken(ctx context.Context, token string) context.Context {
+ return context.WithValue(ctx, csrfKey{}, token)
+}
+
+// TokenFromContext returns the CSRF token from context, if set.
+func TokenFromContext(ctx context.Context) string {
+ if v, ok := ctx.Value(csrfKey{}).(string); ok {
+ return v
+ }
+ return ""
+}
+
+func randomToken() string {
+ var b [32]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return ""
+ }
+ return base64.RawURLEncoding.EncodeToString(b[:])
+}
+
+func validToken(token string) bool {
+ if token == "" {
+ return false
+ }
+ _, err := base64.RawURLEncoding.DecodeString(token)
+ return err == nil
+}
+
+// ClearToken removes the CSRF cookie.
+func ClearToken(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: csrfCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: IsSecureRequest(r),
+ SameSite: http.SameSiteStrictMode,
+ })
+}
+
+// IsSecureRequest returns true for TLS or proxy-terminated HTTPS.
+func IsSecureRequest(r *http.Request) bool {
+ if r.TLS != nil {
+ return true
+ }
+ if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
+ parts := strings.Split(xf, ",")
+ return strings.EqualFold(strings.TrimSpace(parts[0]), "https")
+ }
+ return false
+}
diff --git a/src/webui/internal/server/middleware.go b/src/webui/internal/server/middleware.go
new file mode 100644
index 00000000..31dff8e4
--- /dev/null
+++ b/src/webui/internal/server/middleware.go
@@ -0,0 +1,176 @@
+// SPDX-License-Identifier: MIT
+
+package server
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/kernelkit/webui/internal/auth"
+ "github.com/kernelkit/webui/internal/handlers"
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/security"
+)
+
+const cookieName = "session"
+
+// authMiddleware checks the session cookie on every request, looks up
+// the session, and attaches decrypted credentials to the context.
+// Unauthenticated requests are redirected to /login (or get a 401 if
+// the request comes from HTMX).
+func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if isPublicPath(r.URL.Path) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ cookie, err := r.Cookie(cookieName)
+ if err != nil {
+ deny(w, r)
+ return
+ }
+
+ username, password, csrf, features, ok := store.Lookup(cookie.Value)
+ if !ok {
+ deny(w, r)
+ return
+ }
+
+ // Sliding window: re-issue the cookie with a fresh timestamp.
+ // Skip for background polling endpoints so they don't keep
+ // the session alive indefinitely.
+ if !isPollingPath(r.URL.Path) {
+ if fresh, err := store.CreateWithCSRF(username, password, csrf, features); err == nil {
+ http.SetCookie(w, &http.Cookie{
+ Name: cookieName,
+ Value: fresh,
+ Path: "/",
+ HttpOnly: true,
+ Secure: security.IsSecureRequest(r),
+ SameSite: http.SameSiteLaxMode,
+ })
+ }
+ }
+
+ ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{
+ Username: username,
+ Password: password,
+ })
+ ctx = security.WithToken(ctx, csrf)
+ ctx = handlers.ContextWithCapabilities(ctx, handlers.NewCapabilities(features))
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+func csrfMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ token := security.TokenFromContext(r.Context())
+ token = security.EnsureToken(w, r, token)
+ r = r.WithContext(security.WithToken(r.Context(), token))
+
+ switch r.Method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ if !sameOrigin(r) {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ if !validCSRF(r, token) {
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+func sameOrigin(r *http.Request) bool {
+ host := r.Host
+ if xf := r.Header.Get("X-Forwarded-Host"); xf != "" {
+ parts := strings.Split(xf, ",")
+ host = strings.TrimSpace(parts[0])
+ }
+ if host == "" {
+ return false
+ }
+
+ origin := r.Header.Get("Origin")
+ if origin != "" {
+ u, err := url.Parse(origin)
+ if err != nil {
+ return false
+ }
+ return strings.EqualFold(u.Host, host)
+ }
+
+ ref := r.Header.Get("Referer")
+ if ref != "" {
+ u, err := url.Parse(ref)
+ if err != nil {
+ return false
+ }
+ return strings.EqualFold(u.Host, host)
+ }
+
+ return true
+}
+
+func validCSRF(r *http.Request, token string) bool {
+ if token == "" {
+ return false
+ }
+ if hdr := r.Header.Get("X-CSRF-Token"); hdr != "" {
+ return subtleConstantTimeEquals(hdr, token)
+ }
+ if err := r.ParseForm(); err != nil {
+ return false
+ }
+ return subtleConstantTimeEquals(r.FormValue("csrf"), token)
+}
+
+func subtleConstantTimeEquals(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ var diff byte
+ for i := 0; i < len(a); i++ {
+ diff |= a[i] ^ b[i]
+ }
+ return diff == 0
+}
+
+func securityHeadersMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")
+ if security.IsSecureRequest(r) {
+ w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func isPublicPath(path string) bool {
+ return path == "/login" || strings.HasPrefix(path, "/assets/")
+}
+
+func isPollingPath(path string) bool {
+ return path == "/device-status" || strings.HasSuffix(path, "/counters")
+}
+
+func deny(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("HX-Request") == "true" {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go
new file mode 100644
index 00000000..63087d39
--- /dev/null
+++ b/src/webui/internal/server/server.go
@@ -0,0 +1,161 @@
+// SPDX-License-Identifier: MIT
+
+package server
+
+import (
+ "html/template"
+ "io/fs"
+ "net/http"
+
+ "github.com/kernelkit/webui/internal/auth"
+ "github.com/kernelkit/webui/internal/handlers"
+ "github.com/kernelkit/webui/internal/restconf"
+)
+
+// New creates a fully wired http.Handler with all routes and middleware.
+func New(
+ store *auth.SessionStore,
+ rc *restconf.Client,
+ templateFS fs.FS,
+ staticFS fs.FS,
+) (http.Handler, error) {
+ // Parse templates per page so each can define its own "content" block
+ // without collisions.
+ loginTmpl, err := template.ParseFS(templateFS, "pages/login.html")
+ if err != nil {
+ return nil, err
+ }
+ dashTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/dashboard.html")
+ if err != nil {
+ return nil, err
+ }
+ fwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/firewall.html")
+ if err != nil {
+ return nil, err
+ }
+ ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/keystore.html")
+ if err != nil {
+ return nil, err
+ }
+ ifTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/interfaces.html")
+ if err != nil {
+ return nil, err
+ }
+ ifDetailTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/iface-detail.html", "fragments/iface-counters.html")
+ if err != nil {
+ return nil, err
+ }
+ ifCountersTmpl, err := template.ParseFS(templateFS, "fragments/iface-counters.html")
+ if err != nil {
+ return nil, err
+ }
+ fwrTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/firmware.html")
+ if err != nil {
+ return nil, err
+ }
+ routingTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/routing.html")
+ if err != nil {
+ return nil, err
+ }
+ wifiTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/wifi.html")
+ if err != nil {
+ return nil, err
+ }
+ vpnTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/vpn.html")
+ if err != nil {
+ return nil, err
+ }
+ dhcpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/dhcp.html")
+ if err != nil {
+ return nil, err
+ }
+ ntpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/ntp.html")
+ if err != nil {
+ return nil, err
+ }
+ lldpTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/lldp.html")
+ if err != nil {
+ return nil, err
+ }
+ containersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/containers.html")
+ if err != nil {
+ return nil, err
+ }
+
+ login := &auth.LoginHandler{
+ Store: store,
+ RC: rc,
+ Template: loginTmpl,
+ }
+
+ dash := &handlers.DashboardHandler{
+ Template: dashTmpl,
+ RC: rc,
+ }
+
+ fw := &handlers.FirewallHandler{
+ Template: fwTmpl,
+ RC: rc,
+ }
+
+ ks := &handlers.KeystoreHandler{
+ Template: ksTmpl,
+ RC: rc,
+ }
+
+ iface := &handlers.InterfacesHandler{
+ Template: ifTmpl,
+ DetailTemplate: ifDetailTmpl,
+ CountersTemplate: ifCountersTmpl,
+ RC: rc,
+ }
+
+ sys := &handlers.SystemHandler{
+ RC: rc,
+ Template: fwrTmpl,
+ }
+
+ routing := &handlers.RoutingHandler{Template: routingTmpl, RC: rc}
+ wifi := &handlers.WiFiHandler{Template: wifiTmpl, RC: rc}
+ vpn := &handlers.VPNHandler{Template: vpnTmpl, RC: rc}
+ dhcp := &handlers.DHCPHandler{Template: dhcpTmpl, RC: rc}
+ ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc}
+ lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc}
+ containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc}
+
+ mux := http.NewServeMux()
+
+ // Auth routes (public).
+ mux.HandleFunc("GET /login", login.ShowLogin)
+ mux.HandleFunc("POST /login", login.DoLogin)
+ mux.HandleFunc("POST /logout", login.DoLogout)
+
+ // Static assets (public).
+ staticServer := http.FileServerFS(staticFS)
+ mux.Handle("GET /assets/", http.StripPrefix("/assets/", staticServer))
+
+ // Authenticated routes.
+ mux.HandleFunc("GET /{$}", dash.Index)
+ mux.HandleFunc("GET /interfaces", iface.Overview)
+ mux.HandleFunc("GET /interfaces/{name}", iface.Detail)
+ mux.HandleFunc("GET /interfaces/{name}/counters", iface.Counters)
+ mux.HandleFunc("GET /firewall", fw.Overview)
+ mux.HandleFunc("GET /keystore", ks.Overview)
+ mux.HandleFunc("GET /firmware", sys.Firmware)
+ mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall)
+ mux.HandleFunc("POST /reboot", sys.Reboot)
+ mux.HandleFunc("GET /device-status", sys.DeviceStatus)
+ mux.HandleFunc("GET /config", sys.DownloadConfig)
+ mux.HandleFunc("GET /routing", routing.Overview)
+ mux.HandleFunc("GET /wifi", wifi.Overview)
+ mux.HandleFunc("GET /vpn", vpn.Overview)
+ mux.HandleFunc("GET /dhcp", dhcp.Overview)
+ mux.HandleFunc("GET /ntp", ntp.Overview)
+ mux.HandleFunc("GET /lldp", lldp.Overview)
+ mux.HandleFunc("GET /containers", containers.Overview)
+
+ handler := authMiddleware(store, mux)
+ handler = csrfMiddleware(handler)
+ handler = securityHeadersMiddleware(handler)
+ return handler, nil
+}
diff --git a/src/webui/internal/testutil/helpers.go b/src/webui/internal/testutil/helpers.go
new file mode 100644
index 00000000..79f9d407
--- /dev/null
+++ b/src/webui/internal/testutil/helpers.go
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: MIT
+
+package testutil
+
+import (
+ "context"
+ "encoding/json"
+ "sync"
+)
+
+type mockEntry struct {
+ body any
+ err error
+}
+
+type MockFetcher struct {
+ mu sync.Mutex
+ responses map[string]mockEntry
+ errors map[string]error
+}
+
+func NewMockFetcher() *MockFetcher {
+ return &MockFetcher{
+ responses: make(map[string]mockEntry),
+ errors: make(map[string]error),
+ }
+}
+
+func (m *MockFetcher) SetResponse(path string, body any) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.responses[path] = mockEntry{body: body}
+}
+
+func (m *MockFetcher) SetError(path string, err error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.errors[path] = err
+}
+
+func (m *MockFetcher) Get(_ context.Context, path string, target any) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if err, ok := m.errors[path]; ok {
+ return err
+ }
+
+ entry, ok := m.responses[path]
+ if !ok {
+ return nil
+ }
+
+ raw, err := json.Marshal(entry.body)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(raw, target)
+}
+
+func (m *MockFetcher) GetRaw(_ context.Context, path string) ([]byte, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if err, ok := m.errors[path]; ok {
+ return nil, err
+ }
+
+ entry, ok := m.responses[path]
+ if !ok {
+ return []byte("{}"), nil
+ }
+
+ return json.Marshal(entry.body)
+}
+
+func (m *MockFetcher) Post(_ context.Context, path string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if err, ok := m.errors[path]; ok {
+ return err
+ }
+ return nil
+}
+
+func (m *MockFetcher) PostJSON(_ context.Context, path string, _ any) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if err, ok := m.errors[path]; ok {
+ return err
+ }
+ return nil
+}
diff --git a/src/webui/main.go b/src/webui/main.go
new file mode 100644
index 00000000..65fc5246
--- /dev/null
+++ b/src/webui/main.go
@@ -0,0 +1,85 @@
+// SPDX-License-Identifier: MIT
+
+package main
+
+import (
+ "embed"
+ "flag"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/kernelkit/webui/internal/auth"
+ "github.com/kernelkit/webui/internal/restconf"
+ "github.com/kernelkit/webui/internal/server"
+)
+
+//go:embed templates/*
+var templateFS embed.FS
+
+//go:embed static/*
+var staticFS embed.FS
+
+func main() {
+ defaultRC := "http://localhost:8080/restconf"
+ if env := os.Getenv("RESTCONF_URL"); env != "" {
+ defaultRC = env
+ }
+
+ listen := flag.String("listen", ":10000", "address to listen on")
+ restconfURL := flag.String("restconf", defaultRC, "RESTCONF base URL")
+ sessionKey := flag.String("session-key", "/var/lib/misc/webui-session.key", "path to persistent session key file")
+ insecureTLS := flag.Bool("insecure-tls", envBool("INSECURE_TLS"), "disable TLS certificate verification")
+ flag.Parse()
+
+ store, err := auth.NewSessionStore(*sessionKey)
+ if err != nil {
+ log.Fatalf("session store: %v", err)
+ }
+
+ rc := restconf.NewClient(*restconfURL, *insecureTLS)
+
+ tmplFS, err := fs.Sub(templateFS, "templates")
+ if err != nil {
+ log.Fatalf("template fs: %v", err)
+ }
+
+ stFS, err := fs.Sub(staticFS, "static")
+ if err != nil {
+ log.Fatalf("static fs: %v", err)
+ }
+
+ handler, err := server.New(store, rc, tmplFS, stFS)
+ if err != nil {
+ log.Fatalf("server setup: %v", err)
+ }
+
+ log.Printf("listening on %s (restconf %s)", *listen, *restconfURL)
+ srv := &http.Server{
+ Addr: *listen,
+ Handler: handler,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+ if err := srv.ListenAndServe(); err != nil {
+ log.Fatalf("listen: %v", err)
+ }
+}
+
+func envBool(key string) bool {
+ v := strings.TrimSpace(os.Getenv(key))
+ if v == "" {
+ return false
+ }
+ switch strings.ToLower(v) {
+ case "1", "true", "yes", "y", "on":
+ return true
+ default:
+ return false
+ }
+}
diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css
new file mode 100644
index 00000000..2a6c6980
--- /dev/null
+++ b/src/webui/static/css/style.css
@@ -0,0 +1,1100 @@
+/* ==========================================================================
+ Design System: Primitives & Tokens
+ ========================================================================== */
+
+:root {
+ /* --- Primitives: Slate (Neutral) --- */
+ --slate-50: #f8fafc;
+ --slate-100: #f1f5f9;
+ --slate-200: #e2e8f0;
+ --slate-300: #cbd5e1;
+ --slate-400: #94a3b8;
+ --slate-500: #64748b;
+ --slate-600: #475569;
+ --slate-700: #334155;
+ --slate-800: #1e293b;
+ --slate-900: #0f172a;
+ --slate-950: #020617;
+
+ /* --- Primitives: Colors --- */
+ --blue-500: #3b82f6;
+ --blue-600: #2563eb;
+ --blue-700: #1d4ed8;
+ --green-500: #22c55e;
+ --green-600: #16a34a;
+ --amber-500: #f59e0b;
+ --amber-600: #d97706;
+ --red-500: #ef4444;
+ --red-600: #dc2626;
+
+ /* --- Semantic Tokens: Light Mode (Default) --- */
+ --bg: var(--slate-50);
+ --surface: #ffffff;
+ --fg: var(--slate-900);
+ --fg-muted: var(--slate-500);
+ --border: var(--slate-200);
+ --border-subtle: var(--slate-100);
+
+ --primary: var(--blue-500);
+ --primary-hover: var(--blue-600);
+
+ --success: var(--green-500);
+ --warning: var(--amber-500);
+ --danger: var(--red-500);
+
+ /* Sidebar (Always Dark) */
+ --sidebar-bg: var(--slate-800);
+ --sidebar-fg: var(--slate-200);
+ --sidebar-hover: rgba(255, 255, 255, 0.1);
+ --sidebar-border: rgba(255, 255, 255, 0.1);
+ --sidebar-width: 240px;
+
+ /* Layout */
+ --radius: 8px;
+ --radius-sm: 4px;
+ --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+ --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ --font-mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
+
+ /* Legacy variable mapping */
+ --card-bg: var(--surface);
+}
+
+/* --- Dark Mode Overrides --- */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: var(--slate-900);
+ --surface: var(--slate-800);
+ --fg: var(--slate-100);
+ --fg-muted: var(--slate-400);
+ --border: var(--slate-700);
+ --border-subtle: var(--slate-800);
+ }
+}
+
+/* Manual Dark Mode Toggle */
+.dark {
+ --bg: var(--slate-900);
+ --surface: var(--slate-800);
+ --fg: var(--slate-100);
+ --fg-muted: var(--slate-400);
+ --border: var(--slate-700);
+ --border-subtle: var(--slate-800);
+ --zebra-stripe: rgba(255,255,255,0.03);
+}
+
+/* Force Light Mode (override system dark preference) */
+.light {
+ --bg: var(--slate-50);
+ --surface: #ffffff;
+ --fg: var(--slate-900);
+ --fg-muted: var(--slate-500);
+ --border: var(--slate-200);
+ --border-subtle: var(--slate-100);
+}
+.light .alert-error { background: #fef2f2; color: #991b1b; border-color: #fecaca; }
+.light .alert-info { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
+.light .health-bar-track { background-color: var(--slate-200); }
+.light .zone-badge {
+ background-color: hsl(var(--zone-hue), 80%, 90%);
+ color: hsl(var(--zone-hue), 80%, 30%);
+ border-color: hsl(var(--zone-hue), 60%, 80%);
+}
+.light .matrix-allow { background: #dcfce7; color: #166534; }
+.light .matrix-deny { background: #fef2f2; color: #991b1b; }
+.light .matrix-self { background: var(--slate-100); color: var(--slate-400); }
+.light .badge-accept { background: #dcfce7; color: #166534; }
+.light .badge-reject { background: #fef2f2; color: #991b1b; }
+.light .badge-drop { background: var(--slate-100); color: var(--slate-600); }
+.light .badge-continue { background: #dbeafe; color: #1e40af; }
+.light .key-full { background: var(--slate-100); }
+
+/* ==========================================================================
+ Reset & Base
+ ========================================================================== */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html, body {
+ height: 100%;
+ font-family: var(--font-sans);
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--fg);
+ background: var(--bg);
+ -webkit-font-smoothing: antialiased;
+}
+
+a {
+ color: var(--primary);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+a:hover {
+ text-decoration: underline;
+}
+
+/* ==========================================================================
+ Layout
+ ========================================================================== */
+
+.layout {
+ display: flex;
+ min-height: 100vh;
+}
+
+#sidebar {
+ width: var(--sidebar-width);
+ background: var(--sidebar-bg);
+ color: var(--sidebar-fg);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ border-right: 1px solid var(--sidebar-border);
+}
+
+#content {
+ flex: 1;
+ padding: 2rem;
+ overflow-y: auto;
+ height: 100vh;
+}
+
+/* ==========================================================================
+ Sidebar Components
+ ========================================================================== */
+
+.sidebar-header {
+ padding: 1.5rem 1.25rem;
+ border-bottom: 1px solid var(--sidebar-border);
+}
+
+.sidebar-logo {
+ max-width: 100%;
+ height: auto;
+ display: block;
+}
+
+.sidebar-nav {
+ list-style: none;
+ flex: 1;
+ padding: 1rem 0;
+}
+
+.nav-section-header {
+ font-size: 0.65rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--sidebar-fg);
+ opacity: 0.5;
+ padding: 1rem 1rem 0.25rem;
+ pointer-events: none;
+}
+
+.nav-icon {
+ display: inline-block;
+ width: 1rem;
+ height: 1rem;
+ background-color: currentColor;
+ -webkit-mask-image: var(--icon);
+ mask-image: var(--icon);
+ -webkit-mask-size: contain;
+ mask-size: contain;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ mask-position: center;
+ flex-shrink: 0;
+}
+
+.nav-link {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0.5rem 1rem;
+ color: var(--sidebar-fg);
+ text-decoration: none;
+ font-size: 0.875rem;
+ border-radius: 0.375rem;
+ margin: 0 0.5rem;
+ transition: background-color 0.15s;
+ font-weight: 500;
+}
+
+.nav-link:hover {
+ background-color: var(--sidebar-hover);
+ color: #fff;
+ text-decoration: none;
+}
+
+.nav-link.active {
+ background-color: var(--primary);
+ color: #fff;
+ text-decoration: none;
+}
+
+.sidebar-footer {
+ padding: 1.25rem;
+ border-top: 1px solid var(--sidebar-border);
+}
+
+/* ==========================================================================
+ Buttons
+ ========================================================================== */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.5rem 1rem;
+ border: 1px solid transparent;
+ border-radius: var(--radius);
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-decoration: none;
+ transition: all 0.15s ease;
+ line-height: 1.25;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: #fff;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+ text-decoration: none;
+}
+
+.btn-block {
+ display: flex;
+ width: 100%;
+}
+
+/* Sidebar Actions */
+.btn-sidebar-action, .btn-logout {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ color: var(--sidebar-fg);
+ border: 1px solid rgba(255,255,255,0.2);
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-size: 0.85rem;
+ text-decoration: none;
+ margin-bottom: 0.5rem;
+ transition: all 0.15s ease;
+}
+
+.btn-sidebar-action:hover, .btn-logout:hover {
+ background: var(--sidebar-hover);
+ border-color: rgba(255,255,255,0.3);
+ text-decoration: none;
+}
+
+.btn-danger-hover:hover {
+ border-color: var(--danger);
+ color: #fca5a5;
+ background: rgba(220, 38, 38, 0.1);
+}
+
+/* ==========================================================================
+ Cards & Content Containers
+ ========================================================================== */
+
+.info-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.info-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-sm);
+ overflow: hidden;
+ margin-bottom: 1.5rem;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Legacy support: info-card often contains direct h2 */
+.info-card h2 {
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+ color: var(--fg-muted);
+ padding: 1rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+ margin: 0;
+ background: var(--bg);
+}
+
+.card-header {
+ padding: 0.75rem 1.25rem;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.85rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+ color: var(--fg-muted);
+}
+
+.card-body {
+ padding: 1.25rem;
+}
+
+/* ==========================================================================
+ Tables
+ ========================================================================== */
+
+.info-table, .disk-table, .counters-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+}
+
+.info-table th, .info-table td,
+.disk-table th, .disk-table td,
+.counters-table th, .counters-table td {
+ padding: 0.75rem 1.25rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.info-table tr:last-child th, .info-table tr:last-child td,
+.disk-table tbody tr:last-child td,
+.counters-table tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.info-table tr:nth-child(even),
+.disk-table tbody tr:nth-child(even),
+.counters-table tbody tr:nth-child(even) {
+ background-color: var(--zebra-stripe, rgba(0,0,0,0.025));
+}
+
+.info-table th {
+ text-align: left;
+ font-weight: 500;
+ color: var(--fg-muted);
+ width: 40%;
+ background: transparent;
+}
+
+.disk-table thead th, .counters-table thead th {
+ background: var(--bg);
+ font-weight: 600;
+ color: var(--fg-muted);
+ text-align: left;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+}
+
+.counters-table td {
+ font-family: var(--font-mono);
+}
+
+/* New Data Table Styles */
+.data-table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+
+.data-table th, .data-table td {
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+}
+
+.data-table th {
+ background: var(--bg);
+ color: var(--fg-muted);
+ font-weight: 600;
+ font-size: 0.8rem;
+ text-transform: uppercase;
+}
+
+.data-table tbody tr:nth-child(even) {
+ background-color: var(--zebra-stripe, rgba(0,0,0,0.025));
+}
+
+.data-table tbody tr:hover {
+ background-color: rgba(0,0,0,0.05);
+}
+
+/* ==========================================================================
+ Forms & Inputs
+ ========================================================================== */
+
+.form-group {
+ margin-bottom: 1.25rem;
+}
+
+.form-group label {
+ display: block;
+ font-size: 0.85rem;
+ font-weight: 600;
+ margin-bottom: 0.4rem;
+ color: var(--fg);
+}
+
+.form-group input {
+ display: block;
+ width: 100%;
+ padding: 0.6rem 0.8rem;
+ background: var(--surface);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ font-size: 0.9rem;
+ transition: all 0.15s ease;
+}
+
+.form-group input:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
+}
+
+/* ==========================================================================
+ Authentication
+ ========================================================================== */
+
+.login-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background: var(--bg);
+ padding: 1rem;
+}
+
+.login-card {
+ background: var(--surface);
+ padding: 2.5rem;
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ width: 100%;
+ max-width: 400px;
+ border: 1px solid var(--border);
+}
+
+.login-logo {
+ display: block;
+ max-width: 180px;
+ height: auto;
+ margin: 0 auto 2rem;
+}
+
+/* ==========================================================================
+ Alerts & Banners
+ ========================================================================== */
+
+.conn-banner {
+ background: var(--danger);
+ color: #fff;
+ text-align: center;
+ padding: 0.5rem;
+ font-size: 0.9rem;
+ font-weight: 600;
+ position: sticky;
+ top: 0;
+ z-index: 50;
+}
+
+.alert {
+ padding: 1rem;
+ border-radius: var(--radius);
+ margin-bottom: 1.5rem;
+ font-size: 0.9rem;
+ border: 1px solid transparent;
+}
+
+.alert-error {
+ background: #fef2f2;
+ color: var(--red-600);
+ border-color: #fecaca;
+}
+
+.alert-info {
+ background: #eff6ff;
+ color: var(--blue-700);
+ border-color: #bfdbfe;
+}
+
+/* Dark mode adjustments for alerts */
+@media (prefers-color-scheme: dark) {
+ .alert-error {
+ background: rgba(239, 68, 68, 0.1);
+ color: #fca5a5;
+ border-color: rgba(239, 68, 68, 0.2);
+ }
+ .alert-info {
+ background: rgba(59, 130, 246, 0.1);
+ color: #93c5fd;
+ border-color: rgba(59, 130, 246, 0.2);
+ }
+}
+.dark .alert-error {
+ background: rgba(239, 68, 68, 0.1);
+ color: #fca5a5;
+ border-color: rgba(239, 68, 68, 0.2);
+}
+.dark .alert-info {
+ background: rgba(59, 130, 246, 0.1);
+ color: #93c5fd;
+ border-color: rgba(59, 130, 246, 0.2);
+}
+
+/* ==========================================================================
+ Status Indicators
+ ========================================================================== */
+
+/* Legacy Status Dots */
+.status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ margin-right: 0.5rem;
+ vertical-align: middle;
+}
+
+.status-up { background: var(--success); }
+.status-down { background: var(--danger); }
+
+/* New Interface Status Indicators */
+.iface-status {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ margin-right: 0.5rem;
+}
+
+.iface-up {
+ background-color: var(--success);
+ box-shadow: 0 0 0 rgba(34, 197, 94, 0.4);
+ animation: pulse-green 2s infinite;
+}
+
+.iface-down {
+ background-color: var(--danger);
+}
+
+.iface-lower-down {
+ background-color: var(--warning);
+}
+
+@keyframes pulse-green {
+ 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7); }
+ 70% { box-shadow: 0 0 0 6px rgba(34, 197, 94, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); }
+}
+
+
+/* ==========================================================================
+ Components: Health Bar
+ ========================================================================== */
+
+.health-bar-track {
+ width: 100%;
+ height: 6px;
+ background-color: var(--slate-200);
+ border-radius: 99px;
+ overflow: hidden;
+}
+
+.health-bar-fill {
+ height: 100%;
+ background-color: var(--success);
+ border-radius: 99px;
+ transition: width 0.5s ease;
+}
+
+.health-bar-fill.is-warn { background-color: var(--warning); }
+.health-bar-fill.is-crit { background-color: var(--danger); }
+
+/* Dark mode track */
+@media (prefers-color-scheme: dark) {
+ .health-bar-track { background-color: var(--slate-700); }
+}
+.dark .health-bar-track { background-color: var(--slate-700); }
+
+/* ==========================================================================
+ Components: Zone Badge
+ ========================================================================== */
+
+.zone-badge {
+ /* Requires --zone-hue to be set inline or via utility */
+ --zone-hue: 200; /* Default */
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.6rem;
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-weight: 600;
+ background-color: hsl(var(--zone-hue), 80%, 90%);
+ color: hsl(var(--zone-hue), 80%, 30%);
+ border: 1px solid hsl(var(--zone-hue), 60%, 80%);
+}
+
+@media (prefers-color-scheme: dark) {
+ .zone-badge {
+ background-color: hsl(var(--zone-hue), 60%, 20%);
+ color: hsl(var(--zone-hue), 80%, 85%);
+ border-color: hsl(var(--zone-hue), 60%, 30%);
+ }
+}
+.dark .zone-badge {
+ background-color: hsl(var(--zone-hue), 60%, 20%);
+ color: hsl(var(--zone-hue), 80%, 85%);
+ border-color: hsl(var(--zone-hue), 60%, 30%);
+}
+
+/* ==========================================================================
+ Utilities
+ ========================================================================== */
+
+.num {
+ font-variant-numeric: tabular-nums;
+ font-family: var(--font-mono);
+}
+
+/* ==========================================================================
+ Domain Specific: Firewall, Keystore, Firmware
+ ========================================================================== */
+
+/* Zone Matrix */
+.matrix-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+}
+
+.zone-matrix {
+ border-collapse: collapse;
+ font-size: 0.9rem;
+ width: 100%;
+}
+
+.zone-matrix th, .zone-matrix td {
+ padding: 0.75rem;
+ text-align: center;
+ border: 1px solid var(--border);
+ min-width: 90px;
+}
+
+.zone-matrix thead th {
+ font-weight: 600;
+ color: var(--fg-muted);
+ background: var(--bg);
+}
+
+.zone-matrix tbody th {
+ font-weight: 600;
+ text-align: right;
+ background: var(--bg);
+ color: var(--fg-muted);
+}
+
+.matrix-corner {
+ color: var(--fg-muted);
+}
+
+.matrix-allow {
+ background: #dcfce7;
+ color: #166534;
+ font-weight: 700;
+}
+.matrix-deny {
+ background: #fef2f2;
+ color: #991b1b;
+ font-weight: 700;
+}
+.matrix-self {
+ background: var(--slate-100);
+ color: var(--slate-400);
+}
+
+@media (prefers-color-scheme: dark) {
+ .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; }
+ .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; }
+ .matrix-self { background: var(--slate-800); color: var(--slate-600); }
+}
+.dark .matrix-allow { background: rgba(22, 163, 74, 0.2); color: #86efac; }
+.dark .matrix-deny { background: rgba(220, 38, 38, 0.2); color: #fca5a5; }
+.dark .matrix-self { background: var(--slate-800); color: var(--slate-600); }
+
+.matrix-legend {
+ display: flex;
+ gap: 1.5rem;
+ font-size: 0.85rem;
+ color: var(--fg-muted);
+ margin-top: 1rem;
+}
+
+.legend-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.legend-swatch {
+ width: 16px;
+ height: 16px;
+ border-radius: 4px;
+ border: 1px solid var(--border);
+}
+
+/* Badges */
+.badge {
+ display: inline-block;
+ padding: 0.25rem 0.6rem;
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: capitalize;
+}
+
+.badge-accept { background: #dcfce7; color: #166534; }
+.badge-reject { background: #fef2f2; color: #991b1b; }
+.badge-drop { background: var(--slate-100); color: var(--slate-600); }
+.badge-continue { background: #dbeafe; color: #1e40af; }
+
+@media (prefers-color-scheme: dark) {
+ .badge-accept { background: rgba(22, 163, 74, 0.2); color: #86efac; }
+ .badge-reject { background: rgba(220, 38, 38, 0.2); color: #fca5a5; }
+ .badge-drop { background: var(--slate-800); color: var(--slate-400); }
+ .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
+}
+.dark .badge-accept { background: rgba(22, 163, 74, 0.2); color: #86efac; }
+.dark .badge-reject { background: rgba(220, 38, 38, 0.2); color: #fca5a5; }
+.dark .badge-drop { background: var(--slate-800); color: var(--slate-400); }
+.dark .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
+
+/* Interfaces */
+.iface-name { white-space: nowrap; font-weight: 500; }
+.iface-name a { color: var(--primary); text-decoration: none; }
+.iface-name a:hover { text-decoration: underline; }
+
+.tree-pipe { color: var(--slate-300); font-family: var(--font-mono); margin-right: 0.5rem; }
+.addr-origin { color: var(--fg-muted); font-size: 0.75rem; margin-left: 0.5rem; }
+
+.wg-peer {
+ padding: 1rem 0;
+ border-bottom: 1px solid var(--border);
+}
+.wg-peer:last-child { border-bottom: none; }
+
+.eth-stats {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ font-size: 0.85rem;
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+}
+.eth-stats-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 1rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+.eth-stats dt { color: var(--fg-muted); font-family: var(--font-mono); }
+.eth-stats dd { text-align: right; font-family: var(--font-mono); font-weight: 600; }
+
+.iface-detail-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; }
+.iface-detail-header h2 { font-size: 1.5rem; font-weight: 600; color: var(--fg); }
+.back-link { display: inline-flex; align-items: center; margin-bottom: 0.5rem; font-size: 0.9rem; }
+
+.info-card h3 {
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--fg-muted);
+ margin: 1.5rem 0 0.75rem;
+ padding-left: 0.5rem;
+ border-left: 3px solid var(--primary);
+}
+
+/* Firmware & Reboot */
+.firmware-form { display: flex; gap: 1rem; align-items: flex-end; }
+.firmware-form .form-group { flex: 1; margin-bottom: 0; }
+
+.progress-bar-wrap {
+ background: var(--slate-200);
+ border-radius: var(--radius);
+ height: 1.25rem;
+ overflow: hidden;
+ margin-top: 0.5rem;
+}
+.progress-bar {
+ background: var(--primary);
+ height: 100%;
+ width: 0%;
+ transition: width 0.3s ease;
+}
+.progress-text { font-size: 0.85rem; color: var(--fg-muted); margin-top: 0.5rem; text-align: center; }
+
+.reboot-overlay {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 60vh;
+}
+.reboot-spinner {
+ width: 48px;
+ height: 48px;
+ border: 4px solid var(--slate-200);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin-bottom: 1.5rem;
+}
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.reboot-message { font-size: 1.25rem; font-weight: 600; color: var(--fg); }
+.reboot-status { font-size: 1rem; color: var(--fg-muted); margin-top: 0.5rem; }
+.reboot-status.is-error { color: var(--danger); }
+
+/* Keystore */
+.empty-message { color: var(--fg-muted); font-style: italic; padding: 1rem; text-align: center; }
+.key-full { font-family: var(--font-mono); font-size: 0.8rem; word-break: break-all; color: var(--fg); background: var(--slate-100); padding: 0.25rem; border-radius: 4px; }
+@media (prefers-color-scheme: dark) { .key-full { background: var(--slate-800); } }
+.dark .key-full { background: var(--slate-800); }
+
+/* Survey */
+.survey-chart { max-width: 100%; height: auto; display: block; }
+.survey-hint { font-size: 0.8rem; color: var(--fg-muted); margin-bottom: 1rem; }
+
+/* WiFi */
+.wifi-radio-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+}
+
+.wifi-caps {
+ display: flex;
+ gap: 0.375rem;
+ align-items: center;
+}
+
+.wifi-survey-body {
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.wifi-survey-body .survey-chart {
+ max-height: 220px;
+ width: auto;
+}
+
+.wifi-ssid {
+ font-weight: 400;
+ color: var(--fg-muted);
+ font-size: 0.85rem;
+ margin-left: 0.5rem;
+ font-family: var(--font-mono);
+}
+
+.wifi-band-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.wifi-band-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.wifi-band-caps {
+ display: inline-flex;
+ gap: 0.25rem;
+}
+
+/* Signal Strength */
+.signal-excellent { color: var(--success); font-weight: 600; }
+.signal-good { color: var(--green-600); font-weight: 600; }
+.signal-ok { color: var(--warning); font-weight: 600; }
+.signal-poor { color: var(--danger); font-weight: 600; }
+
+/* ==========================================================================
+ Topbar & Theme Toggle
+ ========================================================================== */
+
+.topbar {
+ display: none;
+ align-items: center;
+ justify-content: flex-end;
+ padding: 0.5rem 1rem;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+}
+
+.theme-toggle {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--sidebar-fg);
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ padding: 0;
+}
+
+.theme-toggle:hover {
+ color: #fff;
+}
+
+.hamburger-btn {
+ background: none;
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ padding: 0.375rem;
+ cursor: pointer;
+ color: var(--fg-muted);
+ display: none; /* Hidden by default; shown via media query */
+ align-items: center;
+}
+.hamburger-btn:hover {
+ color: var(--fg);
+ border-color: var(--fg-muted);
+}
+
+/* ==========================================================================
+ Responsive Layout
+ ========================================================================== */
+
+/* Mobile (≤768px): Sidebar hidden, hamburger visible */
+@media (max-width: 768px) {
+ #sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 100%;
+ z-index: 200;
+ transform: translateX(-100%);
+ transition: transform 0.25s ease;
+ width: var(--sidebar-width);
+ }
+
+ body.sidebar-open #sidebar {
+ transform: translateX(0);
+ }
+
+ body.sidebar-open::after {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,0.4);
+ z-index: 100;
+ }
+
+ #content {
+ height: auto;
+ min-height: 100vh;
+ }
+
+ .hamburger-btn {
+ display: flex;
+ }
+
+ .topbar {
+ display: flex;
+ justify-content: space-between;
+ }
+}
+
+/* Tablet (769px–1024px): Icon-only sidebar */
+@media (min-width: 769px) and (max-width: 1024px) {
+ :root {
+ --sidebar-width: 64px;
+ }
+
+ .nav-link span:not(.nav-icon) {
+ display: none;
+ }
+
+ .nav-section-header {
+ display: none;
+ }
+
+ .nav-link {
+ justify-content: center;
+ padding: 0.6rem;
+ }
+
+ .sidebar-footer .btn-sidebar-action span:not(.nav-icon) {
+ display: none;
+ }
+
+ .sidebar-footer .btn-logout span:not(.nav-icon) {
+ display: none;
+ }
+
+ .theme-toggle span {
+ display: none;
+ }
+
+ .theme-toggle {
+ justify-content: center;
+ }
+
+ .sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+ }
+
+ .sidebar-logo {
+ max-width: 32px;
+ }
+}
+
+/* Desktop: hamburger hidden */
+@media (min-width: 769px) {
+ .hamburger-btn {
+ display: none;
+ }
+}
+
+/* Column priority hiding for data tables */
+@media (max-width: 600px) {
+ .col-priority-2 { display: none; }
+}
+@media (max-width: 480px) {
+ .col-priority-3 { display: none; }
+}
diff --git a/src/webui/static/img/icons/containers.svg b/src/webui/static/img/icons/containers.svg
new file mode 100644
index 00000000..a6389999
--- /dev/null
+++ b/src/webui/static/img/icons/containers.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/dashboard.svg b/src/webui/static/img/icons/dashboard.svg
new file mode 100644
index 00000000..0ec39729
--- /dev/null
+++ b/src/webui/static/img/icons/dashboard.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/dhcp.svg b/src/webui/static/img/icons/dhcp.svg
new file mode 100644
index 00000000..30823ecf
--- /dev/null
+++ b/src/webui/static/img/icons/dhcp.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/firewall.svg b/src/webui/static/img/icons/firewall.svg
new file mode 100644
index 00000000..e4058c25
--- /dev/null
+++ b/src/webui/static/img/icons/firewall.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/firmware.svg b/src/webui/static/img/icons/firmware.svg
new file mode 100644
index 00000000..a7669492
--- /dev/null
+++ b/src/webui/static/img/icons/firmware.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-bridge.svg b/src/webui/static/img/icons/iface-bridge.svg
new file mode 100644
index 00000000..4dde450f
--- /dev/null
+++ b/src/webui/static/img/icons/iface-bridge.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-container.svg b/src/webui/static/img/icons/iface-container.svg
new file mode 100644
index 00000000..a6389999
--- /dev/null
+++ b/src/webui/static/img/icons/iface-container.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-ethernet.svg b/src/webui/static/img/icons/iface-ethernet.svg
new file mode 100644
index 00000000..6c0ee150
--- /dev/null
+++ b/src/webui/static/img/icons/iface-ethernet.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-gre.svg b/src/webui/static/img/icons/iface-gre.svg
new file mode 100644
index 00000000..0c966494
--- /dev/null
+++ b/src/webui/static/img/icons/iface-gre.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-lag.svg b/src/webui/static/img/icons/iface-lag.svg
new file mode 100644
index 00000000..64d060d9
--- /dev/null
+++ b/src/webui/static/img/icons/iface-lag.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-veth.svg b/src/webui/static/img/icons/iface-veth.svg
new file mode 100644
index 00000000..62c3728f
--- /dev/null
+++ b/src/webui/static/img/icons/iface-veth.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-vlan.svg b/src/webui/static/img/icons/iface-vlan.svg
new file mode 100644
index 00000000..379524d9
--- /dev/null
+++ b/src/webui/static/img/icons/iface-vlan.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-vxlan.svg b/src/webui/static/img/icons/iface-vxlan.svg
new file mode 100644
index 00000000..2ce493b9
--- /dev/null
+++ b/src/webui/static/img/icons/iface-vxlan.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-wifi.svg b/src/webui/static/img/icons/iface-wifi.svg
new file mode 100644
index 00000000..a344e5a3
--- /dev/null
+++ b/src/webui/static/img/icons/iface-wifi.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/iface-wireguard.svg b/src/webui/static/img/icons/iface-wireguard.svg
new file mode 100644
index 00000000..b321cd2d
--- /dev/null
+++ b/src/webui/static/img/icons/iface-wireguard.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/interfaces.svg b/src/webui/static/img/icons/interfaces.svg
new file mode 100644
index 00000000..6c0ee150
--- /dev/null
+++ b/src/webui/static/img/icons/interfaces.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/keystore.svg b/src/webui/static/img/icons/keystore.svg
new file mode 100644
index 00000000..1a8c6f99
--- /dev/null
+++ b/src/webui/static/img/icons/keystore.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/lldp.svg b/src/webui/static/img/icons/lldp.svg
new file mode 100644
index 00000000..b6e2ac80
--- /dev/null
+++ b/src/webui/static/img/icons/lldp.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/ntp.svg b/src/webui/static/img/icons/ntp.svg
new file mode 100644
index 00000000..b6461c17
--- /dev/null
+++ b/src/webui/static/img/icons/ntp.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/routing.svg b/src/webui/static/img/icons/routing.svg
new file mode 100644
index 00000000..f0ae57aa
--- /dev/null
+++ b/src/webui/static/img/icons/routing.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/status-down.svg b/src/webui/static/img/icons/status-down.svg
new file mode 100644
index 00000000..aeb3a283
--- /dev/null
+++ b/src/webui/static/img/icons/status-down.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/status-up.svg b/src/webui/static/img/icons/status-up.svg
new file mode 100644
index 00000000..6a6a46af
--- /dev/null
+++ b/src/webui/static/img/icons/status-up.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/status-warning.svg b/src/webui/static/img/icons/status-warning.svg
new file mode 100644
index 00000000..421cf3db
--- /dev/null
+++ b/src/webui/static/img/icons/status-warning.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/wifi.svg b/src/webui/static/img/icons/wifi.svg
new file mode 100644
index 00000000..a344e5a3
--- /dev/null
+++ b/src/webui/static/img/icons/wifi.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/icons/wireguard.svg b/src/webui/static/img/icons/wireguard.svg
new file mode 100644
index 00000000..b321cd2d
--- /dev/null
+++ b/src/webui/static/img/icons/wireguard.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/webui/static/img/jack.png b/src/webui/static/img/jack.png
new file mode 100644
index 00000000..b2026c16
Binary files /dev/null and b/src/webui/static/img/jack.png differ
diff --git a/src/webui/static/img/logo.png b/src/webui/static/img/logo.png
new file mode 100644
index 00000000..169dfacc
Binary files /dev/null and b/src/webui/static/img/logo.png differ
diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js
new file mode 100644
index 00000000..565e0755
--- /dev/null
+++ b/src/webui/static/js/app.js
@@ -0,0 +1,212 @@
+(function () {
+ function getCSRFToken() {
+ var meta = document.querySelector('meta[name="csrf-token"]');
+ return meta ? meta.getAttribute('content') : '';
+ }
+
+ function initCSRF() {
+ var token = getCSRFToken();
+ if (!token || !window.htmx || !document.body) {
+ return;
+ }
+ document.body.addEventListener('htmx:configRequest', function (evt) {
+ evt.detail.headers['X-CSRF-Token'] = token;
+ });
+ }
+
+ function initDeviceStatusBanner() {
+ var banner = document.getElementById('conn-banner');
+ if (!banner) {
+ return;
+ }
+ var down = false;
+ function check() {
+ fetch('/device-status').then(function (r) {
+ if (!r.ok) {
+ throw r;
+ }
+ if (down) {
+ down = false;
+ banner.hidden = true;
+ }
+ }).catch(function () {
+ if (!down) {
+ down = true;
+ banner.hidden = false;
+ }
+ });
+ }
+ setInterval(check, 10000);
+ }
+
+ function initProgressBars(root) {
+ var scope = root || document;
+ var bars = scope.querySelectorAll('.progress-bar[data-progress]');
+ bars.forEach(function (bar) {
+ var val = parseInt(bar.getAttribute('data-progress'), 10);
+ if (!isNaN(val) && val >= 0) {
+ bar.style.width = Math.min(val, 100) + '%';
+ }
+ });
+ }
+
+ function startRebootOverlay(root) {
+ var scope = root || document;
+ var overlay = scope.querySelector('.reboot-overlay');
+ if (!overlay || overlay.dataset.init === 'true') {
+ return;
+ }
+ overlay.dataset.init = 'true';
+
+ var timeout = parseInt(overlay.getAttribute('data-timeout'), 10);
+ if (isNaN(timeout) || timeout <= 0) {
+ timeout = 120000;
+ }
+ var interval = parseInt(overlay.getAttribute('data-interval'), 10);
+ if (isNaN(interval) || interval <= 0) {
+ interval = 2000;
+ }
+ var start = Date.now();
+ var status = overlay.querySelector('#reboot-status');
+ var returnTo = window.location.pathname + window.location.search;
+
+ function waitDown() {
+ if (Date.now() - start > timeout) {
+ if (status) {
+ status.textContent = 'Timeout - device did not shut down within 2 minutes.';
+ status.classList.add('is-error');
+ }
+ return;
+ }
+ fetch('/device-status').then(function (r) {
+ if (r.ok) {
+ setTimeout(waitDown, interval);
+ } else {
+ if (status) {
+ status.textContent = 'Device is down, waiting for it to come back...';
+ }
+ setTimeout(waitUp, interval);
+ }
+ }).catch(function () {
+ if (status) {
+ status.textContent = 'Device is down, waiting for it to come back...';
+ }
+ setTimeout(waitUp, interval);
+ });
+ }
+
+ function waitUp() {
+ if (Date.now() - start > timeout) {
+ if (status) {
+ status.textContent = 'Timeout - device did not respond within 2 minutes.';
+ status.classList.add('is-error');
+ }
+ return;
+ }
+ fetch('/device-status').then(function (r) {
+ if (r.ok) {
+ window.location = returnTo || '/';
+ } else {
+ setTimeout(waitUp, interval);
+ }
+ }).catch(function () {
+ setTimeout(waitUp, interval);
+ });
+ }
+
+ setTimeout(waitDown, interval);
+ }
+
+ function initDynamicUI(root) {
+ initProgressBars(root);
+ startRebootOverlay(root);
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ initCSRF();
+ initDeviceStatusBanner();
+ initDynamicUI(document);
+ });
+
+ if (window.htmx && document.body) {
+ document.body.addEventListener('htmx:afterSwap', function (evt) {
+ initDynamicUI(evt.target);
+ });
+ }
+})();
+
+// Dark mode toggle (auto / light / dark)
+(function() {
+ function getTheme() {
+ var m = document.cookie.split(';').map(function(c){return c.trim();}).find(function(c){return c.indexOf('theme=')===0;});
+ return m ? m.split('=')[1] : null;
+ }
+ function applyTheme(mode) {
+ var el = document.documentElement;
+ el.classList.remove('dark', 'light');
+ if (mode === 'dark') {
+ el.classList.add('dark');
+ } else if (mode === 'light') {
+ el.classList.add('light');
+ }
+ updateIcon(mode || 'auto');
+ }
+ function updateIcon(mode) {
+ var btn = document.getElementById('theme-toggle');
+ if (!btn) return;
+ var icons = btn.querySelectorAll('svg');
+ for (var i = 0; i < icons.length; i++) icons[i].style.display = 'none';
+ var id = mode === 'dark' ? 'icon-dark' : mode === 'light' ? 'icon-light' : 'icon-auto';
+ var active = btn.querySelector('#' + id);
+ if (active) active.style.display = '';
+ btn.setAttribute('aria-label',
+ mode === 'dark' ? 'Theme: dark (click for auto)' :
+ mode === 'light' ? 'Theme: light (click for dark)' :
+ 'Theme: auto (click for light)');
+ }
+ function setTheme(mode) {
+ if (mode) {
+ document.cookie = 'theme=' + mode + '; path=/; max-age=31536000; samesite=lax';
+ } else {
+ document.cookie = 'theme=; path=/; max-age=0; samesite=lax';
+ }
+ applyTheme(mode);
+ }
+ var saved = getTheme();
+ applyTheme(saved);
+ document.addEventListener('DOMContentLoaded', function() {
+ var btn = document.getElementById('theme-toggle');
+ if (btn) btn.addEventListener('click', function() {
+ var cur = getTheme();
+ if (!cur) setTheme('light');
+ else if (cur === 'light') setTheme('dark');
+ else setTheme(null);
+ });
+ });
+})();
+
+// Sidebar toggle (mobile)
+(function() {
+ document.addEventListener('DOMContentLoaded', function() {
+ var btn = document.getElementById('hamburger-btn');
+ if (!btn) return;
+ btn.addEventListener('click', function() {
+ var open = document.body.classList.toggle('sidebar-open');
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ // Close sidebar when clicking the overlay (::after pseudo)
+ document.body.addEventListener('click', function(e) {
+ if (document.body.classList.contains('sidebar-open') && e.target === document.body) {
+ document.body.classList.remove('sidebar-open');
+ btn.setAttribute('aria-expanded', 'false');
+ }
+ });
+ // Close sidebar when a nav link is clicked (htmx navigation)
+ document.addEventListener('htmx:beforeRequest', function() {
+ if (document.body.classList.contains('sidebar-open')) {
+ document.body.classList.remove('sidebar-open');
+ if (btn) btn.setAttribute('aria-expanded', 'false');
+ }
+ });
+ });
+})();
diff --git a/src/webui/static/js/htmx.min.js b/src/webui/static/js/htmx.min.js
new file mode 100644
index 00000000..59937d71
--- /dev/null
+++ b/src/webui/static/js/htmx.min.js
@@ -0,0 +1 @@
+var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=cn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true},parseInterval:null,_:null,version:"2.0.4"};Q.onLoad=j;Q.process=kt;Q.on=ye;Q.off=be;Q.trigger=he;Q.ajax=Rn;Q.find=u;Q.findAll=x;Q.closest=g;Q.remove=z;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=$e;Q.defineExtension=Fn;Q.removeExtension=Bn;Q.logAll=V;Q.logNone=_;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:le,canAccessLocalStorage:B,findThisElement:Se,filterValues:hn,swap:$e,hasAttribute:s,getAttributeValue:te,getClosestAttributeValue:re,getClosestMatch:o,getExpressionVars:En,getHeaders:fn,getInputValues:cn,getInternalData:ie,getSwapSpecification:gn,getTriggerSpecs:st,getTarget:Ee,makeFragment:P,mergeObjects:ce,makeSettleInfo:xn,oobSwap:He,querySelectorExt:ae,settleImmediately:Kt,shouldCancel:ht,triggerEvent:he,triggerErrorEvent:fe,withExtensions:Ft};const r=["get","post","put","delete","patch"];const H=r.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function te(e,t){return ee(e,t)||ee(e,"data-"+t)}function c(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ne(){return document}function m(e,t){return e.getRootNode?e.getRootNode({composed:t}):ne()}function o(e,t){while(e&&!t(e)){e=c(e)}return e||null}function i(e,t,n){const r=te(t,n);const o=te(t,"hx-disinherit");var i=te(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function re(t,n){let r=null;o(t,function(e){return!!(r=i(t,ue(e),n))});if(r!=="unset"){return r}}function h(e,t){const n=e instanceof Element&&(e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector);return!!n&&n.call(e,t)}function T(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function q(e){const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function A(e){const t=ne().createElement("script");se(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function N(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(N(e)){const t=A(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){O(e)}finally{e.remove()}}})}function P(e){const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=T(t);let r;if(n==="html"){r=new DocumentFragment;const i=q(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=q(t);L(r,i.body);r.title=i.title}else{const i=q(''+t+"");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function oe(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function k(e){return typeof e==="function"}function D(e){return t(e,"Object")}function ie(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function M(t){const n=[];if(t){for(let e=0;e=0}function le(e){return e.getRootNode({composed:true})===document}function F(e){return e.trim().split(/\s+/)}function ce(e,t){for(const n in t){if(t.hasOwnProperty(n)){e[n]=t[n]}}return e}function S(e){try{return JSON.parse(e)}catch(e){O(e);return null}}function B(){const e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function U(t){try{const e=new URL(t);if(e){t=e.pathname+e.search}if(!/^\/$/.test(t)){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return vn(ne().body,function(){return eval(e)})}function j(t){const e=Q.on("htmx:load",function(e){t(e.detail.elt)});return e}function V(){Q.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Q.logger=null}function u(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return u(ne(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ne(),e)}}function E(){return window}function z(e,t){e=y(e);if(t){E().setTimeout(function(){z(e);e=null},t)}else{c(e).removeChild(e)}}function ue(e){return e instanceof Element?e:null}function $(e){return e instanceof HTMLElement?e:null}function J(e){return typeof e==="string"?e:null}function f(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function K(e,t,n){e=ue(y(e));if(!e){return}if(n){E().setTimeout(function(){K(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function G(e,t,n){let r=ue(y(e));if(!r){return}if(n){E().setTimeout(function(){G(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function W(e,t){e=y(e);e.classList.toggle(t)}function Z(e,t){e=y(e);se(e.parentElement.children,function(e){G(e,t)});K(ue(e),t)}function g(e,t){e=ue(y(e));if(e&&e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&ue(c(e)));return null}}function l(e,t){return e.substring(0,t.length)===t}function Y(e,t){return e.substring(e.length-t.length)===t}function ge(e){const t=e.trim();if(l(t,"<")&&Y(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function p(t,r,n){if(r.indexOf("global ")===0){return p(t,r.slice(7),true)}t=y(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=ge(o.shift());let e;if(r.indexOf("closest ")===0){e=g(ue(t),ge(r.substr(8)))}else if(r.indexOf("find ")===0){e=u(f(t),ge(r.substr(5)))}else if(r==="next"||r==="nextElementSibling"){e=ue(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,ge(r.substr(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=ue(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=me(t,ge(r.substr(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=m(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=f(m(t,!!n));i.push(...M(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=f(m(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ae(e,t){if(typeof e!=="string"){return p(e,t)[0]}else{return p(ne().body,e)[0]}}function y(e,t){if(typeof e==="string"){return u(f(t)||document,e)}else{return e}}function xe(e,t,n,r){if(k(t)){return{target:ne().body,event:J(e),listener:t,options:n}}else{return{target:y(e),event:J(t),listener:n,options:r}}}function ye(t,n,r,o){Vn(function(){const e=xe(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=k(n);return e?n:r}function be(t,n,r){Vn(function(){const e=xe(t,n,r);e.target.removeEventListener(e.event,e.listener)});return k(n)?n:r}const ve=ne().createElement("output");function we(e,t){const n=re(e,t);if(n){if(n==="this"){return[Se(e,t)]}else{const r=p(e,n);if(r.length===0){O('The selector "'+n+'" on '+t+" returned no matches!");return[ve]}else{return r}}}}function Se(e,t){return ue(o(e,function(e){return te(ue(e),t)!=null}))}function Ee(e){const t=re(e,"hx-target");if(t){if(t==="this"){return Se(e,"hx-target")}else{return ae(e,t)}}else{const n=ie(e);if(n.boosted){return ne().body}else{return e}}}function Ce(t){const n=Q.config.attributesToSettle;for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=p(t,n,false);if(r){se(r,function(e){let t;const n=o.cloneNode(true);t=ne().createDocumentFragment();t.appendChild(n);if(!Re(s,e)){t=f(n)}const r={shouldSwap:true,target:e,fragment:t};if(!he(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){qe(t);_e(s,e,e,t,i);Te()}se(i.elts,function(e){he(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);fe(ne().body,"htmx:oobErrorNoTarget",{content:o})}return e}function Te(){const e=u("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=u("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function qe(e){se(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=te(e,"id");const n=ne().getElementById(t);if(n!=null){if(e.moveBefore){let e=u("#--htmx-preserve-pantry--");if(e==null){ne().body.insertAdjacentHTML("afterend","");e=u("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function Le(l,e,c){se(e.querySelectorAll("[id]"),function(t){const n=ee(t,"id");if(n&&n.length>0){const r=n.replace("'","\\'");const o=t.tagName.replace(":","\\:");const e=f(l);const i=e&&e.querySelector(o+"[id='"+r+"']");if(i&&i!==e){const s=t.cloneNode();Oe(t,i);c.tasks.push(function(){Oe(t,s)})}}})}function Ae(e){return function(){G(e,Q.config.addedClass);kt(ue(e));Ne(f(e));he(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=$(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function a(e,t,n,r){Le(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;K(ue(o),Q.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function $e(e,t,r,o){if(!o){o={}}e=y(e);const i=o.contextElement?m(o.contextElement,false):ne();const n=document.activeElement;let s={};try{s={elt:n,start:n?n.selectionStart:null,end:n?n.selectionEnd:null}}catch(e){}const l=xn(e);if(r.swapStyle==="textContent"){e.textContent=t}else{let n=P(t);l.title=n.title;if(o.selectOOB){const u=o.selectOOB.split(",");for(let t=0;t0){E().setTimeout(c,r.settleDelay)}else{c()}}function Je(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=S(r);for(const i in o){if(o.hasOwnProperty(i)){let e=o[i];if(D(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}he(n,i,e)}}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=vn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){fe(ne().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(tt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function C(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function rt(e){let t;if(e.length>0&&Ye.test(e[0])){e.shift();t=C(e,Qe).trim();e.shift()}else{t=C(e,v)}return t}const ot="input, textarea, select";function it(e,t,n){const r=[];const o=et(t);do{C(o,w);const l=o.length;const c=C(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};C(o,w);u.pollInterval=d(C(o,/[,\[\s]/));C(o,w);var i=nt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=nt(e,o,"event");if(i){a.eventFilter=i}C(o,w);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(C(o,v))}else if(f==="from"&&o[0]===":"){o.shift();if(Ye.test(o[0])){var s=rt(o)}else{var s=C(o,v);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=rt(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=rt(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(C(o,v))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=C(o,v)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=rt(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=C(o,v)}else{fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}r.push(a)}}if(o.length===l){fe(e,"htmx:syntax:error",{token:o.shift()})}C(o,w)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function st(e){const t=te(e,"hx-trigger");let n=[];if(t){const r=Q.config.triggerSpecsCache;n=r&&r[t]||it(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,ot)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function lt(e){ie(e).cancelled=true}function ct(e,t,n){const r=ie(e);r.timeout=E().setTimeout(function(){if(le(e)&&r.cancelled!==true){if(!gt(n,e,Mt("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ct(e,t,n)}},n.pollInterval)}function ut(e){return location.hostname===e.hostname&&ee(e,"href")&&ee(e,"href").indexOf("#")!==0}function at(e){return g(e,Q.config.disableSelector)}function ft(t,n,e){if(t instanceof HTMLAnchorElement&&ut(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(ee(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=ee(t,"href")}else{const i=ee(t,"method");r=i?i.toLowerCase():"get";o=ee(t,"action");if(o==null||o===""){o=ne().location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){pt(t,function(e,t){const n=ue(e);if(at(n)){b(n);return}de(r,o,n,t)},n,e,true)})}}function ht(e,t){const n=ue(t);if(!n){return false}if(e.type==="submit"||e.type==="click"){if(n.tagName==="FORM"){return true}if(h(n,'input[type="submit"], button')&&(h(n,"[form]")||g(n,"form")!==null)){return true}if(n instanceof HTMLAnchorElement&&n.href&&(n.getAttribute("href")==="#"||n.getAttribute("href").indexOf("#")!==0)){return true}}return false}function dt(e,t){return ie(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;fe(ne().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function pt(l,c,e,u,a){const f=ie(l);let t;if(u.from){t=p(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}se(t,function(i){const s=function(e){if(!le(l)){i.removeEventListener(u.trigger,s);return}if(dt(l,e)){return}if(a||ht(e,l)){e.preventDefault()}if(gt(u,l,e)){return}const t=ie(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(ue(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=event.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){he(l,"htmx:trigger");c(l,e);f.throttle=E().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=E().setTimeout(function(){he(l,"htmx:trigger");c(l,e)},u.delay)}else{he(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let mt=false;let xt=null;function yt(){if(!xt){xt=function(){mt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(mt){mt=false;se(ne().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){bt(e)})}},200)}}function bt(e){if(!s(e,"data-hx-revealed")&&X(e)){e.setAttribute("data-hx-revealed","true");const t=ie(e);if(t.initHash){he(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){he(e,"revealed")},{once:true})}}}function vt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;he(e,"htmx:trigger");t(e)}};if(r>0){E().setTimeout(o,r)}else{o()}}function wt(t,n,e){let i=false;se(r,function(r){if(s(t,"hx-"+r)){const o=te(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){St(t,e,n,function(e,t){const n=ue(e);if(g(n,Q.config.disableSelector)){b(n);return}de(r,o,n,t)})})}});return i}function St(r,e,t,n){if(e.trigger==="revealed"){yt();pt(r,n,t,e);bt(ue(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ae(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ct(ue(r),n,e)}else{pt(r,n,t,e)}}function Et(e){const t=ue(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function Tt(e){const t=g(ue(e.target),"button, input[type='submit']");const n=Lt(e);if(n){n.lastButtonClicked=t}}function qt(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Lt(e){const t=g(ue(e.target),"button, input[type='submit']");if(!t){return}const n=y("#"+ee(t,"form"),t.getRootNode())||g(t,"form");if(!n){return}return ie(n)}function At(e){e.addEventListener("click",Tt);e.addEventListener("focusin",Tt);e.addEventListener("focusout",qt)}function Nt(t,e,n){const r=ie(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){vn(t,function(){if(at(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function It(t){ke(t);for(let e=0;eQ.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){fe(ne().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Vt(t){if(!B()){return null}t=U(t);const n=S(localStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){he(ne().body,"htmx:historyCacheMissLoad",i);const e=P(this.response);const t=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;const n=Ut();const r=xn(n);kn(e.title);qe(e);Ve(n,t,r);Te();Kt(r.tasks);Bt=o;he(ne().body,"htmx:historyRestore",{path:o,cacheMiss:true,serverResponse:this.response})}else{fe(ne().body,"htmx:historyCacheMissLoadError",i)}};e.send()}function Wt(e){zt();e=e||location.pathname+location.search;const t=Vt(e);if(t){const n=P(t.content);const r=Ut();const o=xn(r);kn(t.title);qe(n);Ve(r,n,o);Te();Kt(o.tasks);E().setTimeout(function(){window.scrollTo(0,t.scroll)},0);Bt=e;he(ne().body,"htmx:historyRestore",{path:e,item:t})}else{if(Q.config.refreshOnHistoryMiss){window.location.reload(true)}else{Gt(e)}}}function Zt(e){let t=we(e,"hx-indicator");if(t==null){t=[e]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList.add.call(e.classList,Q.config.requestClass)});return t}function Yt(e){let t=we(e,"hx-disabled-elt");if(t==null){t=[]}se(t,function(e){const t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")});return t}function Qt(e,t){se(e.concat(t),function(e){const t=ie(e);t.requestCount=(t.requestCount||1)-1});se(e,function(e){const t=ie(e);if(t.requestCount===0){e.classList.remove.call(e.classList,Q.config.requestClass)}});se(t,function(e){const t=ie(e);if(t.requestCount===0){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function en(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);se(e,e=>r.append(t,e))}}function on(t,n,r,o,i){if(o==null||en(t,o)){return}else{t.push(o)}if(tn(o)){const s=ee(o,"name");let e=o.value;if(o instanceof HTMLSelectElement&&o.multiple){e=M(o.querySelectorAll("option:checked")).map(function(e){return e.value})}if(o instanceof HTMLInputElement&&o.files){e=M(o.files)}nn(s,e,n);if(i){sn(o,r)}}if(o instanceof HTMLFormElement){se(o.elements,function(e){if(t.indexOf(e)>=0){rn(e.name,e.value,n)}else{t.push(e)}if(i){sn(e,r)}});new FormData(o).forEach(function(e,t){if(e instanceof File&&e.name===""){return}nn(t,e,n)})}}function sn(e,t){const n=e;if(n.willValidate){he(n,"htmx:validation:validate");if(!n.checkValidity()){t.push({elt:n,message:n.validationMessage,validity:n.validity});he(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})}}}function ln(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function cn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=ie(e);if(s.lastButtonClicked&&!le(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||te(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){on(n,o,i,g(e,"form"),l)}on(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&ee(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=ee(u,"name");nn(a,u.value,o)}const c=we(e,"hx-include");se(c,function(e){on(n,r,i,ue(e),l);if(!h(e,"form")){se(f(e).querySelectorAll(ot),function(e){on(n,r,i,e,l)})}});ln(r,o);return{errors:i,formData:r,values:An(r)}}function un(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function an(e){e=qn(e);let n="";e.forEach(function(e,t){n=un(n,t,e)});return n}function fn(e,t,n){const r={"HX-Request":"true","HX-Trigger":ee(e,"id"),"HX-Trigger-Name":ee(e,"name"),"HX-Target":te(t,"id"),"HX-Current-URL":ne().location.href};bn(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(ie(e).boosted){r["HX-Boosted"]="true"}return r}function hn(n,e){const t=re(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){se(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;se(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function dn(e){return!!ee(e,"href")&&ee(e,"href").indexOf("#")>=0}function gn(e,t){const n=t||re(e,"hx-swap");const r={swapStyle:ie(e).boosted?"innerHTML":Q.config.defaultSwapStyle,swapDelay:Q.config.defaultSwapDelay,settleDelay:Q.config.defaultSettleDelay};if(Q.config.scrollIntoViewOnBoost&&ie(e).boosted&&!dn(e)){r.show="top"}if(n){const s=F(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{O("Unknown modifier in hx-swap: "+l)}}}}return r}function pn(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&ee(e,"enctype")==="multipart/form-data"}function mn(t,n,r){let o=null;Ft(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(pn(n)){return ln(new FormData,qn(r))}else{return an(r)}}}function xn(e){return{tasks:[],elts:[e]}}function yn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=ue(ae(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=ue(ae(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Q.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Q.config.scrollBehavior})}}}function bn(r,e,o,i){if(i==null){i={}}if(r==null){return i}const s=te(r,e);if(s){let e=s.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=vn(r,function(){return Function("return ("+e+")")()},{})}else{n=S(e)}for(const l in n){if(n.hasOwnProperty(l)){if(i[l]==null){i[l]=n[l]}}}}return bn(ue(c(r)),e,o,i)}function vn(e,t,n){if(Q.config.allowEval){return t()}else{fe(e,"htmx:evalDisallowedError");return n}}function wn(e,t){return bn(e,"hx-vars",true,t)}function Sn(e,t){return bn(e,"hx-vals",false,t)}function En(e){return ce(wn(e),Sn(e))}function Cn(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function On(t){if(t.responseURL&&typeof URL!=="undefined"){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){fe(ne().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function R(e,t){return t.test(e.getAllResponseHeaders())}function Rn(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return de(t,n,null,null,{targetOverride:y(r)||ve,returnPromise:true})}else{let e=y(r.target);if(r.target&&!e||r.source&&!e&&!y(r.source)){e=ve}return de(t,n,y(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true})}}else{return de(t,n,null,null,{returnPromise:true})}}function Hn(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function Tn(e,t,n){let r;let o;if(typeof URL==="function"){o=new URL(t,document.location.href);const i=document.location.origin;r=i===o.origin}else{o=t;r=l(t,document.location.origin)}if(Q.config.selfRequestsOnly){if(!r){return false}}return he(e,"htmx:validateUrl",ce({url:o,sameHost:r},n))}function qn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n in e){if(e.hasOwnProperty(n)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}}return t}function Ln(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function An(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}else{return e[t]}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Ln(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function de(t,n,r,o,i,D){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ne().body}const M=i.handler||Dn;const X=i.select||null;if(!le(r)){oe(s);return e}const c=i.targetOverride||ue(Ee(r));if(c==null||c==ve){fe(r,"htmx:targetError",{target:te(r,"hx-target")});oe(l);return e}let u=ie(r);const a=u.lastButtonClicked;if(a){const L=ee(a,"formaction");if(L!=null){n=L}const A=ee(a,"formmethod");if(A!=null){if(A.toLowerCase()!=="dialog"){t=A}}}const f=re(r,"hx-confirm");if(D===undefined){const K=function(e){return de(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(he(r,"htmx:confirm",G)===false){oe(s);return e}}let h=r;let d=re(r,"hx-sync");let g=null;let F=false;if(d){const N=d.split(":");const I=N[0].trim();if(I==="this"){h=Se(r,"hx-sync")}else{h=ue(ae(r,I))}d=(N[1]||"drop").trim();u=ie(h);if(d==="drop"&&u.xhr&&u.abortable!==true){oe(s);return e}else if(d==="abort"){if(u.xhr){oe(s);return e}else{F=true}}else if(d==="replace"){he(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");g=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){he(h,"htmx:abort")}else{if(g==null){if(o){const P=ie(o);if(P&&P.triggerSpec&&P.triggerSpec.queue){g=P.triggerSpec.queue}}if(g==null){g="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(g==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="all"){u.queuedRequests.push(function(){de(t,n,r,o,i)})}else if(g==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){de(t,n,r,o,i)})}oe(s);return e}}const p=new XMLHttpRequest;u.xhr=p;u.abortable=F;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const B=re(r,"hx-prompt");if(B){var x=prompt(B);if(x===null||!he(r,"htmx:prompt",{prompt:x,target:c})){oe(s);m();return e}}if(f&&!D){if(!confirm(f)){oe(s);m();return e}}let y=fn(r,c,x);if(t!=="get"&&!pn(r)){y["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){y=ce(y,i.headers)}const U=cn(r,t);let b=U.errors;const j=U.formData;if(i.values){ln(j,qn(i.values))}const V=qn(En(r));const v=ln(j,V);let w=hn(v,r);if(Q.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",ee(c,"id")||"true")}if(n==null||n===""){n=ne().location.href}const S=bn(r,"hx-request");const _=ie(r).boosted;let E=Q.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:_,useUrlParams:E,formData:w,parameters:An(w),unfilteredFormData:v,unfilteredParameters:An(v),headers:y,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Q.config.withCredentials,timeout:i.timeout||S.timeout||Q.config.timeout,path:n,triggeringEvent:o};if(!he(r,"htmx:configRequest",C)){oe(s);m();return e}n=C.path;t=C.verb;y=C.headers;w=qn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){he(r,"htmx:validation:halted",C);oe(s);m();return e}const z=n.split("#");const $=z[0];const O=z[1];let R=n;if(E){R=$;const Z=!w.keys().next().done;if(Z){if(R.indexOf("?")<0){R+="?"}else{R+="&"}R+=an(w);if(O){R+="#"+O}}}if(!Tn(r,R,C)){fe(r,"htmx:invalidPath",C);oe(l);return e}p.open(t.toUpperCase(),R,true);p.overrideMimeType("text/html");p.withCredentials=C.withCredentials;p.timeout=C.timeout;if(S.noHeaders){}else{for(const k in y){if(y.hasOwnProperty(k)){const Y=y[k];Cn(p,k,Y)}}}const H={xhr:p,target:c,requestConfig:C,etc:i,boosted:_,select:X,pathInfo:{requestPath:n,finalRequestPath:R,responsePath:null,anchor:O}};p.onload=function(){try{const t=Hn(r);H.pathInfo.responsePath=On(p);M(r,H);if(H.keepIndicators!==true){Qt(T,q)}he(r,"htmx:afterRequest",H);he(r,"htmx:afterOnLoad",H);if(!le(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(le(n)){e=n}}if(e){he(e,"htmx:afterRequest",H);he(e,"htmx:afterOnLoad",H)}}oe(s);m()}catch(e){fe(r,"htmx:onLoadError",ce({error:e},H));throw e}};p.onerror=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendError",H);oe(l);m()};p.onabort=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:sendAbort",H);oe(l);m()};p.ontimeout=function(){Qt(T,q);fe(r,"htmx:afterRequest",H);fe(r,"htmx:timeout",H);oe(l);m()};if(!he(r,"htmx:beforeRequest",H)){oe(s);m();return e}var T=Zt(r);var q=Yt(r);se(["loadstart","loadend","progress","abort"],function(t){se([p,p.upload],function(e){e.addEventListener(t,function(e){he(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});he(r,"htmx:beforeSend",H);const J=E?null:mn(p,r,w);p.send(J);return e}function Nn(e,t){const n=t.xhr;let r=null;let o=null;if(R(n,/HX-Push:/i)){r=n.getResponseHeader("HX-Push");o="push"}else if(R(n,/HX-Push-Url:/i)){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(R(n,/HX-Replace-Url:/i)){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=re(e,"hx-push-url");const c=re(e,"hx-replace-url");const u=ie(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function In(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Pn(e){for(var t=0;t0){E().setTimeout(e,x.swapDelay)}else{e()}}if(f){fe(o,"htmx:responseError",ce({error:"Response Status Error Code "+s.status+" from "+i.pathInfo.requestPath},i))}}const Mn={};function Xn(){return{init:function(e){return null},getSelectors:function(){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,n){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,n,r){return false},encodeParameters:function(e,t,n){return null}}}function Fn(e,t){if(t.init){t.init(n)}Mn[e]=ce(Xn(),t)}function Bn(e){delete Mn[e]}function Un(e,n,r){if(n==undefined){n=[]}if(e==undefined){return n}if(r==undefined){r=[]}const t=te(e,"hx-ext");if(t){se(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){r.push(e.slice(7));return}if(r.indexOf(e)<0){const t=Mn[e];if(t&&n.indexOf(t)<0){n.push(t)}}})}return Un(ue(c(e)),n,r)}var jn=false;ne().addEventListener("DOMContentLoaded",function(){jn=true});function Vn(e){if(jn||ne().readyState==="complete"){e()}else{ne().addEventListener("DOMContentLoaded",e)}}function _n(){if(Q.config.includeIndicatorStyles!==false){const e=Q.config.inlineStyleNonce?` nonce="${Q.config.inlineStyleNonce}"`:"";ne().head.insertAdjacentHTML("beforeend","")}}function zn(){const e=ne().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function $n(){const e=zn();if(e){Q.config=ce(Q.config,e)}}Vn(function(){$n();_n();let e=ne().body;kt(e);const t=ne().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.target;const n=ie(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){Wt();se(t,function(e){he(e,"htmx:restored",{document:ne(),triggerEvent:he})})}else{if(n){n(e)}}};E().setTimeout(function(){he(e,"htmx:load",{});e=null},0)});return Q}();
\ No newline at end of file
diff --git a/src/webui/templates/fragments/iface-counters.html b/src/webui/templates/fragments/iface-counters.html
new file mode 100644
index 00000000..e995bb01
--- /dev/null
+++ b/src/webui/templates/fragments/iface-counters.html
@@ -0,0 +1,45 @@
+{{define "iface-counters"}}
+
+
+
+
+ | Counter |
+ RX |
+ TX |
+
+
+
+
+ | Bytes |
+ {{.Counters.RxBytes}} |
+ {{.Counters.TxBytes}} |
+
+
+ | Unicast |
+ {{.Counters.RxUnicast}} |
+ {{.Counters.TxUnicast}} |
+
+
+ | Broadcast |
+ {{.Counters.RxBroadcast}} |
+ {{.Counters.TxBroadcast}} |
+
+
+ | Multicast |
+ {{.Counters.RxMulticast}} |
+ {{.Counters.TxMulticast}} |
+
+
+ | Discards |
+ {{.Counters.RxDiscards}} |
+ {{.Counters.TxDiscards}} |
+
+
+ | Errors |
+ {{.Counters.RxErrors}} |
+ {{.Counters.TxErrors}} |
+
+
+
+
+{{end}}
diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html
new file mode 100644
index 00000000..f2278db1
--- /dev/null
+++ b/src/webui/templates/layouts/base.html
@@ -0,0 +1,36 @@
+{{define "base"}}
+
+
+
+
+
+
+
+
+ {{if .PageTitle}}{{.PageTitle}} — {{end}}Infix
+
+
+
+
+
+
+ Device unreachable
+
+
+ {{template "sidebar" .}}
+
+ {{block "content" .}}{{end}}
+
+
+
+
+{{end}}
diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html
new file mode 100644
index 00000000..6d9318b5
--- /dev/null
+++ b/src/webui/templates/layouts/sidebar.html
@@ -0,0 +1,175 @@
+{{define "sidebar"}}
+
+{{end}}
diff --git a/src/webui/templates/pages/containers.html b/src/webui/templates/pages/containers.html
new file mode 100644
index 00000000..d7a3496a
--- /dev/null
+++ b/src/webui/templates/pages/containers.html
@@ -0,0 +1,52 @@
+{{define "containers.html"}}{{template "base" .}}{{end}}
+
+{{define "content"}}
+{{if .Error}}{{.Error}}
{{end}}
+
+{{if .Containers}}
+
+
+
+
+
+ | Name | Image | Status | CPU | Memory | Uptime |
+
+
+ {{range .Containers}}
+
+ | {{.Name}} |
+ {{.Image}} |
+
+
+ {{.Status}}
+ |
+
+ {{if gt .CPUPct 0}}
+
+ {{.CPUPct}}%
+ {{else}}—{{end}}
+ |
+
+ {{if .MemUsed}}
+
+ {{.MemUsed}} / {{.MemLimit}}
+ {{else}}—{{end}}
+ |
+ {{if .Uptime}}{{.Uptime}}{{else}}—{{end}} |
+
+ {{end}}
+
+
+
+
+{{else if not .Error}}
+
+
+ No containers configured.
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html
new file mode 100644
index 00000000..3a68b52b
--- /dev/null
+++ b/src/webui/templates/pages/dashboard.html
@@ -0,0 +1,103 @@
+{{define "dashboard.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+
+
+
+
+
+ {{if .Hostname}}| Hostname | {{.Hostname}} |
{{end}}
+ {{if .OSName}}| OS | {{.OSName}} {{.OSVersion}} |
{{end}}
+ {{if .Machine}}| Architecture | {{.Machine}} |
{{end}}
+ {{if .Firmware}}| Firmware | {{.Firmware}} |
{{end}}
+ {{if .Uptime}}| Uptime | {{.Uptime}} |
{{end}}
+
+
+
+
+
+
+
+
+ {{if .MemTotal}}
+
+ | Memory |
+
+
+ {{.MemUsed}} / {{.MemTotal}} MiB ({{.MemPercent}}%)
+ |
+
+ {{end}}
+ {{if .Load1}}
+
+ | Load Average |
+
+ {{.Load1}} · {{.Load5}} · {{.Load15}}
+ |
+
+ {{end}}
+
+
+
+
+ {{if .Board.Model}}
+
+
+
+
+ {{if .Board.Model}}| Model | {{.Board.Model}} |
{{end}}
+ {{if .Board.Manufacturer}}| Manufacturer | {{.Board.Manufacturer}} |
{{end}}
+ {{if .Board.SerialNum}}| Serial Number | {{.Board.SerialNum}} |
{{end}}
+ {{if .Board.HardwareRev}}| Hardware Rev | {{.Board.HardwareRev}} |
{{end}}
+ {{if .Board.BaseMAC}}| Base MAC | {{.Board.BaseMAC}} |
{{end}}
+ {{if .Sensors}}
+ {{range .Sensors}}
+ | {{.Name}} | {{.Value}} |
+ {{end}}
+ {{end}}
+
+
+
+ {{end}}
+
+
+{{if .Disks}}
+
+
+
+
+
+
+
+ | Mount |
+ Size |
+ Used |
+ Available |
+ Use% |
+
+
+
+ {{range .Disks}}
+
+ | {{.Mount}} |
+ {{.Size}} |
+ {{.Used}} |
+ {{.Available}} |
+ {{.Percent}}% |
+
+ {{end}}
+
+
+
+
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/dhcp.html b/src/webui/templates/pages/dhcp.html
new file mode 100644
index 00000000..4e2d4ef0
--- /dev/null
+++ b/src/webui/templates/pages/dhcp.html
@@ -0,0 +1,67 @@
+{{define "dhcp.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .DHCP}}
+
+ DHCP Server
+
+
+ | Status |
+
+
+ {{if .DHCP.Enabled}}Enabled{{else}}Disabled{{end}}
+ |
+
+
+
+
+ Discovers{{.DHCP.Stats.InDiscoveries}}
+ Requests{{.DHCP.Stats.InRequests}}
+ Releases{{.DHCP.Stats.InReleases}}
+ Offers{{.DHCP.Stats.OutOffers}}
+ Acks{{.DHCP.Stats.OutAcks}}
+ Naks{{.DHCP.Stats.OutNaks}}
+
+
+ {{if .DHCP.Leases}}
+ Active Leases
+
+
+
+
+ | IP Address |
+ MAC Address |
+ Hostname |
+ Expires |
+ Client ID |
+
+
+
+ {{range .DHCP.Leases}}
+
+ | {{.Address}} |
+ {{.MAC}} |
+ {{if .Hostname}}{{.Hostname}}{{else}}—{{end}} |
+ {{.Expires}} |
+ {{if .ClientID}}{{.ClientID}}{{else}}—{{end}} |
+
+ {{end}}
+
+
+
+ {{else}}
+ No active leases.
+ {{end}}
+
+{{else if not .Error}}
+
+ DHCP server data not available.
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html
new file mode 100644
index 00000000..828278c6
--- /dev/null
+++ b/src/webui/templates/pages/firewall.html
@@ -0,0 +1,122 @@
+{{define "firewall.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+
+
+
+
+
+ | Firewall |
+
+
+ {{.EnabledText}}
+ |
+
+ {{if .DefaultZone}}
+ | Default Zone | {{.DefaultZone}} |
+ {{end}}
+
+ | Lockdown Mode |
+ {{if .Lockdown}}Active{{else}}Inactive{{end}} |
+
+ | Log Denied Traffic | {{.Logging}} |
+
+
+
+
+{{if .Matrix}}
+
+
+
+
+
+
+ | → |
+ {{range .ZoneNames}}{{.}} | {{end}}
+
+
+
+ {{range .Matrix}}
+
+ | {{.Zone}} |
+ {{range .Cells}}{{.Symbol}} | {{end}}
+
+ {{end}}
+
+
+
+ Allow
+ Deny
+
+
+
+{{end}}
+
+{{if .Zones}}
+
+
+
+
+
+
+ | Name |
+ Action |
+ Interfaces |
+ Networks |
+ Services |
+
+
+
+ {{range .Zones}}
+
+ | {{.Name}} |
+ {{.Action}} |
+ {{.Interfaces}} |
+ {{.Networks}} |
+ {{.Services}} |
+
+ {{end}}
+
+
+
+
+{{end}}
+
+{{if .Policies}}
+
+
+
+
+
+
+ | Name |
+ Priority |
+ Action |
+ Ingress |
+ Egress |
+ Services |
+
+
+
+ {{range .Policies}}
+
+ | {{.Name}} |
+ {{.Priority}} |
+ {{.Action}} |
+ {{.Ingress}} |
+ {{.Egress}} |
+ {{.Services}}{{if .Masquerade}} masq{{end}} |
+
+ {{end}}
+
+
+
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/firmware.html b/src/webui/templates/pages/firmware.html
new file mode 100644
index 00000000..5c3e0e17
--- /dev/null
+++ b/src/webui/templates/pages/firmware.html
@@ -0,0 +1,69 @@
+{{define "firmware.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .Message}}
+{{.Message}}
+{{end}}
+
+{{if .Installer}}{{if .Installer.Active}}
+
+
+
+ {{.Installer.Percentage}}% — {{.Installer.Message}}
+
+{{end}}{{end}}
+
+{{if .Installer}}{{if .Installer.LastError}}
+Last error: {{.Installer.LastError}}
+{{end}}{{end}}
+
+
+
+ {{if .Slots}}
+
+
+
+
+ | Slot |
+ State |
+ Version |
+
+
+
+ {{range .Slots}}
+
+ | {{.Name}}{{if .BootName}} ({{.BootName}}){{end}}{{if .Booted}} booted{{end}} |
+ {{.State}} |
+ {{if .Version}}{{.Version}}{{else}}-{{end}} |
+
+ {{end}}
+
+
+
+ {{else}}
+ No firmware slot information available.
+ {{end}}
+
+
+
+{{end}}
diff --git a/src/webui/templates/pages/iface-detail.html b/src/webui/templates/pages/iface-detail.html
new file mode 100644
index 00000000..009ffbb1
--- /dev/null
+++ b/src/webui/templates/pages/iface-detail.html
@@ -0,0 +1,155 @@
+{{define "iface-detail.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+
+
+
+
+
+
+ | Name | {{.Name}} |
+ | Type | {{.Type}} |
+ | Index | {{.IfIndex}} |
+
+ | Status |
+ {{.Status}} |
+
+ {{if .PhysAddr}}| MAC Address | {{.PhysAddr}} |
{{end}}
+ {{if .MTU}}| MTU | {{.MTU}} |
{{end}}
+ {{if .Speed}}| Speed | {{.Speed}} |
{{end}}
+ {{if .Duplex}}| Duplex | {{.Duplex}} |
{{end}}
+ {{if .AutoNeg}}| Auto-negotiation | {{.AutoNeg}} |
{{end}}
+ {{if .WiFiMode}}| Mode | {{.WiFiMode}} |
{{end}}
+ {{if .WiFiSSID}}| SSID | {{.WiFiSSID}} |
{{end}}
+ {{if .WiFiSignal}}| Signal | {{.WiFiSignal}} |
{{end}}
+ {{if .WiFiRxSpeed}}| RX Speed | {{.WiFiRxSpeed}} |
{{end}}
+ {{if .WiFiTxSpeed}}| TX Speed | {{.WiFiTxSpeed}} |
{{end}}
+ {{if .WiFiStationCount}}| Connected Stations | {{.WiFiStationCount}} |
{{end}}
+ {{if .WGPeerSummary}}| Peers | {{.WGPeerSummary}} |
{{end}}
+
+ {{if .Addresses}}
+ Addresses
+
+ {{range .Addresses}}
+
+ | {{.Address}}{{if .Origin}} ({{.Origin}}){{end}} |
+
+ {{end}}
+
+ {{end}}
+
+
+
+
+ {{template "iface-counters" .}}
+
+
+
+{{if .WiFiStations}}
+
+
+
+
+
+
+ | MAC |
+ Signal |
+ Time |
+ RX Pkts |
+ TX Pkts |
+ RX Bytes |
+ TX Bytes |
+ RX Speed |
+ TX Speed |
+
+
+
+ {{range .WiFiStations}}
+
+ | {{.MAC}} |
+ {{if .Signal}}{{.Signal}}{{end}} |
+ {{.Time}} |
+ {{.RxPkts}} |
+ {{.TxPkts}} |
+ {{.RxBytes}} |
+ {{.TxBytes}} |
+ {{.RxSpeed}} |
+ {{.TxSpeed}} |
+
+ {{end}}
+
+
+
+
+{{end}}
+
+{{if .ScanResults}}
+
+
+
+
+
+
+ | SSID |
+ BSSID |
+ Signal |
+ Channel |
+ Security |
+
+
+
+ {{range .ScanResults}}
+
+ | {{if .SSID}}{{.SSID}}{{else}}Hidden{{end}} |
+ {{.BSSID}} |
+ {{if .Signal}}{{.Signal}}{{end}} |
+ {{.Channel}} |
+ {{.Encryption}} |
+
+ {{end}}
+
+
+
+
+{{end}}
+
+{{if .WGPeers}}
+
+
+ {{range .WGPeers}}
+
+
+ | Public Key | {{.PublicKey}} |
+
+ | Status |
+ {{.Status}} |
+
+ {{if .Endpoint}}| Endpoint | {{.Endpoint}} |
{{end}}
+ {{if .Handshake}}| Latest Handshake | {{.Handshake}} |
{{end}}
+ {{if .TxBytes}}| Transfer TX | {{.TxBytes}} |
{{end}}
+ {{if .RxBytes}}| Transfer RX | {{.RxBytes}} |
{{end}}
+
+
+ {{end}}
+
+{{end}}
+
+{{if .EthFrameStats}}
+
+
+
+ {{range .EthFrameStats}}
+
+
- {{.Key}}
+ - {{.Value}}
+
+ {{end}}
+
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/interfaces.html b/src/webui/templates/pages/interfaces.html
new file mode 100644
index 00000000..6d611397
--- /dev/null
+++ b/src/webui/templates/pages/interfaces.html
@@ -0,0 +1,46 @@
+{{define "interfaces.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .Interfaces}}
+
+
+
+
+
+
+ | Name |
+ Type |
+ Status |
+ MAC |
+ Address |
+ Detail |
+
+
+
+ {{range .Interfaces}}
+
+ | {{if .Indent}}{{.Indent}}{{end}}{{.Name}} |
+ {{.Type}} |
+ {{.Status}} |
+ {{.PhysAddr}} |
+ {{range $i, $a := .Addresses}}{{if $i}} {{end}}{{$a.Address}}{{if $a.Origin}} ({{$a.Origin}}){{end}}{{end}} |
+ {{.Detail}} |
+
+ {{end}}
+
+
+
+
+{{else if not .Error}}
+
+
+ No interfaces found.
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/keystore.html b/src/webui/templates/pages/keystore.html
new file mode 100644
index 00000000..6db22639
--- /dev/null
+++ b/src/webui/templates/pages/keystore.html
@@ -0,0 +1,74 @@
+{{define "keystore.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .Empty}}
+
+{{end}}
+
+{{if .SymmetricKeys}}
+
+
+
+
+
+
+ | Name |
+ Format |
+ Value |
+
+
+
+ {{range .SymmetricKeys}}
+
+ | {{.Name}} |
+ {{.Format}} |
+ {{.Value}} |
+
+ {{end}}
+
+
+
+
+{{end}}
+
+{{if .AsymmetricKeys}}
+
+
+
+
+
+
+ | Name |
+ Algorithm |
+ Public Key |
+ {{if (index .AsymmetricKeys 0).PrivateKeyFull}}Private Key | {{end}}
+ Certificates |
+
+
+
+ {{range .AsymmetricKeys}}
+
+ | {{.Name}} |
+ {{.Algorithm}} |
+ {{if .PublicKeyFull}}{{.PublicKeyFull}}{{else}}-{{end}} |
+ {{if .PrivateKeyFull}}
+ {{.PrivateKeyFull}} |
+ {{end}}
+ {{range $i, $c := .Certificates}}{{if $i}}, {{end}}{{$c}}{{end}} |
+
+ {{end}}
+
+
+
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/lldp.html b/src/webui/templates/pages/lldp.html
new file mode 100644
index 00000000..941f6f5c
--- /dev/null
+++ b/src/webui/templates/pages/lldp.html
@@ -0,0 +1,45 @@
+{{define "lldp.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .Neighbors}}
+
+ LLDP Neighbors
+
+
+
+
+ | Local Port |
+ Chassis ID |
+ System Name |
+ Port ID |
+ Port Desc |
+ Mgmt Address |
+
+
+
+ {{range .Neighbors}}
+
+ | {{.LocalPort}} |
+ {{.ChassisID}} |
+ {{if .SystemName}}{{.SystemName}}{{else}}—{{end}} |
+ {{if .PortID}}{{.PortID}}{{else}}—{{end}} |
+ {{if .PortDesc}}{{.PortDesc}}{{else}}—{{end}} |
+ {{if .MgmtAddress}}{{.MgmtAddress}}{{else}}—{{end}} |
+
+ {{end}}
+
+
+
+
+{{else if not .Error}}
+
+ No LLDP neighbors discovered.
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html
new file mode 100644
index 00000000..78d5bb71
--- /dev/null
+++ b/src/webui/templates/pages/login.html
@@ -0,0 +1,40 @@
+{{define "login.html"}}
+
+
+
+
+
+
+
+ Infix — Login
+
+
+
+
+
+
+
+
+

+ {{if .Error}}
+
{{.Error}}
+ {{end}}
+
+
+
+
+
+{{end}}
diff --git a/src/webui/templates/pages/ntp.html b/src/webui/templates/pages/ntp.html
new file mode 100644
index 00000000..ccb7034a
--- /dev/null
+++ b/src/webui/templates/pages/ntp.html
@@ -0,0 +1,74 @@
+{{define "ntp.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .NTP}}
+
+ NTP
+
+
+ | Sync Status |
+
+
+ {{if .NTP.Synchronized}}Synchronized{{else}}Not Synchronized{{end}}
+ |
+
+ {{if .NTP.Stratum}}
+ | Stratum | {{.NTP.Stratum}} |
+ {{end}}
+ {{if .NTP.RefID}}
+ | Reference ID | {{.NTP.RefID}} |
+ {{end}}
+ {{if .NTP.Offset}}
+ | Offset | {{.NTP.Offset}} |
+ {{end}}
+ {{if .NTP.RootDelay}}
+ | Root Delay | {{.NTP.RootDelay}} |
+ {{end}}
+
+
+ {{if .NTP.Associations}}
+ Associations
+
+
+
+
+ | Server |
+ Stratum |
+ Ref ID |
+ Reach |
+ Poll (s) |
+ Offset |
+ Delay |
+
+
+
+ {{range .NTP.Associations}}
+
+ | {{.Address}} |
+ {{.Stratum}} |
+ {{.RefID}} |
+ {{.Reach}} |
+ {{.Poll}} |
+ {{.Offset}} |
+ {{.Delay}} |
+
+ {{end}}
+
+
+
+ {{else}}
+ No associations.
+ {{end}}
+
+{{else if not .Error}}
+
+ NTP data not available.
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/routing.html b/src/webui/templates/pages/routing.html
new file mode 100644
index 00000000..9d9df7a5
--- /dev/null
+++ b/src/webui/templates/pages/routing.html
@@ -0,0 +1,113 @@
+{{define "routing.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+
+
Routing
+ {{if .Error}}
{{.Error}}
{{end}}
+
+
+
Routing Table
+ {{if .Routes}}
+
+
+
+ | Destination |
+ Next Hop |
+ Protocol |
+ Preference |
+ Active |
+
+
+ {{range .Routes}}
+
+ | {{.DestPrefix}} |
+ {{if .NextHopAddr}}{{.NextHopAddr}}{{else}}{{.NextHopIface}}{{end}} |
+
+ {{if eq .Protocol "ospfv2"}}ospfv2
+ {{else if eq .Protocol "static"}}static
+ {{else if eq .Protocol "kernel"}}kernel
+ {{else if eq .Protocol "direct"}}direct
+ {{else}}{{.Protocol}}{{end}}
+ |
+ {{.Preference}} |
+ {{if .Active}}{{else}}{{end}} |
+
+ {{end}}
+
+
+
+ {{else}}
+
No routes found.
+ {{end}}
+
+
+ {{if .HasOSPF}}
+
+
OSPF Neighbors
+ {{if .OSPFNeighbors}}
+
+
+
+ | Router ID |
+ State |
+ Role |
+ Interface |
+ Uptime |
+
+
+ {{range .OSPFNeighbors}}
+
+ | {{.RouterID}} |
+ {{.State}} |
+ {{.Role}} |
+ {{.Interface}} |
+ {{.Uptime}} |
+
+ {{end}}
+
+
+
+ {{else}}
+
No OSPF neighbors.
+ {{end}}
+
+
+
+
OSPF Interfaces
+ {{if .OSPFIfaces}}
+
+
+
+ | Name |
+ State |
+ Cost |
+ DR |
+ BDR |
+
+
+ {{range .OSPFIfaces}}
+
+ | {{.Name}} |
+ {{.State}} |
+ {{.Cost}} |
+ {{.DR}} |
+ {{.BDR}} |
+
+ {{end}}
+
+
+
+ {{else}}
+
No OSPF interfaces.
+ {{end}}
+
+ {{else}}
+
+
OSPF
+
OSPF is not configured on this device.
+
+ {{end}}
+
+{{end}}
diff --git a/src/webui/templates/pages/vpn.html b/src/webui/templates/pages/vpn.html
new file mode 100644
index 00000000..b272d58c
--- /dev/null
+++ b/src/webui/templates/pages/vpn.html
@@ -0,0 +1,58 @@
+{{define "vpn.html"}}{{template "base" .}}{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .Tunnels}}
+{{range .Tunnels}}
+
+
+ {{if .Addresses}}
+
+ {{range .Addresses}}{{.}} {{end}}
+
+ {{end}}
+ {{if .Peers}}
+
+
+
+
+ | Public Key |
+ Endpoint |
+ Status |
+ Last Handshake |
+ RX |
+ TX |
+
+
+
+ {{range .Peers}}
+
+ | {{.PublicKeyShort}} |
+ {{if .Endpoint}}{{.Endpoint}}{{else}}—{{end}} |
+ {{.Status}} |
+ {{.LastHandshake}} |
+ {{.RxBytes}} |
+ {{.TxBytes}} |
+
+ {{end}}
+
+
+
+ {{else}}
+ No peers configured.
+ {{end}}
+
+{{end}}
+{{else if not .Error}}
+
+
+ No WireGuard tunnels configured.
+
+{{end}}
+{{end}}
diff --git a/src/webui/templates/pages/wifi.html b/src/webui/templates/pages/wifi.html
new file mode 100644
index 00000000..58b48553
--- /dev/null
+++ b/src/webui/templates/pages/wifi.html
@@ -0,0 +1,148 @@
+{{define "wifi.html"}}
+{{template "base" .}}
+{{end}}
+
+{{define "content"}}
+{{if .Error}}
+{{.Error}}
+{{end}}
+
+{{if .Radios}}
+{{range .Radios}}
+
+
+
+
+
+ {{if .Manufacturer}}| Manufacturer | {{.Manufacturer}} |
{{end}}
+ {{if .Driver}}| Driver | {{.Driver}} |
{{end}}
+ {{if .Standards}}| Standards | {{.Standards}} |
{{end}}
+ {{if .Band}}| Active Band | {{.Band}} |
{{end}}
+ {{if .Channel}}| Channel | {{.Channel}} |
{{end}}
+ {{if .Frequency}}| Frequency | {{.Frequency}} MHz |
{{end}}
+ {{if .Noise}}| Noise Floor | {{.Noise}} dBm |
{{end}}
+ {{if .MaxAP}}| Max APs | {{.MaxAP}} |
{{end}}
+ {{if .Bands}}
+
+ | Supported Bands |
+
+
+ {{range .Bands}}
+
+ {{.Name}}
+
+ {{end}}
+
+ |
+
+ {{end}}
+
+
+
+
+ {{if .SurveySVG}}
+
+ {{end}}
+
+
+{{range .Interfaces}}
+
+
+
+ {{if eq .Mode "station"}}
+
+
+ {{if .Signal}}| Signal | {{.Signal}} |
{{end}}
+ {{if .RxSpeed}}| RX Speed | {{.RxSpeed}} |
{{end}}
+ {{if .TxSpeed}}| TX Speed | {{.TxSpeed}} |
{{end}}
+
+
+ {{end}}
+
+ {{if and (eq .Mode "ap") .APClients}}
+
+
+
+
+ | MAC Address |
+ Signal |
+ Connected |
+ RX Bytes |
+ TX Bytes |
+ RX Speed |
+ TX Speed |
+
+
+
+ {{range .APClients}}
+
+ | {{.MAC}} |
+ {{if .Signal}}{{.Signal}}{{else}}—{{end}} |
+ {{.ConnTime}} |
+ {{.RxBytes}} |
+ {{.TxBytes}} |
+ {{.RxSpeed}} |
+ {{.TxSpeed}} |
+
+ {{end}}
+
+
+
+ {{else if eq .Mode "ap"}}
+ No stations connected.
+ {{end}}
+
+ {{if and (eq .Mode "station") .ScanResults}}
+
+
+
+
+ | SSID |
+ BSSID |
+ Signal |
+ Channel |
+ Security |
+
+
+
+ {{range .ScanResults}}
+
+ | {{if .SSID}}{{.SSID}}{{else}}Hidden{{end}} |
+ {{.BSSID}} |
+ {{if .Signal}}{{.Signal}}{{else}}—{{end}} |
+ {{.Channel}} |
+ {{.Encryption}} |
+
+ {{end}}
+
+
+
+ {{end}}
+
+{{end}}
+{{end}}
+{{else if not .Error}}
+
+
+ No WiFi radios detected.
+
+{{end}}
+{{end}}