From 54e5743f69ee7c855872c2db8b14d590c79188f8 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 28 Mar 2026 09:21:30 +0100 Subject: [PATCH 01/19] webui: initial import Initial import of https://github.com/kernelkit/webui2 @ e344d3f Signed-off-by: Joachim Wiberg --- board/common/rootfs/etc/default/webui | 2 + .../common/rootfs/etc/nginx/app/restconf.conf | 1 + .../rootfs/etc/nginx/available/default.conf | 5 - .../rootfs/etc/nginx/restconf-access.conf | 3 + board/common/rootfs/etc/nginx/restconf.app | 1 + package/Config.in | 1 + package/webui/Config.in | 8 + package/webui/webui.conf | 9 + package/webui/webui.mk | 23 + package/webui/webui.svc | 3 + src/confd/src/services.c | 22 +- src/webui/.gitignore | 1 + src/webui/LICENSE | 19 + src/webui/Makefile | 15 + src/webui/README.md | 120 ++ src/webui/go.mod | 3 + src/webui/internal/auth/login.go | 131 ++ src/webui/internal/auth/session.go | 3 + src/webui/internal/auth/store.go | 156 +++ src/webui/internal/handlers/capabilities.go | 98 ++ .../internal/handlers/capabilities_test.go | 124 ++ src/webui/internal/handlers/common.go | 21 + src/webui/internal/handlers/containers.go | 186 +++ .../internal/handlers/containers_test.go | 69 ++ src/webui/internal/handlers/dashboard.go | 469 +++++++ src/webui/internal/handlers/dashboard_test.go | 84 ++ src/webui/internal/handlers/dhcp.go | 170 +++ src/webui/internal/handlers/firewall.go | 281 +++++ src/webui/internal/handlers/interfaces.go | 988 +++++++++++++++ src/webui/internal/handlers/keystore.go | 190 +++ src/webui/internal/handlers/lldp.go | 138 +++ src/webui/internal/handlers/ntp.go | 147 +++ src/webui/internal/handlers/routing.go | 301 +++++ src/webui/internal/handlers/routing_test.go | 69 ++ src/webui/internal/handlers/services_test.go | 110 ++ src/webui/internal/handlers/system.go | 213 ++++ src/webui/internal/handlers/vpn.go | 230 ++++ src/webui/internal/handlers/vpn_test.go | 69 ++ src/webui/internal/handlers/wifi.go | 425 +++++++ src/webui/internal/handlers/wifi_test.go | 69 ++ src/webui/internal/restconf/client.go | 223 ++++ src/webui/internal/restconf/errors.go | 63 + src/webui/internal/restconf/fetcher.go | 16 + src/webui/internal/security/csrf.go | 102 ++ src/webui/internal/server/middleware.go | 176 +++ src/webui/internal/server/server.go | 161 +++ src/webui/internal/testutil/helpers.go | 95 ++ src/webui/main.go | 85 ++ src/webui/static/css/style.css | 1100 +++++++++++++++++ src/webui/static/img/icons/containers.svg | 5 + src/webui/static/img/icons/dashboard.svg | 6 + src/webui/static/img/icons/dhcp.svg | 4 + src/webui/static/img/icons/firewall.svg | 3 + src/webui/static/img/icons/firmware.svg | 5 + src/webui/static/img/icons/iface-bridge.svg | 6 + .../static/img/icons/iface-container.svg | 5 + src/webui/static/img/icons/iface-ethernet.svg | 3 + src/webui/static/img/icons/iface-gre.svg | 5 + src/webui/static/img/icons/iface-lag.svg | 5 + src/webui/static/img/icons/iface-veth.svg | 5 + src/webui/static/img/icons/iface-vlan.svg | 5 + src/webui/static/img/icons/iface-vxlan.svg | 3 + src/webui/static/img/icons/iface-wifi.svg | 6 + .../static/img/icons/iface-wireguard.svg | 5 + src/webui/static/img/icons/interfaces.svg | 3 + src/webui/static/img/icons/keystore.svg | 4 + src/webui/static/img/icons/lldp.svg | 7 + src/webui/static/img/icons/ntp.svg | 4 + src/webui/static/img/icons/routing.svg | 6 + src/webui/static/img/icons/status-down.svg | 5 + src/webui/static/img/icons/status-up.svg | 4 + src/webui/static/img/icons/status-warning.svg | 5 + src/webui/static/img/icons/wifi.svg | 6 + src/webui/static/img/icons/wireguard.svg | 5 + src/webui/static/img/jack.png | Bin 0 -> 1759 bytes src/webui/static/img/logo.png | Bin 0 -> 28727 bytes src/webui/static/js/app.js | 212 ++++ src/webui/static/js/htmx.min.js | 1 + .../templates/fragments/iface-counters.html | 45 + src/webui/templates/layouts/base.html | 36 + src/webui/templates/layouts/sidebar.html | 175 +++ src/webui/templates/pages/containers.html | 52 + src/webui/templates/pages/dashboard.html | 103 ++ src/webui/templates/pages/dhcp.html | 67 + src/webui/templates/pages/firewall.html | 122 ++ src/webui/templates/pages/firmware.html | 69 ++ src/webui/templates/pages/iface-detail.html | 155 +++ src/webui/templates/pages/interfaces.html | 46 + src/webui/templates/pages/keystore.html | 74 ++ src/webui/templates/pages/lldp.html | 45 + src/webui/templates/pages/login.html | 40 + src/webui/templates/pages/ntp.html | 74 ++ src/webui/templates/pages/routing.html | 113 ++ src/webui/templates/pages/vpn.html | 51 + src/webui/templates/pages/wifi.html | 148 +++ 95 files changed, 8738 insertions(+), 8 deletions(-) create mode 100644 board/common/rootfs/etc/default/webui create mode 120000 board/common/rootfs/etc/nginx/app/restconf.conf create mode 100644 board/common/rootfs/etc/nginx/restconf-access.conf create mode 100644 package/webui/Config.in create mode 100644 package/webui/webui.conf create mode 100644 package/webui/webui.mk create mode 100644 package/webui/webui.svc create mode 100644 src/webui/.gitignore create mode 100644 src/webui/LICENSE create mode 100644 src/webui/Makefile create mode 100644 src/webui/README.md create mode 100644 src/webui/go.mod create mode 100644 src/webui/internal/auth/login.go create mode 100644 src/webui/internal/auth/session.go create mode 100644 src/webui/internal/auth/store.go create mode 100644 src/webui/internal/handlers/capabilities.go create mode 100644 src/webui/internal/handlers/capabilities_test.go create mode 100644 src/webui/internal/handlers/common.go create mode 100644 src/webui/internal/handlers/containers.go create mode 100644 src/webui/internal/handlers/containers_test.go create mode 100644 src/webui/internal/handlers/dashboard.go create mode 100644 src/webui/internal/handlers/dashboard_test.go create mode 100644 src/webui/internal/handlers/dhcp.go create mode 100644 src/webui/internal/handlers/firewall.go create mode 100644 src/webui/internal/handlers/interfaces.go create mode 100644 src/webui/internal/handlers/keystore.go create mode 100644 src/webui/internal/handlers/lldp.go create mode 100644 src/webui/internal/handlers/ntp.go create mode 100644 src/webui/internal/handlers/routing.go create mode 100644 src/webui/internal/handlers/routing_test.go create mode 100644 src/webui/internal/handlers/services_test.go create mode 100644 src/webui/internal/handlers/system.go create mode 100644 src/webui/internal/handlers/vpn.go create mode 100644 src/webui/internal/handlers/vpn_test.go create mode 100644 src/webui/internal/handlers/wifi.go create mode 100644 src/webui/internal/handlers/wifi_test.go create mode 100644 src/webui/internal/restconf/client.go create mode 100644 src/webui/internal/restconf/errors.go create mode 100644 src/webui/internal/restconf/fetcher.go create mode 100644 src/webui/internal/security/csrf.go create mode 100644 src/webui/internal/server/middleware.go create mode 100644 src/webui/internal/server/server.go create mode 100644 src/webui/internal/testutil/helpers.go create mode 100644 src/webui/main.go create mode 100644 src/webui/static/css/style.css create mode 100644 src/webui/static/img/icons/containers.svg create mode 100644 src/webui/static/img/icons/dashboard.svg create mode 100644 src/webui/static/img/icons/dhcp.svg create mode 100644 src/webui/static/img/icons/firewall.svg create mode 100644 src/webui/static/img/icons/firmware.svg create mode 100644 src/webui/static/img/icons/iface-bridge.svg create mode 100644 src/webui/static/img/icons/iface-container.svg create mode 100644 src/webui/static/img/icons/iface-ethernet.svg create mode 100644 src/webui/static/img/icons/iface-gre.svg create mode 100644 src/webui/static/img/icons/iface-lag.svg create mode 100644 src/webui/static/img/icons/iface-veth.svg create mode 100644 src/webui/static/img/icons/iface-vlan.svg create mode 100644 src/webui/static/img/icons/iface-vxlan.svg create mode 100644 src/webui/static/img/icons/iface-wifi.svg create mode 100644 src/webui/static/img/icons/iface-wireguard.svg create mode 100644 src/webui/static/img/icons/interfaces.svg create mode 100644 src/webui/static/img/icons/keystore.svg create mode 100644 src/webui/static/img/icons/lldp.svg create mode 100644 src/webui/static/img/icons/ntp.svg create mode 100644 src/webui/static/img/icons/routing.svg create mode 100644 src/webui/static/img/icons/status-down.svg create mode 100644 src/webui/static/img/icons/status-up.svg create mode 100644 src/webui/static/img/icons/status-warning.svg create mode 100644 src/webui/static/img/icons/wifi.svg create mode 100644 src/webui/static/img/icons/wireguard.svg create mode 100644 src/webui/static/img/jack.png create mode 100644 src/webui/static/img/logo.png create mode 100644 src/webui/static/js/app.js create mode 100644 src/webui/static/js/htmx.min.js create mode 100644 src/webui/templates/fragments/iface-counters.html create mode 100644 src/webui/templates/layouts/base.html create mode 100644 src/webui/templates/layouts/sidebar.html create mode 100644 src/webui/templates/pages/containers.html create mode 100644 src/webui/templates/pages/dashboard.html create mode 100644 src/webui/templates/pages/dhcp.html create mode 100644 src/webui/templates/pages/firewall.html create mode 100644 src/webui/templates/pages/firmware.html create mode 100644 src/webui/templates/pages/iface-detail.html create mode 100644 src/webui/templates/pages/interfaces.html create mode 100644 src/webui/templates/pages/keystore.html create mode 100644 src/webui/templates/pages/lldp.html create mode 100644 src/webui/templates/pages/login.html create mode 100644 src/webui/templates/pages/ntp.html create mode 100644 src/webui/templates/pages/routing.html create mode 100644 src/webui/templates/pages/vpn.html create mode 100644 src/webui/templates/pages/wifi.html diff --git a/board/common/rootfs/etc/default/webui b/board/common/rootfs/etc/default/webui new file mode 100644 index 000000000..765ba175b --- /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 000000000..01182dc2a --- /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/available/default.conf b/board/common/rootfs/etc/nginx/available/default.conf index 76a2552e8..b3835fdc8 100644 --- a/board/common/rootfs/etc/nginx/available/default.conf +++ b/board/common/rootfs/etc/nginx/available/default.conf @@ -20,10 +20,5 @@ server { root html; } - location / { - root html; - index index.html index.htm; - } - include /etc/nginx/app/*.conf; } diff --git a/board/common/rootfs/etc/nginx/restconf-access.conf b/board/common/rootfs/etc/nginx/restconf-access.conf new file mode 100644 index 000000000..ec361e7d9 --- /dev/null +++ b/board/common/rootfs/etc/nginx/restconf-access.conf @@ -0,0 +1,3 @@ +allow 127.0.0.1; +allow ::1; +deny all; diff --git a/board/common/rootfs/etc/nginx/restconf.app b/board/common/rootfs/etc/nginx/restconf.app index 7c8caddae..1d2617412 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/package/Config.in b/package/Config.in index 110d247b2..6998f5804 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/package/webui/Config.in b/package/webui/Config.in new file mode 100644 index 000000000..55cfabc73 --- /dev/null +++ b/package/webui/Config.in @@ -0,0 +1,8 @@ +config BR2_PACKAGE_WEBUI + bool "webui" + depends on BR2_PACKAGE_HOST_GO_TARGET_ARCH_SUPPORTS + depends on BR2_PACKAGE_ROUSETTE + help + Web management interface for Infix, a Go+HTMX application + that provides browser-based configuration and monitoring + via RESTCONF. diff --git a/package/webui/webui.conf b/package/webui/webui.conf new file mode 100644 index 000000000..bcedda91d --- /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 000000000..d04577ce9 --- /dev/null +++ b/package/webui/webui.mk @@ -0,0 +1,23 @@ +################################################################################ +# +# 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 +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 000000000..731bbbba3 --- /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:8080 \ + -- Web management interface diff --git a/src/confd/src/services.c b/src/confd/src/services.c index 0981f80df..8ebb99fe2 100644 --- a/src/confd/src/services.c +++ b/src/confd/src/services.c @@ -557,7 +557,20 @@ 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). When external + * RESTCONF access is disabled we tighten the location to loopback-only + * by writing the appropriate allow/deny rules into the include file. + */ + FILE *fp = fopen("/etc/nginx/restconf-access.conf", "w"); + if (fp) { + if (!ena) + fputs("allow 127.0.0.1;\nallow ::1;\ndeny all;\n", fp); + fclose(fp); + } + mdns_records(ena ? MDNS_ADD : MDNS_DELETE, restconf); finit_reload("nginx"); return put(cfg); @@ -703,13 +716,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 000000000..859dc4235 --- /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 000000000..364389fb6 --- /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 000000000..664896455 --- /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 :8080 --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 000000000..8e3e76651 --- /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 :8080 +``` + +| **Flag** | **Default** | **Description** | +|-------------------|-----------------------------------|-------------------------------------------| +| `--listen` | `:8080` | 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 8080 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 000000000..63966d747 --- /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 000000000..17d75f792 --- /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 000000000..f8d87aabe --- /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 000000000..25051ce92 --- /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 000000000..5472894be --- /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 000000000..188414929 --- /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 000000000..74018bc50 --- /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 000000000..19807383d --- /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 000000000..1f3ec95e5 --- /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 000000000..de0f22486 --- /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 000000000..d996ad847 --- /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 000000000..4c81731d3 --- /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 000000000..64c485821 --- /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 000000000..58687bc5e --- /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, ``, + svgW, svgH) + + // Y-axis grid lines and labels (utilization %). + for _, pct := range []int{0, 25, 50, 75, 100} { + y := padT + chartH - pct*chartH/100 + fmt.Fprintf(&b, ``, + padL, y, float64(padL)+chartW, y) + fmt.Fprintf(&b, `%d%%`, + padL-4, y+4, pct) + } + + // Right Y-axis labels (noise dBm). + if hasNoise { + nMid := (nFloor + nCeil) / 2 + for _, db := range []int{nFloor + 5, nMid, nCeil - 5} { + ny := float64(padT+chartH) - float64(db-nFloor)/nRange*float64(chartH) + fmt.Fprintf(&b, `%d`, + float64(padL)+chartW+4, ny+3, db) + } + } + + // Draw bars and collect noise line points. + var noisePts []string + + for i, ch := range sorted { + cx := float64(padL) + float64(i)*slotW + slotW/2 + bx := cx - barW/2 + + var rxPct, txPct, otherPct float64 + if ch.ActiveTime > 0 { + act := float64(ch.ActiveTime) + rxPct = float64(ch.ReceiveTime) / act * 100 + txPct = float64(ch.TransmitTime) / act * 100 + otherPct = float64(ch.BusyTime)/act*100 - rxPct - txPct + if otherPct < 0 { + otherPct = 0 + } + if total := rxPct + txPct + otherPct; total > 100 { + s := 100 / total + rxPct *= s + txPct *= s + otherPct *= s + } + } + + baseY := float64(padT + chartH) + + // Stacked: other busy (bottom), transmit (middle), receive (top). + if otherPct > 0.5 { + h := otherPct / 100 * float64(chartH) + fmt.Fprintf(&b, ``, + bx, baseY-h, barW, h) + baseY -= h + } + if txPct > 0.5 { + h := txPct / 100 * float64(chartH) + fmt.Fprintf(&b, ``, + bx, baseY-h, barW, h) + baseY -= h + } + if rxPct > 0.5 { + h := rxPct / 100 * float64(chartH) + fmt.Fprintf(&b, ``, + bx, baseY-h, barW, h) + } + + // In-use marker (triangle above bar). + if ch.InUse { + totalPct := rxPct + txPct + otherPct + topY := float64(padT+chartH) - totalPct/100*float64(chartH) + fmt.Fprintf(&b, ``, + cx, topY-3, cx-4, topY-11, cx+4, topY-11) + } + + // Channel label on X-axis. + chNum := freqToChannel(ch.Frequency) + label := fmt.Sprintf("%d", chNum) + if chNum == 0 { + label = fmt.Sprintf("%d", ch.Frequency) + } + fmt.Fprintf(&b, `%s`, + cx, padT+chartH+14, label) + + // Noise line point. + if hasNoise && ch.Noise != 0 { + ny := float64(padT+chartH) - float64(ch.Noise-nFloor)/nRange*float64(chartH) + noisePts = append(noisePts, fmt.Sprintf("%.1f,%.1f", cx, ny)) + } + } + + // Draw noise floor line. + if len(noisePts) > 1 { + fmt.Fprintf(&b, ``, + strings.Join(noisePts, " ")) + for _, pt := range noisePts { + parts := strings.SplitN(pt, ",", 2) + fmt.Fprintf(&b, ``, + parts[0], parts[1]) + } + } + + // Legend row. + ly := svgH - 8 + lx := float64(padL) + + for _, item := range []struct{ color, label string }{ + {"#3b82f6", "Rx"}, + {"#22c55e", "Tx"}, + {"#f59e0b", "Other"}, + } { + fmt.Fprintf(&b, ``, + lx, ly-9, item.color) + fmt.Fprintf(&b, `%s`, + lx+13, ly, item.label) + lx += 13 + float64(len(item.label))*7 + 10 + } + + if hasNoise { + fmt.Fprintf(&b, ``, + lx, ly-4, lx+14, ly-4) + lx += 18 + fmt.Fprintf(&b, `Noise (dBm)`, + lx, ly) + lx += 80 + } + + // In-use legend marker. + fmt.Fprintf(&b, ``, + lx+5, ly-9, lx+1, ly-1, lx+9, ly-1) + fmt.Fprintf(&b, `In use`, + lx+14, ly) + + b.WriteString(``) + 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 000000000..f76f06317 --- /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 000000000..c3d9e19bc --- /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 000000000..8e4a40a16 --- /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 000000000..69a983e01 --- /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 000000000..12da62a6a --- /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 000000000..0c6abc6d1 --- /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 000000000..3f9664d94 --- /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 000000000..6def10e92 --- /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 000000000..62ad57af1 --- /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 000000000..89c80cf97 --- /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 000000000..02947a4a4 --- /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 000000000..4c8e45b31 --- /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 000000000..8774a67f2 --- /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 000000000..a06f57a65 --- /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 000000000..91a0232ab --- /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 000000000..31dff8e44 --- /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 000000000..63087d396 --- /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 000000000..79f9d407e --- /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 000000000..55214380d --- /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", ":8080", "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 000000000..2a6c6980b --- /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 000000000..a6389999b --- /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 000000000..0ec39729c --- /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 000000000..30823ecf3 --- /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 000000000..e4058c258 --- /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 000000000..a76694922 --- /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 000000000..4dde450f4 --- /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 000000000..a6389999b --- /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 000000000..6c0ee150f --- /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 000000000..0c9664940 --- /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 000000000..64d060d91 --- /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 000000000..62c3728fb --- /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 000000000..379524d9c --- /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 000000000..2ce493b9d --- /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 000000000..a344e5a3a --- /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 000000000..b321cd2dc --- /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 000000000..6c0ee150f --- /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 000000000..1a8c6f994 --- /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 000000000..b6e2ac803 --- /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 000000000..b6461c172 --- /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 000000000..f0ae57aae --- /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 000000000..aeb3a2839 --- /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 000000000..6a6a46afb --- /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 000000000..421cf3db3 --- /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 000000000..a344e5a3a --- /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 000000000..b321cd2dc --- /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 0000000000000000000000000000000000000000..b2026c16de00c184e566cba76db0c3948371a2a2 GIT binary patch literal 1759 zcmbtV`#aMM7@ymyhNz=2j>{y3T*hj-fNhy~y zwA^Z4Xk;BXC#`bWX^h?^9P)#^FGh#{k+fnKF|Bh`@Wwy!_&h}aihjY5D27r zz#Vy1!VC%06~Geja<;BqLK_I~{!t*1R_aekeb$7{Ns{W(u71&8=fa{%!NgDyi9|BP zMxKod2_}S^og;={|752D0)cS{kWRYgW8YMYdw}C-_&~Xn9U8Li) z;Af2bCu{7zrGdPGfunHrmJlJPFRs4PvW*@gt$p6jMbjL;+*vy?#Xx=WAUK;*aRwXghF zv~yTI9-np4%R$QTlL&)UzuJpVY}ps3ANC@@s?Y>o!nJHG*6j+ljV7Q`P51ps3DSDD z78q4%M5CpHgHC@{RXu#uk%QY;+$`Ym0%A)jyT8955i^-T@1jy65Yp~gA`n-Jwlp<; zX%_<(0G7eZIUq|#=}B57LGu(K*N{Q%|6cMt={oPmjo7;8APm9bAl?Pu5s{JmzjYCP z!!&-Slpf~r$?J( zYP+LXdRh=GoV}=hrNvfW(}pEO4cI&rt9TWfn$_*ww+VHtlf~&$K}(DJ&CM+%w1gw~!aq$D&;wE>db>ws% z00x5@cDW==p89RrE~d%$RX1L$c$;UKD16D7YFA!ctJL29WjvT!-2j2Z;r^uxl{I%= zvDn$xk%NC8@;v2R#?NTYg!UL28#@c+YZPRqqsZD2Fqb=)6lAelmYMQY*XG^H3-gY; z7YEjAj7&|tzoSmJ*Q@mmHHVt`cvnAb6rX)_XZ+s1UIw$hpnS2qgSx-i?hn;O-__Dm z^2nFh+Lr{+YdJhu$hW;82tb;{zBv&P^>*OxWN%v9d+q0M9yRFx;^^X1-HRI5wL!+S zbD1+vS4~b5Mes|(Y-}H8O)2U6HmyX(kP#=q)*(g+zxAO{58d%F#o|zL%R-U=aiuAa z(o2!~Z!R9bVi;g+t2NSAw`%0+IXNV*IL%h~R9p?BYT(B2H+}c691M^IIu<-sgvMW- zmr|V9%$|(I@ABh~kl{q(v8>m>^VgE0=1F*gf7;+EnUzCh)mlM3$~G*@P+O550=&hm zLClW}^`^2R00I}vkPFLyS;;f>*ImKsVvUI^XhLXBh3<% zc9)tMnLV)yGK?+yD3X25g8%CE(HkE!i|+3JDEGzF$ET1J54$O_alWxRU<~gx`p$;= zvU5WUMe*js86CY`h5VzD+4XckQz{acOKjm+cw}Itxs;m^mHC(C%asWNW*{P#2ked< znh_yNW2dui5v$Ao`guz0J10$58ynmW@@Crl1R|hxFrmWt*o%t~a-@QIVg&;O1IZ@V zOUQyqc`Iz9y-Tfm{mz<$Ixi1XFkx%aWThftwJDHiSTC7L8*=4)T zo{EgCC`OFk`9@ux2~+~s>8DIO4btP+WBEum>xhH$EV&*0=JZ;+639LNV={XRB^Tqk z(DXLygjc|pi_nM(SPJoZbd^g{U(tE9<>Tu*uX}HRRoU-4CC>?Tz|{j;>l~E)5A1X! Al>h($ literal 0 HcmV?d00001 diff --git a/src/webui/static/img/logo.png b/src/webui/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..169dfacce77524f9052a11bbddd5a2dd8fb17144 GIT binary patch literal 28727 zcmYhibyQW|_dR?rEt1k*3W{`hUICSq?&i|njdY8Mba#VvcS?hFBi+*d9-hzlH^%#i zF}Rn#*WPEvoO7)cDlaREj!KLQ008<&Dd-mffHMXEKY)S+{vFRU@*e!>g{_pj0|1Z( zKL3H7kgy$qKO}PepysG(W8&zdZ*L5^xVSKxSz9<5>f0JK+1SI9j|7MTfD-r!6;*Og zIaqRWRlK?oKDla8?eZ|#K{y!hO-%?$rnSV!DKz zfU-Skh9g`q>zS=V(qK0`B!auZ3YdlKRBisVOn??fH-epyS{)V^_L6ggi7KlbW&1T$ zmjmhei_^ux{QP_nxKRMg()Tx?e;o4H`2==^LtY2tMx0Xlx|7|q8%jj0*SMY7MC+rX=t$hv>a{N8wB4DW~yezAm0gdmh_>!7eIe!KQ6#{>bKv?F>ef8iDoT zD6QY4ps9IxEJa5hK*;la4pa|_7M?)?fk=H*S>DlyY!juCr~CW0BHagS6y3f7r%AC& zeEKc7cn@I?YHrr*nBvY0aik}XiBJ?)kv?3Nuv|KskdA>vXWdRzVKhp*02UI*>0=Fr zCWBlBx6~2Pe`3WxC%96Hk>Je0bq+6^AGbve%Am<8=_s_uieyu#1Y^PXT{qWF-(?VG zYR8qWyfTdT5e2XWX=zKj`PEK`*{k)r9u9kI6wHny;^vq$K zMUL9Y?Jp@g16aHPCY#8*<%E)Mc_XT^eknRCXo&P3iOJSD<&Pvh;YEXULd_fpEI1%V zdF1%jPCR^zupe=zVQb%p+jxKT?Rx2{kY;N|v-J26pVhxcU;Qc|uHD+3vS&&jN8Bue ztO0KcQzqjBc>+WJ+Ngzyc5ClGQlGYmvjdWNZwZ2%c=l@zZo`V`$)HP~K_R8m{(%;@ifZIGa*a$Oh}hDv1p zS}1R(M%(_E1Iv@cSyx;*7OWb!LOvx-YiJfoI200+f;Ykk5Wz=w3@ta#H?|QasPrEc zc@^rO`H*B$wCni3j zm&Fy0a6=>kfpVU}aT;O?+qyvg!6@DQLEreSY^mw;10SoMwY>_@Hheut0G1?AyDsZn zvMvVQ*870si3#jU+x@6g7vQ=IVirn8IrS$dkZ?jppx%JncS+EOhB~QD&l`eh zN{qb(tRg{0J0vn(9Ct2;?vn=hkjbO-IVX5uKmd$jnhtW7pn+o_- z7{&QCJwG^oYt4|1*^NV&uQ4QIA>wACWt$)5nsagfBGcLDl)dwWsG3oWCN)j1Z9nW75&xW0{;b!ljgv(FtP8aehSG@0>7 zb0r}YdEB1z9uhz}&B zrJokqNB8%V=%pDWq}d4V_z1e6bUU#yO2$&o$?wh@)@rOw%9miMk!xUCveA;@U`LS< zMRk}-^y2u(f@URTl$9Ov`1&McCp)@#M%0TXtPaY2L_}`bEnI0!%L59q;a+pG<1^Ih`E2kjvRi(MKBM)z$f$P# zM1#M7D?)w*5KHR!%r3vfnVOlogESi}2_V_9(*GoE&Jn@sWPmh>$7(VW1)r8>o(8al z8Ym6|0nI!K*5lc&BJcp5pt=0pNcj8jqLtm0v|wGL-6aloo*K)DunOwM>3rSYI|QAQ zvS6c*n)~wo3%St=6TH|1qj_EREB!a%w@nO4IoMJ<2dQW_Ug+Z^5fEed&6|(Sp-*_T-STYfGxP~+{CQ6Zb2yUB0`@M z0U)IwbKi08*f@ngsHjl0J>RjG#L+lxCum44q36)KPh2ztyYh>oBJ!U%BWVeVko&s* zbgT#Jc%DF(czr}-KeVN9>L^t^jcMjeB`GN>qI3SV(1Pb>q%9YXKO%khqeVijwJ%Yq z^uFJqs8Xm5Ae7D#;r9>fMLL*kLiTPGo+wP27{l`74W)c*AF7z*5zBfDVM4`@uSJL@5v zuWvP=Ur<4HQ6`>G_V*PmzUH{}LCtP@ zawY9nGogxicS-r!yM$+dx3EnZZ_5${Ej&eKgYiC8V&IMNBi?fhaM!rrKW=M}^)CynFWo|MPVlR$gwZS888%H3!xJ zECWjng%$Us-*j$CH5%_6FYJEFX-0=&%ThMU?h(z%$SCF1#i2Zr)C43TmF@z6bhJq@AOK@p6TQRUN z2%UcZTAStG<6*MRV$;!#U6iLI28{>myUAh9K}ak@LWW5q>Gh=xg$oV)Frk~X%8fp_ zvQIPgERVS&H`_bo*(++zu9enDG{gxYI9mh|e<8X1AP%Z!+uJp08r9{_bgCKOI8D(w{`_p@?64*e zb6=JwEzYD7OCHOqkw=Ob1K~7#naHRqlHTAzPt6!7BHyCkqN$&8b?D1gO<)K?KM&(U za>2!VWxvF6T3t_FM^UkAsageDMD?8ceTgUCgc|_7zb~_oLXV%8N`|bGi5rUDmBMY( z(ijGX%if9tu(X~T%$!Nx7aZfhKcqWQg;Di%uAXv5aKsWi*K7->p%$%K?+r5y8E_*Y zh77*%@YRAK+Je&9YK(bBMfC48khkD%{aN1J69H)Cq8Z0#W37ipj)FyE55~{PC8qxa z3bq#*$zo_BU27FReSOu_l*(rOEHq&6_sTUUiNzic0y%cPwE zEDhHQi0%`xAw>72e#?+CBd7rUF2MC_XE=@f6J`>Yfs|d-lCiw4uG#KzP@jW$^qd$$QNwwy`@!-#qLXDmrC6bA|kQv%QxCd5NsEmBmlAQ zyM*^Nus{bxZL1~S;Se9%3(RS5I8)M{2knMW*#bs)7N8zLS5v;aC^Ezg29brPn; z8{c}VEu65jnyXsJ2hAf}9wm?FhfgevfHh*Luv<_ECw=isNolDSifS@>^frrcJf!)+ zmIr@f-D-t8Pq)31qJM+mxn$q=Gz}{_+7`vF&0ZzXD-nA$H zrocV6?-w54BrESIU#itQfuQpZCYASyq$HG59 zD_dx^qk5+rJdnX4nI?m$D{W3A3*Zx)}u~^id zcPcgub==dz?gOMp70ozR$o3-YY+NfI9gnHqgxhANiwu0!mnoa4| zld>v_X3B-qlb+W`>(bp8oan3CkfWS{!;hm4v2Z6jVl z)39Y9+;5TKH-u4mfc9Y~!lvM-wSx|(zv(b@!VnX(^)+YovzC+NKO%XRpK4gcJ@3^<3 z(FmG~p>(+lq!E0~=WQRDi;{3qZLkT7QpMJ?yTtahKC8K*b+>4igRUdU>*^kF1EcU` zmU^>+NWHJ9G)`B;mRRcZIp$q0TG}YbxJ>{^ooE^OA3{kYgq8Ei^j z632UpC_yaQsYf0SgU9yj*r`AIbaj<*!wQ^wkq)Aj?N&HOTur(DbQvBCOK5Ct^wJ}2 zh?>h~ihqA#_}D$iI*YSQI9Xe|M_fP3u zZ-27*whcc~09G+0U6z4}fh$40hDSlWIm|R!Aww80%JtUpwjSSt_w_up%W6jsPhF*# z;zWBhE|B--RhbZ-&Kp{_q|WVBMJb$G3$0M}`GSx+R-Fcr)|Yua7F~WaYT`T54c^|m zw>3>XyJw*gg&UbHEh3Kue9UPYM|js)!CD! zmiEGSHI0Si4;1=R@K0LBFf|#9fksESa4^K8pLMq2BXTDt-}was@dP3QdxrO=4!JWKe|C4#s&D8Tn11$d={P)Fw!~5&PA(LKUI^-Kx_ZqTtfrGh4mud>+ zOU6aId7$&njLeJSu0FAJd;fQpfguQRi7FYau7%~pE-`%@b7m?ewAaz1b|@l#qLl%V z1UDOS7rJBnV?G37>tbO8Je4%trX^9#+2e8$cRA8}leeF=Mj4+l;q86kcw}IeTJE)v-+m^?`X-CeVrS_P^lpU_VATQ6PY1|D# z9JLxUQFcw6LChT>N8y6U(>7G$Dh7o|I%eZBV)rjUn{q|k1l$7gx>^Hq6n=q}1;S@1 zF!2s5)-3p#4CrtQu;^cc(DI0rEvIWu{?P6+*gs{p0>Z~sSX{gSGs1YFLYxOqE8t8O zZ8&KYIFaU!*;qx;1ffQ=aHVL=ANaJ@R28q}vJHZgN4FP@l^Ea#2&><|szBnSP8EO6 z09LP9$6GXj9W`+Ql(Cpe@EB;hwlD!}wDqyWF$;5fi!>+aZ zmlU4u6?G%aEKI(@d8ugh+*N=CEsOvgtZ|0`8gC4{LkYHB0IO84s8hp|6j*61ItMR+ z`N$|4X&+k^o^o`~sH(vmZCZ@HS$+{d?cED^8t(xO)N1)|TG6JRsBdDn2DH_Q%1Ccs zLml-^vfc6SC9Fh95z*#yQbYcp@rTXAUpo9kF4Y8KnFh6nk(g;*h{_MuI08%#-)D); z0M!J&6A5omd7SN3YI&7vB5xUPg!E(uy_#1SV)^fb8k!8$=$d;^G&=I#7}T+H&T`ul zQ-fQsrtkOaoeWB9(axYS7@=QoJtRRy`NgD6bY&m2R;=S9CZW`g3%rX@3YpqBrI4jK z2RiX|Ec?+?EQRny^c4};v1|timK*sx$FM&N#!#FWB6f(T(k*YkB2)j)MfqKVRI+274 zRZ=*w?6SrqsttCIAY4%?=KJU|FOJz{VbEqnt$1g zM>&srky+I*CdaH%{kH552^M(94gI^fAJy$B2ZrsaO#guVmkc85bWg8x!{61v+c4D7 zR&=DFE#Uhc&vd94X@ej=SL|r6DXGbb9n&=Eh3spCWJeJ_vq6Tdc?E9H@kQ3!-8<-w z>iIp}jv@0)A6bxcLIYe|mi==k1po+6=ZeM=lTS+G6K;29v`e)UgPsW&6=v5*KNGl- zWH_(RKNM{W&mMBv|N1|>zkz}IlY8&?W@J@D`{z;o+?3Yw9T9Sx(6GTxtVol?{ z4h{JccBqbcD8ZAWnJjA@F9Ag~W9OP+yFdoCn@nlJ4&30C>`(w#m)Aex%8w`k^|1VB zsy?gU{|^za6vHrfpPzwj=FiMbT|lLbICv6jE0Z@oyd?91;lA}#Qu#E`m#F0AR3leq zS_kmF3fihw@3&We@V|)EH~nqEo z`|XXLoGz$v7FgnNO=&mo*+gofeCXiUsLiMTOtm0%UJZxIWu$|Z=9ulhH_tOF1Y0!~ z#l?kpWY`aXB4*JZm2W!#jhl31fK?6r3<7f7U?VKHS_u&)@pmS~ZA6&%Z4kk`v@zB1!-6;{a`AW8+M* zL1CafxM3Gc`5DA1@*^T+LN%$bY_91#<&U>i8w&M^cO!vQgcmXOl_ z8HSSlAsoM_Fm+E6#kzOPJ21{7WQPgo%S{THVwG{$iT~FF6AS3c`Vd}XMP?-jBoKA4 z4a?hUgXxo8t|lL;JzHX1T)NGvX(^tNYzF0j_uuIVg&wwE-a)i*y4SpsR@SwTv}e5h z0~uVtRxYeoGnAa@zNe?RM~@<5``@$FZ~PbbFP-J|D<#IC(0+BW=zJL^DK0Kf&S*#6 zxH=#$i@8hy69y3}_v`=LL8%ON#tPw9pQVrOlw06WTHnYyKS&}tvAnG;odG|SxsAg% zRzB;u?%FOnxiyHzD&?rEOz>_w+i;_d#MiyFhj4e5jccpYB5Rm81_}pjQ>_VAOzopeCr7EJNpIwRkYb zTB~t~V17(q5q(+9t;V8jS0rVYR6foh4065@;gu&CDUU7XMB(%Dor~5kM7Z~O9gNUB zsquoW<144J!MOMEadC07=<^ytC>7ltJob{Rg2JiybCIIi!07(g&|PRooaS~!RB}H$ zD3$-}4gXyf#~FwncoW&v)y6-a+2<&RnD29!^u^FfQl|!GN@d2xjEJI8bw3TI z3;QHwy&HumB=26^SN>w7)97d>=`(*a?OLqgIY9!1!I7TG2Qc@cH^5mH;895Md;Yb} zQDh-y&6VIo1|x=!mo!SPCrQG?iUffd7IveqZ4c9-KEbb9Wv@FK+7iFN2k}w}C!e#fd+H@0>lP2G z**N#Yal@8$^PeRAU5=ofVHJK{&R}lw^%WDx?iPQ3Rwvd7>UIrO2|Z&}JsROQG=JKmoXBlzYRb|@ zwXv;t6*)3$xH}!BAm1qP;jvO`{9QB)xd1|>lr*#gE0N&__Uivi!c{E zgy@E1w4WHn!anv3+^CtlBeV z>8wLP#SiGQRYL#YEWiOPRds3tIp{8rEI0-}U%}*(5E2r>P?Uo#gMy7mPftIQc*bg$K0}i8B+h<^=AjpQz)B+SqrGOzVIOUe(6a<94K};syXMF+Bv;~J zWrZtz*c@wUE+#okVW}Dp;W1&cKiG_KyWcGEN)a?RG9tW8V61>18XjqcI3uD|`SaX| zbPg^CQ@CE!OycnSdo^7?j78*f1HK}{wjkx@qH%ui-?3o(`|mztCM{WWbLbGBH+;1J zX3%N(k&5AM$SGe3>s|vVIZ6CNX07=69gj!FYfZG@v5DE2tP2|lJ_xpoSizund{I+V zqd7FPYpGJEMI^7zh-}*#aMC@*c~-m?pMXX3+*wNq|7K;x^FaYg*)Q*Hn7*KQXlSUN zL{Aut;k4W!3CUyGx_}YNDB@Qi*Mg{UTdNq?24q3}chBg5old-v=qT zU_+#eETTLaS4hG5V&LCDd&NutpI||AAkvVp>gOPk+~wnyThied5izpM4G(rv0zNj@A`Nh`(Xwxo5RE!UP-lz9#G!@by#u?+2tYP%6fQAtpR3X0{s=v0 z*o-z^%WQ!fLRitvwDPV!fZ}X7cZmPXb7jaADXVM3jvr90m7pHO1B;Ij7hl=)&AjH~ zs>Ssk-^d%`=1D6|aBa5hU>T`U!_E>-*3;wJNYl)&v}?egTyL(;i%^l}&r}FgsK5Dro&DBf=yz8ng}Tk*fA1(54Gr zbS#VnXzKABdtaIU@S`eEV~5GIETDWCdak|*jh*e^47He4q0EOrRe!9AK;<%pKX0<^ zbyg~hND*Eo6Q+t>S52Vqn`32a9tk>R|19 zmepTIyhO*aICmgro1Xm^tBBbpfg4djyP6PWepaV#{Wl&P@O{-VGp)T@rX*D;c_Ij- z!{$nejj?O2&10*1LH&>CeM(&rX@3aV%Ap`wZ5IIEnRJ_MEo8UQ6@se=>0gHu=P2Zk zvvz$jEJf{&{@853-08-P)JIyu>#(I3V%9ot=Ok0k!Izgwh!+(6+BwkUP?(w;mU~7p zq!syy4`cD%##Nk}o<2g>yDUTEqIRFs-fb29K?x!N{fcMY0=f1Jm63J=@spv!{Wr4` zFC3Ayal6E8j2SUimWAKYxSRBJ;gpUQRw~Dc4^{UQ0rl|T`RT7lDC?ef38?8^RCx1A zK+G~izby^)`Vc{6LPEpUO&u4jK>I(vbBPz0&46G!(q)B53=Kfn>W#|SuDwB0*9Xen zMDnH2ol+!=ebXkAEoMJkn-3p)q+%Bczx@l`%$A{-0NPQ}uh7onnO}v3Ovzr5w^uv; z)&l!B*lh?rpm<1XpkevNH8A04QsuPL+oQ&PCVti>`1T*iZGHXS&o zm+7+P>zM50S5(OVe1<5isK|<3Uvkp2;M*)T9HuKPD?jMqIE;Drzra4+8cH;@Q*C?# zR%=-};xl6(k3kWGRp0czDJYU@{+WV7x@y_^V&B&tEjDt@GoQg}$4Aho_`vCzMAN;K zC*S8gg+ialojz0vz_<5~U_&rG9$M(w>uwP{SvE>w}xmklR*pxRL*jmskU7IZNN z@OcRZ{_tUU4S-M?{_y>$#BoeiB6_Efkklg3?&kzbc%6^;51&Afgn)IxWOH-7rFXSF zsB+FA_1q;jZp6lsF^ojvi2h2ez%{%h?@(qYQbgBLh384grbB>6mOihbsAv!z;&?(` z9RUwjDC#i`dL)|58mQ0-Nzy$q0(o+)l+^KpBaa3-FrG#*dTMVL9(yeBd45l_UVHv2 zYF#U`Cvv{A?f8mF1e)_5K(2kYgF&eIG6fc0)wb*0lx_aN7u*%IT4u|PeA0lD`)yzq z8PrN@z=A8&F(ge3(!>%)16VgjS3H|DadH-9kNE6l{gj<~tn4i+dHs_g3yi~6;HuNX zb$y`f*!46Y`Ge+}j>1mKtgWt2~|C8935dpZru8RC6Ko^9p zngJK?`H%Bqc<-YEv&Y|j{`pqZ*|H%(M3-&#U%yDT?}>-@xZwMr<+Ip+m%{qXU)^Yw zRDOr<^0Sy_Nm1w^p4?64Oc6Zy{q-f_p-}6(J2<43f$lTCi8rk}*CD&nmTSbIz!kh^ zKGo39o5oRuu*L*j+SV)tLR>TTp5@Tw2kjAq=k%RE>hj{fCkgus%Gntv;7Z!Qf&Z&n z32ej|*?XjmK>yk|d~*8m6alx%)diam+M;Cr^Qyr6d}n+gpIN6Q-5ac1+k~Lx*tcdo z3--WK)i*1v3gm>~*sO%q*vO2=hbTnm>H)t1Bu_b`oWgV2U8z&DwX*lbC;t)%(7YjXb~t zt?{)XWvNI(SJU>u^Xvp~PoE+J; zbY@@BR0;mMFIH={z?Fn2BuBL=fF##Dkg6(8(FOCR0dx$uW-|0-ni(G-K;@?f9CwQA zzgTb-i#-Se9+DD~7OENc~ch1~&eY4!4!qs-UXBrIe ze81wSQ=SBhoxJZ;j`dSEWE%C-EhzpP;Iw%B*wZdCJ zP_{$tJ{JdwSX@$46A9ZDzyUIqvI7n0RYR?~GF1rf+TVS0W%#qZ%v2GuA@tFvrfxJ7 zdF0b}3qm7=j>4fHDTbsJhfa4v4fq%m#f;j*Yee;X3IulInFw~uja!iGmq^DR{~U?^ zTUxwV%d9_l>nPojsv<^^M9KRnZnu!+U7dm^kX8%A`h+fKdI3!6`N zpiW^>KxHD-FmD~W{}Qb=G?Av}+fq9=2b>qeD#(V27!qbBGGa>3GLn-@zGbnVIqzI2 z&XBY!cL#!&*=Bkl;Xm7mLM2UG#RL8bg(@rJ|7Y!g_P?e1AEAf-i20usK|QEOo$8A< znJ43b7mqsw*Y5X#tmAbUH^anP)w0W;=i}qJwv!HNZxy^t^L%J4UokiF5JQuQ*K!`% zD=8@{1S$@@tY*q|T5fyT$z68e6j#~Hl3zW}Uu|2?SMSEzZoTW42kTo14{a2va3TO< zS=|Xs*;%HuVm;Yla^`N%0ADGjn4Co@A{GK#mAWXGq8=+PI;`^T#}H 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 literal 0 HcmV?d00001 diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js new file mode 100644 index 000000000..565e0755f --- /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 000000000..59937d712 --- /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('");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"," ."+Q.config.indicatorClass+"{opacity:0} ."+Q.config.requestClass+" ."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ."+Q.config.requestClass+"."+Q.config.indicatorClass+"{opacity:1; transition: opacity 200ms ease-in;} ")}}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 000000000..e995bb011 --- /dev/null +++ b/src/webui/templates/fragments/iface-counters.html @@ -0,0 +1,45 @@ +{{define "iface-counters"}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CounterRXTX
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 000000000..f2278db1d --- /dev/null +++ b/src/webui/templates/layouts/base.html @@ -0,0 +1,36 @@ +{{define "base"}} + + + + + + + + + {{if .PageTitle}}{{.PageTitle}} — {{end}}Infix + + + + + + + +
+
+ +
+
+ {{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 000000000..6d9318b54 --- /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 000000000..d7a3496ae --- /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}} +
+
Containers
+
+ + + + + + {{range .Containers}} + + + + + + + + + {{end}} + +
NameImageStatusCPUMemoryUptime
{{.Name}}{{.Image}} + + {{.Status}} + + {{if gt .CPUPct 0}} +
+
+
+ {{.CPUPct}}% + {{else}}{{end}} +
+ {{if .MemUsed}} +
+
+
+ {{.MemUsed}} / {{.MemLimit}} + {{else}}{{end}} +
{{if .Uptime}}{{.Uptime}}{{else}}—{{end}}
+
+
+{{else if not .Error}} +
+
Containers
+

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 000000000..3a68b52b2 --- /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}} + +
+
+
System Information
+
+ + {{if .Hostname}}{{end}} + {{if .OSName}}{{end}} + {{if .Machine}}{{end}} + {{if .Firmware}}{{end}} + {{if .Uptime}}{{end}} +
Hostname{{.Hostname}}
OS{{.OSName}} {{.OSVersion}}
Architecture{{.Machine}}
Firmware{{.Firmware}}
Uptime{{.Uptime}}
+
+
+ +
+
Resources
+
+ + {{if .MemTotal}} + + + + + {{end}} + {{if .Load1}} + + + + + {{end}} +
Memory +
+
+
+ {{.MemUsed}} / {{.MemTotal}} MiB ({{.MemPercent}}%) +
Load Average + {{.Load1}} · {{.Load5}} · {{.Load15}} +
+
+
+ + {{if .Board.Model}} +
+
Hardware
+
+ + {{if .Board.Model}}{{end}} + {{if .Board.Manufacturer}}{{end}} + {{if .Board.SerialNum}}{{end}} + {{if .Board.HardwareRev}}{{end}} + {{if .Board.BaseMAC}}{{end}} + {{if .Sensors}} + {{range .Sensors}} + + {{end}} + {{end}} +
Model{{.Board.Model}}
Manufacturer{{.Board.Manufacturer}}
Serial Number{{.Board.SerialNum}}
Hardware Rev{{.Board.HardwareRev}}
Base MAC{{.Board.BaseMAC}}
{{.Name}}{{.Value}}
+
+
+ {{end}} +
+ +{{if .Disks}} +
+
Disk Usage
+
+
+ + + + + + + + + + + + {{range .Disks}} + + + + + + + + {{end}} + +
MountSizeUsedAvailableUse%
{{.Mount}}{{.Size}}{{.Used}}{{.Available}}{{.Percent}}%
+
+
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/dhcp.html b/src/webui/templates/pages/dhcp.html new file mode 100644 index 000000000..4e2d4ef08 --- /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

+
+ + + + + + + + + + + + {{range .DHCP.Leases}} + + + + + + + + {{end}} + +
IP AddressMAC AddressHostnameExpiresClient ID
{{.Address}}{{.MAC}}{{if .Hostname}}{{.Hostname}}{{else}}{{end}}{{.Expires}}{{if .ClientID}}{{.ClientID}}{{else}}{{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 000000000..828278c68 --- /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 Status
+ + + + + + {{if .DefaultZone}} + + {{end}} + + + + + +
Firewall + + {{.EnabledText}} +
Default Zone{{.DefaultZone}}
Lockdown Mode{{if .Lockdown}}Active{{else}}Inactive{{end}}
Log Denied Traffic{{.Logging}}
+
+
+ +{{if .Matrix}} +
+
Zone Matrix
+
+ + + + + {{range .ZoneNames}}{{end}} + + + + {{range .Matrix}} + + + {{range .Cells}}{{end}} + + {{end}} + +
{{.}}
{{.Zone}}{{.Symbol}}
+
+ Allow + Deny +
+
+
+{{end}} + +{{if .Zones}} +
+
Zones
+
+ + + + + + + + + + + + {{range .Zones}} + + + + + + + + {{end}} + +
NameActionInterfacesNetworksServices
{{.Name}}{{.Action}}{{.Interfaces}}{{.Networks}}{{.Services}}
+
+
+{{end}} + +{{if .Policies}} +
+
Policies
+
+ + + + + + + + + + + + + {{range .Policies}} + + + + + + + + + {{end}} + +
NamePriorityActionIngressEgressServices
{{.Name}}{{.Priority}}{{.Action}}{{.Ingress}}{{.Egress}}{{.Services}}{{if .Masquerade}} masq{{end}}
+
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/firmware.html b/src/webui/templates/pages/firmware.html new file mode 100644 index 000000000..5c3e0e172 --- /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}} +
+
Install in Progress
+
+
+
+

{{.Installer.Percentage}}% — {{.Installer.Message}}

+
+{{end}}{{end}} + +{{if .Installer}}{{if .Installer.LastError}} +
Last error: {{.Installer.LastError}}
+{{end}}{{end}} + +
+
Firmware Slots
+ {{if .Slots}} +
+ + + + + + + + + + {{range .Slots}} + + + + + + {{end}} + +
SlotStateVersion
{{.Name}}{{if .BootName}} ({{.BootName}}){{end}}{{if .Booted}} booted{{end}}{{.State}}{{if .Version}}{{.Version}}{{else}}-{{end}}
+
+ {{else}} +

No firmware slot information available.

+ {{end}} +
+ +
+
Install Firmware
+
+ +
+ + +
+ +
+
+{{end}} diff --git a/src/webui/templates/pages/iface-detail.html b/src/webui/templates/pages/iface-detail.html new file mode 100644 index 000000000..009ffbb1e --- /dev/null +++ b/src/webui/templates/pages/iface-detail.html @@ -0,0 +1,155 @@ +{{define "iface-detail.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+ ← Interfaces +

{{.Name}}

+
+ +
+
+
Interface Info
+ + + + + + + + + {{if .PhysAddr}}{{end}} + {{if .MTU}}{{end}} + {{if .Speed}}{{end}} + {{if .Duplex}}{{end}} + {{if .AutoNeg}}{{end}} + {{if .WiFiMode}}{{end}} + {{if .WiFiSSID}}{{end}} + {{if .WiFiSignal}}{{end}} + {{if .WiFiRxSpeed}}{{end}} + {{if .WiFiTxSpeed}}{{end}} + {{if .WiFiStationCount}}{{end}} + {{if .WGPeerSummary}}{{end}} +
Name{{.Name}}
Type{{.Type}}
Index{{.IfIndex}}
Status{{.Status}}
MAC Address{{.PhysAddr}}
MTU{{.MTU}}
Speed{{.Speed}}
Duplex{{.Duplex}}
Auto-negotiation{{.AutoNeg}}
Mode{{.WiFiMode}}
SSID{{.WiFiSSID}}
Signal{{.WiFiSignal}}
RX Speed{{.WiFiRxSpeed}}
TX Speed{{.WiFiTxSpeed}}
Connected Stations{{.WiFiStationCount}}
Peers{{.WGPeerSummary}}
+ {{if .Addresses}} +

Addresses

+ + {{range .Addresses}} + + + + {{end}} +
{{.Address}}{{if .Origin}} ({{.Origin}}){{end}}
+ {{end}} +
+ +
+
Counters
+ {{template "iface-counters" .}} +
+
+ +{{if .WiFiStations}} +
+
Connected Stations
+
+ + + + + + + + + + + + + + + + {{range .WiFiStations}} + + + + + + + + + + + + {{end}} + +
MACSignalTimeRX PktsTX PktsRX BytesTX BytesRX SpeedTX Speed
{{.MAC}}{{if .Signal}}{{.Signal}}{{end}}{{.Time}}{{.RxPkts}}{{.TxPkts}}{{.RxBytes}}{{.TxBytes}}{{.RxSpeed}}{{.TxSpeed}}
+
+
+{{end}} + +{{if .ScanResults}} +
+
Scan Results
+
+ + + + + + + + + + + + {{range .ScanResults}} + + + + + + + + {{end}} + +
SSIDBSSIDSignalChannelSecurity
{{if .SSID}}{{.SSID}}{{else}}Hidden{{end}}{{.BSSID}}{{if .Signal}}{{.Signal}}{{end}}{{.Channel}}{{.Encryption}}
+
+
+{{end}} + +{{if .WGPeers}} +
+
WireGuard Peers
+ {{range .WGPeers}} +
+ + + + + + + {{if .Endpoint}}{{end}} + {{if .Handshake}}{{end}} + {{if .TxBytes}}{{end}} + {{if .RxBytes}}{{end}} +
Public Key{{.PublicKey}}
Status{{.Status}}
Endpoint{{.Endpoint}}
Latest Handshake{{.Handshake}}
Transfer TX{{.TxBytes}}
Transfer RX{{.RxBytes}}
+
+ {{end}} +
+{{end}} + +{{if .EthFrameStats}} +
+
Ethernet Frame Statistics
+
+ {{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 000000000..6d6113979 --- /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}} +
+
Interfaces
+
+ + + + + + + + + + + + + {{range .Interfaces}} + + + + + + + + + {{end}} + +
NameTypeStatusMACAddressDetail
{{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}}
+
+
+{{else if not .Error}} +
+
Interfaces
+

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 000000000..6db226392 --- /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}} +
+
Keystore
+

Keystore is empty.

+
+{{end}} + +{{if .SymmetricKeys}} +
+
Symmetric Keys
+
+ + + + + + + + + + {{range .SymmetricKeys}} + + + + + + {{end}} + +
NameFormatValue
{{.Name}}{{.Format}}{{.Value}}
+
+
+{{end}} + +{{if .AsymmetricKeys}} +
+
Asymmetric Keys
+
+ + + + + + + {{if (index .AsymmetricKeys 0).PrivateKeyFull}}{{end}} + + + + + {{range .AsymmetricKeys}} + + + + + {{if .PrivateKeyFull}} + + {{end}} + + + {{end}} + +
NameAlgorithmPublic KeyPrivate KeyCertificates
{{.Name}}{{.Algorithm}}{{if .PublicKeyFull}}{{.PublicKeyFull}}{{else}}-{{end}}{{.PrivateKeyFull}}{{range $i, $c := .Certificates}}{{if $i}}, {{end}}{{$c}}{{end}}
+
+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/lldp.html b/src/webui/templates/pages/lldp.html new file mode 100644 index 000000000..941f6f5c7 --- /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

+
+ + + + + + + + + + + + + {{range .Neighbors}} + + + + + + + + + {{end}} + +
Local PortChassis IDSystem NamePort IDPort DescMgmt Address
{{.LocalPort}}{{.ChassisID}}{{if .SystemName}}{{.SystemName}}{{else}}{{end}}{{if .PortID}}{{.PortID}}{{else}}{{end}}{{if .PortDesc}}{{.PortDesc}}{{else}}{{end}}{{if .MgmtAddress}}{{.MgmtAddress}}{{else}}{{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 000000000..78d5bb71e --- /dev/null +++ b/src/webui/templates/pages/login.html @@ -0,0 +1,40 @@ +{{define "login.html"}} + + + + + + + + Infix — Login + + + + + + + + + +{{end}} diff --git a/src/webui/templates/pages/ntp.html b/src/webui/templates/pages/ntp.html new file mode 100644 index 000000000..ccb7034a9 --- /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

+ + + + + + {{if .NTP.Stratum}} + + {{end}} + {{if .NTP.RefID}} + + {{end}} + {{if .NTP.Offset}} + + {{end}} + {{if .NTP.RootDelay}} + + {{end}} +
Sync Status + + {{if .NTP.Synchronized}}Synchronized{{else}}Not Synchronized{{end}} +
Stratum{{.NTP.Stratum}}
Reference ID{{.NTP.RefID}}
Offset{{.NTP.Offset}}
Root Delay{{.NTP.RootDelay}}
+ + {{if .NTP.Associations}} +

Associations

+
+ + + + + + + + + + + + + + {{range .NTP.Associations}} + + + + + + + + + + {{end}} + +
ServerStratumRef IDReachPoll (s)OffsetDelay
{{.Address}}{{.Stratum}}{{.RefID}}{{.Reach}}{{.Poll}}{{.Offset}}{{.Delay}}
+
+ {{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 000000000..9d9df7a5b --- /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}} +
+ + + + + + + + + + {{range .Routes}} + + + + + + + + {{end}} + +
DestinationNext HopProtocolPreferenceActive
{{.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}}
+
+ {{else}} +

No routes found.

+ {{end}} +
+ + {{if .HasOSPF}} +
+

OSPF Neighbors

+ {{if .OSPFNeighbors}} +
+ + + + + + + + + + {{range .OSPFNeighbors}} + + + + + + + + {{end}} + +
Router IDStateRoleInterfaceUptime
{{.RouterID}}{{.State}}{{.Role}}{{.Interface}}{{.Uptime}}
+
+ {{else}} +

No OSPF neighbors.

+ {{end}} +
+ +
+

OSPF Interfaces

+ {{if .OSPFIfaces}} +
+ + + + + + + + + + {{range .OSPFIfaces}} + + + + + + + + {{end}} + +
NameStateCostDRBDR
{{.Name}}{{.State}}{{.Cost}}{{.DR}}{{.BDR}}
+
+ {{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 000000000..ab0ba3af1 --- /dev/null +++ b/src/webui/templates/pages/vpn.html @@ -0,0 +1,51 @@ +{{define "vpn.html"}}{{template "base" .}}{{end}} + +{{define "content"}} +
+

VPN

+ {{if .Error}}
{{.Error}}
{{end}} + + {{if .Tunnels}} + {{range .Tunnels}} +
+

+ + {{.Name}} + {{if .ListenPort}}:{{.ListenPort}}{{end}} +

+
+ {{range .Addresses}}{{.}}{{end}} +
+ + {{if .Peers}} +
+ + + + + + {{range .Peers}} + + + + + + + + + {{end}} + +
Public KeyEndpointStatusLast HandshakeRXTX
{{.PublicKeyShort}}{{if .Endpoint}}{{.Endpoint}}{{else}}{{end}} {{.Status}}{{.LastHandshake}}{{.RxBytes}}{{.TxBytes}}
+
+ {{else}} +

No peers configured.

+ {{end}} +
+ {{end}} + {{else}} +
+

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 000000000..58b485532 --- /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}} +
+
+
+
+ {{.Name}} +
+
+
+ + {{if .Manufacturer}}{{end}} + {{if .Driver}}{{end}} + {{if .Standards}}{{end}} + {{if .Band}}{{end}} + {{if .Channel}}{{end}} + {{if .Frequency}}{{end}} + {{if .Noise}}{{end}} + {{if .MaxAP}}{{end}} + {{if .Bands}} + + + + + {{end}} +
Manufacturer{{.Manufacturer}}
Driver{{.Driver}}
Standards{{.Standards}}
Active Band{{.Band}}
Channel{{.Channel}}
Frequency{{.Frequency}} MHz
Noise Floor{{.Noise}} dBm
Max APs{{.MaxAP}}
Supported Bands +
+ {{range .Bands}} +
+ {{.Name}} +
+ {{end}} +
+
+
+
+ + {{if .SurveySVG}} +
+
Channel Survey
+
+ {{.SurveySVG}} +
+
+ {{end}} +
+ +{{range .Interfaces}} +
+
+
+ + + {{.Name}} + {{if .SSID}}{{.SSID}}{{end}} + + {{if .Mode}}{{.Mode}}{{end}} +
+
+ + {{if eq .Mode "station"}} +
+ + {{if .Signal}}{{end}} + {{if .RxSpeed}}{{end}} + {{if .TxSpeed}}{{end}} +
Signal{{.Signal}}
RX Speed{{.RxSpeed}}
TX Speed{{.TxSpeed}}
+
+ {{end}} + + {{if and (eq .Mode "ap") .APClients}} +
+ + + + + + + + + + + + + + {{range .APClients}} + + + + + + + + + + {{end}} + +
MAC AddressSignalConnectedRX BytesTX BytesRX SpeedTX Speed
{{.MAC}}{{if .Signal}}{{.Signal}}{{else}}—{{end}}{{.ConnTime}}{{.RxBytes}}{{.TxBytes}}{{.RxSpeed}}{{.TxSpeed}}
+
+ {{else if eq .Mode "ap"}} +

No stations connected.

+ {{end}} + + {{if and (eq .Mode "station") .ScanResults}} +
+ + + + + + + + + + + + {{range .ScanResults}} + + + + + + + + {{end}} + +
SSIDBSSIDSignalChannelSecurity
{{if .SSID}}{{.SSID}}{{else}}Hidden{{end}}{{.BSSID}}{{if .Signal}}{{.Signal}}{{else}}—{{end}}{{.Channel}}{{.Encryption}}
+
+ {{end}} +
+{{end}} +{{end}} +{{else if not .Error}} +
+
WiFi
+

No WiFi radios detected.

+
+{{end}} +{{end}} From 2cacac1be29a4e1ea9e422d9837e4579e1cf631c Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Sat, 28 Mar 2026 20:23:46 +0100 Subject: [PATCH 02/19] webui: accordion sidebar and general-purpose status badges Replace flat nav-section-header labels with collapsible
/ accordion groups (Network, Wireless, VPN, Services, System). Groups default open; user state persists via localStorage. Tablet icon-only mode always shows all items regardless of open/closed state. Add badge-up/down/warning/info/neutral utility classes with full dark mode support, complementing the existing firewall-specific badge variants. Signed-off-by: Joachim Wiberg --- src/webui/static/css/style.css | 71 +++++- src/webui/static/js/app.js | 20 ++ src/webui/templates/layouts/sidebar.html | 280 ++++++++++++----------- 3 files changed, 236 insertions(+), 135 deletions(-) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 2a6c6980b..9f69573d4 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -182,20 +182,50 @@ a:hover { } .sidebar-nav { - list-style: none; flex: 1; - padding: 1rem 0; + padding: 0.5rem 0; + overflow-y: auto; } -.nav-section-header { +/* Accordion Nav Groups */ +details.nav-group { + border-top: 1px solid var(--sidebar-border); +} +details.nav-group:first-child { + border-top: none; +} + +details.nav-group > summary.nav-group-summary { + list-style: none; + cursor: pointer; 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; + padding: 0.875rem 1rem 0.375rem; + display: flex; + justify-content: space-between; + align-items: center; + user-select: none; + transition: opacity 0.15s; +} +details.nav-group > summary.nav-group-summary:hover { opacity: 0.8; } +details.nav-group > summary.nav-group-summary::-webkit-details-marker { display: none; } +details.nav-group > summary.nav-group-summary::marker { display: none; } + +details.nav-group > summary.nav-group-summary::after { + content: '▸'; + font-size: 0.7rem; +} +details[open].nav-group > summary.nav-group-summary::after { + content: '▾'; +} + +.nav-group-items { + list-style: none; + padding-bottom: 0.5rem; } .nav-icon { @@ -796,6 +826,31 @@ a:hover { .dark .badge-drop { background: var(--slate-800); color: var(--slate-400); } .dark .badge-continue { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +/* General-purpose status badges */ +.badge-up { background: #dcfce7; color: #166534; } +.badge-down { background: #fef2f2; color: #991b1b; } +.badge-warning { background: #fef9c3; color: #854d0e; } +.badge-info { background: #dbeafe; color: #1e40af; } +.badge-neutral { background: var(--slate-100); color: var(--slate-600); } + +@media (prefers-color-scheme: dark) { + .badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; } + .badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } + .badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } + .badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } + .badge-neutral { background: var(--slate-800); color: var(--slate-400); } +} +.dark .badge-up { background: rgba(22, 163, 74, 0.2); color: #86efac; } +.dark .badge-down { background: rgba(220, 38, 38, 0.2); color: #fca5a5; } +.dark .badge-warning { background: rgba(245, 158, 11, 0.2); color: #fcd34d; } +.dark .badge-info { background: rgba(59, 130, 246, 0.2); color: #93c5fd; } +.dark .badge-neutral { background: var(--slate-800); color: var(--slate-400); } +.light .badge-up { background: #dcfce7; color: #166534; } +.light .badge-down { background: #fef2f2; color: #991b1b; } +.light .badge-warning { background: #fef9c3; color: #854d0e; } +.light .badge-info { background: #dbeafe; color: #1e40af; } +.light .badge-neutral { background: var(--slate-100); color: var(--slate-600); } + /* Interfaces */ .iface-name { white-space: nowrap; font-weight: 500; } .iface-name a { color: var(--primary); text-decoration: none; } @@ -1047,9 +1102,13 @@ a:hover { display: none; } - .nav-section-header { + /* Accordion in icon-only mode: always show items, hide section headers */ + details.nav-group > summary.nav-group-summary { display: none; } + details.nav-group:not([open]) > .nav-group-items { + display: block !important; + } .nav-link { justify-content: center; diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 565e0755f..81119d08f 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -185,6 +185,26 @@ }); })(); +// Accordion nav group persistence +(function() { + document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('details.nav-group').forEach(function(d) { + var label = d.querySelector('summary'); + if (!label) return; + var key = 'nav-group:' + label.textContent.trim(); + var saved = localStorage.getItem(key); + if (saved === 'closed') { + d.removeAttribute('open'); + } else if (saved === 'open') { + d.setAttribute('open', ''); + } + d.addEventListener('toggle', function() { + localStorage.setItem(key, d.open ? 'open' : 'closed'); + }); + }); + }); +})(); + // Sidebar toggle (mobile) (function() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 6d9318b54..1947ca17c 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -3,141 +3,163 @@ - + + +
{{template "sidebar" .}} -
- {{block "content" .}}{{end}} -
+
+
+ +
+
+ +
+ + + + + + + + Download Config + + + +
+ + +
+
+
+
+
+
+ {{block "content" .}}{{end}} +
+
diff --git a/src/webui/templates/layouts/sidebar.html b/src/webui/templates/layouts/sidebar.html index 1947ca17c..da54e7e74 100644 --- a/src/webui/templates/layouts/sidebar.html +++ b/src/webui/templates/layouts/sidebar.html @@ -160,38 +160,5 @@
- {{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html index 78d5bb71e..d60702b4a 100644 --- a/src/webui/templates/pages/login.html +++ b/src/webui/templates/pages/login.html @@ -6,7 +6,7 @@ - Infix — Login + Login @@ -14,26 +14,47 @@