webui: configure interfaces, initial wizard support

Idea of wizard is to simplify:

 - Re-adding Ethernet interfaces you've accidentally removed
 - Setting up WiFi access point/station, incl. radio & PSK
 - VLAN filtering bridges

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:15 +02:00
parent 974ea7260f
commit 9720c7000f
12 changed files with 4731 additions and 13 deletions
File diff suppressed because it is too large Load Diff
+60 -7
View File
@@ -25,7 +25,9 @@ type interfacesWrapper struct {
type ifaceJSON struct {
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Enabled *bool `json:"enabled"`
OperStatus string `json:"oper-status"`
PhysAddress string `json:"phys-address"`
IfIndex int `json:"if-index"`
@@ -33,26 +35,44 @@ type ifaceJSON struct {
IPv6 *ipCfg `json:"ietf-ip:ipv6"`
Statistics *ifaceStats `json:"statistics"`
Ethernet *ethernetJSON `json:"ieee802-ethernet-interface:ethernet"`
Bridge *bridgeCfgJSON `json:"infix-interfaces:bridge"`
BridgePort *bridgePortJSON `json:"infix-interfaces:bridge-port"`
Vlan *vlanJSON `json:"infix-interfaces:vlan"`
Lag *lagCfgJSON `json:"infix-interfaces:lag"`
LagPort *lagPortCfgJSON `json:"infix-interfaces:lag-port"`
Vlan *vlanCfgJSON `json:"infix-interfaces:vlan"`
WiFi *wifiJSON `json:"infix-interfaces:wifi"`
WireGuard *wireGuardJSON `json:"infix-interfaces:wireguard"`
}
type vlanJSON struct {
type vlanCfgJSON struct {
ID int `json:"id"`
TagType string `json:"tag-type"`
LowerLayerIf string `json:"lower-layer-if"`
}
type bridgePortJSON struct {
Bridge string `json:"bridge"`
STP *struct {
Bridge string `json:"bridge"`
PVID *int `json:"pvid"`
Flood *bridgePortFlood `json:"flood"`
Multicast *bridgePortMcast `json:"multicast"`
STP *struct {
CIST *struct {
State string `json:"state"`
} `json:"cist"`
} `json:"stp"`
}
type bridgePortFlood struct {
Broadcast *bool `json:"broadcast"`
Unicast *bool `json:"unicast"`
Multicast *bool `json:"multicast"`
}
type bridgePortMcast struct {
FastLeave *bool `json:"fast-leave"`
Router string `json:"router"`
}
type wifiJSON struct {
Radio string `json:"radio"`
AccessPoint *wifiAPJSON `json:"access-point"`
@@ -60,12 +80,19 @@ type wifiJSON struct {
}
type wifiAPJSON struct {
SSID string `json:"ssid"`
SSID string `json:"ssid"`
Hidden *bool `json:"hidden"`
Security *wifiSecJSON `json:"security"`
Stations struct {
Station []wifiStaJSON `json:"station"`
} `json:"stations"`
}
type wifiSecJSON struct {
Mode string `json:"mode"`
Secret string `json:"secret"`
}
type wifiStaJSON struct {
MACAddress string `json:"mac-address"`
SignalStrength *int `json:"signal-strength"`
@@ -80,6 +107,7 @@ type wifiStaJSON struct {
type wifiStationJSON struct {
SSID string `json:"ssid"`
Security *wifiSecJSON `json:"security"`
SignalStrength *int `json:"signal-strength"`
RxSpeed yangInt64 `json:"rx-speed"`
TxSpeed yangInt64 `json:"tx-speed"`
@@ -132,9 +160,33 @@ type wgPeerJSON struct {
} `json:"transfer"`
}
type dhcpOptionJSON struct {
ID string `json:"id"`
Value string `json:"value,omitempty"`
Hex string `json:"hex,omitempty"`
}
type dhcpv4CfgJSON struct {
ClientID string `json:"client-id,omitempty"`
Arping *bool `json:"arping,omitempty"`
RoutePreference *uint32 `json:"route-preference,omitempty"`
Options []dhcpOptionJSON `json:"option,omitempty"`
}
type dhcpv6CfgJSON struct {
DUID string `json:"duid,omitempty"`
InformationOnly *bool `json:"information-only,omitempty"`
RoutePreference *uint32 `json:"route-preference,omitempty"`
Options []dhcpOptionJSON `json:"option,omitempty"`
}
type ipCfg struct {
Address []ipAddr `json:"address"`
MTU int `json:"mtu"`
Address []ipAddr `json:"address"`
MTU int `json:"mtu"`
DHCP *dhcpv4CfgJSON `json:"infix-dhcp-client:dhcp"`
Autoconf *struct{} `json:"infix-ip:autoconf"`
SLAACv6 *struct{} `json:"autoconf"`
DHCPv6 *dhcpv6CfgJSON `json:"infix-dhcpv6-client:dhcp"`
}
type ipAddr struct {
@@ -282,6 +334,7 @@ func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) {
const (
ifTypeEthernet = "ethernet"
ifTypeLoopback = "loopback"
ifTypePrefix = "infix-if-type:" // YANG module prefix for short-slug identities
)
// prettyIfType converts a YANG interface type identity to the display
+9
View File
@@ -4,12 +4,21 @@ package restconf
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// IsNotFound reports whether err is a RESTCONF error with HTTP status 404.
// Used to distinguish "data resource absent" (expected for delete-or-create
// flows) from real failures.
func IsNotFound(err error) bool {
var e *Error
return errors.As(err, &e) && e.StatusCode == http.StatusNotFound
}
// AuthError is returned when RESTCONF rejects credentials (401/403).
type AuthError struct {
Code int
+82 -3
View File
@@ -4,6 +4,7 @@ package server
import (
"context"
"fmt"
"html/template"
"io/fs"
"net/http"
@@ -112,11 +113,51 @@ func New(
if err != nil {
return nil, err
}
cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-routes.html")
cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-routes.html")
if err != nil {
return nil, err
}
cfgFwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-firewall.html")
cfgFwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-firewall.html")
if err != nil {
return nil, err
}
ifFuncs := template.FuncMap{
"add": func(a, b int) int { return a + b },
"deref": func(v any) any {
switch p := v.(type) {
case *bool:
if p != nil {
return *p
}
case *uint32:
if p != nil {
return *p
}
case *int:
if p != nil {
return *p
}
}
return nil
},
// dict lets callers pass keyed args to nested templates, e.g.
// {{template "foo" (dict "Key" .X "Selected" "")}}.
"dict": func(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("dict: odd argument count")
}
m := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
k, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("dict: non-string key at position %d", i)
}
m[k] = values[i+1]
}
return m, nil
},
}
cfgIfTmpl, err := template.New("").Funcs(ifFuncs).ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "fragments/wizard-psk-picker.html", "fragments/wizard-wgkey-picker.html", "fragments/wizard-radio-picker.html", "pages/configure-interfaces.html")
if err != nil {
return nil, err
}
@@ -133,7 +174,8 @@ func New(
"fragments/yang-tree-node.html",
"fragments/yang-node-detail.html",
"fragments/yang-leaf-group.html",
"fragments/yang-list-table.html")
"fragments/yang-list-table.html",
"fragments/icons.html")
if err != nil {
return nil, err
}
@@ -191,6 +233,7 @@ func New(
cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc, Schema: schemaCache}
cfgRoutes := &handlers.ConfigureRoutesHandler{Template: cfgRoutesTmpl, RC: rc, Schema: schemaCache}
cfgFw := &handlers.ConfigureFirewallHandler{Template: cfgFwTmpl, RC: rc, Schema: schemaCache}
cfgIf := &handlers.ConfigureInterfacesHandler{Template: cfgIfTmpl, RC: rc, Schema: schemaCache}
schemaH := &handlers.SchemaHandler{Cache: schemaCache}
dataH := &handlers.DataHandler{RC: rc, Schema: schemaCache}
treeH := &handlers.TreeHandler{
@@ -266,6 +309,42 @@ func New(
mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP)
mux.HandleFunc("PUT /configure/system/dns", cfgSys.SaveDNS)
mux.HandleFunc("POST /configure/system/preferences", cfgSys.SavePreferences)
mux.HandleFunc("GET /configure/interfaces", cfgIf.Overview)
mux.HandleFunc("POST /configure/interfaces", cfgIf.CreateInterface)
mux.HandleFunc("POST /configure/interfaces/wizard/sym-key", cfgIf.WizardCreateSymKey)
mux.HandleFunc("POST /configure/interfaces/wizard/asym-key", cfgIf.WizardCreateAsymKey)
mux.HandleFunc("POST /configure/interfaces/wizard/wg-genkey", cfgIf.WizardGenerateWGKey)
mux.HandleFunc("POST /configure/interfaces/wizard/radio", cfgIf.WizardCreateRadio)
mux.HandleFunc("POST /configure/interfaces/{name}", cfgIf.SaveGeneral)
mux.HandleFunc("DELETE /configure/interfaces/{name}", cfgIf.DeleteInterface)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv4", cfgIf.AddIPv4)
mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv4/{ip}", cfgIf.DeleteIPv4)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp", cfgIf.SaveIPv4DHCP)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp/settings", cfgIf.SaveIPv4DHCPSettings)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp/options", cfgIf.AddIPv4DHCPOption)
mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv4/dhcp/options/{id}", cfgIf.DeleteIPv4DHCPOption)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/autoconf", cfgIf.SaveIPv4Autoconf)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv6", cfgIf.AddIPv6)
mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv6/{ip}", cfgIf.DeleteIPv6)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/autoconf", cfgIf.SaveIPv6SLAAC)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp", cfgIf.SaveIPv6DHCP)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp/settings", cfgIf.SaveIPv6DHCPSettings)
mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp/options", cfgIf.AddIPv6DHCPOption)
mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv6/dhcp/options/{id}", cfgIf.DeleteIPv6DHCPOption)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge-port", cfgIf.SaveBridgePort)
mux.HandleFunc("DELETE /configure/interfaces/{name}/bridge-port", cfgIf.DeleteBridgePort)
mux.HandleFunc("POST /configure/interfaces/{name}/wifi", cfgIf.SaveWifi)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge", cfgIf.SaveBridge)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge/stp", cfgIf.SaveBridgeSTP)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge/members", cfgIf.SaveBridgeMembers)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge/multicast", cfgIf.SaveBridgeMulticast)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge/vlans", cfgIf.AddVLAN)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge/vlans/{vid}", cfgIf.SaveVLAN)
mux.HandleFunc("DELETE /configure/interfaces/{name}/bridge/vlans/{vid}", cfgIf.DeleteVLAN)
mux.HandleFunc("POST /configure/interfaces/{name}/lag", cfgIf.SaveLAG)
mux.HandleFunc("POST /configure/interfaces/{name}/lag/members", cfgIf.SaveLAGMembers)
mux.HandleFunc("POST /configure/interfaces/{name}/lag-port", cfgIf.SaveLagPort)
mux.HandleFunc("DELETE /configure/interfaces/{name}/lag-port", cfgIf.DeleteLagPort)
mux.HandleFunc("GET /configure/firewall", cfgFw.Overview)
mux.HandleFunc("POST /configure/firewall/enable", cfgFw.Enable)
mux.HandleFunc("POST /configure/firewall/settings", cfgFw.SaveSettings)
+152 -1
View File
@@ -2120,6 +2120,14 @@ select.cfg-input {
.cfg-input-sm { max-width: 8rem; }
.cfg-input-mono { font-family: var(--font-mono); font-size: 0.85rem; }
.cfg-reset-col { width: 1%; white-space: nowrap; padding-left: 0.25rem; }
.cfg-section-head { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 0.4rem; padding-bottom: 0.25rem; border-bottom: 1px solid var(--border); }
.cfg-subsection-head { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); margin: 0.5rem 0 0.25rem; }
.cfg-fold { margin: 0.5rem 0 0; }
.cfg-fold > summary { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); padding-bottom: 0.25rem; border-bottom: 1px solid var(--border); cursor: pointer; list-style: none; display: flex; align-items: center; gap: 0.4rem; user-select: none; }
.cfg-fold > summary::-webkit-details-marker { display: none; }
.cfg-fold > summary::before { content: ''; display: inline-block; transition: transform 0.15s; font-size: 1rem; }
details.cfg-fold[open] > summary::before { transform: rotate(90deg); }
.cfg-fold-body { padding-top: 0.5rem; }
.fw-check-scroll { max-height: 12rem; overflow-y: auto; border: 1px solid var(--border); border-radius: 4px; padding: 0.4rem 0.6rem; background: var(--input-bg, var(--surface)); }
.fw-check-grid { display: flex; flex-wrap: wrap; gap: 0.35rem 1.25rem; }
.fw-check-grid label { display: flex; align-items: center; gap: 0.35rem; font-weight: normal; cursor: pointer; }
@@ -2168,6 +2176,149 @@ select.cfg-input {
.user-shell-form .cfg-input { flex: 1; min-width: 0; }
.user-add-inline .cfg-input { width: auto; flex: 1; min-width: 8rem; }
/* Add Interface modal dialog */
.iface-modal {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--fg);
padding: 0;
min-width: 42rem;
max-width: 90vw;
box-shadow: var(--shadow);
/* Native <dialog>.showModal() centers by default, but some browsers
drift on long content — pin the position to be safe. */
position: fixed;
inset: 0;
margin: auto;
}
.iface-modal::backdrop {
background: rgba(0, 0, 0, 0.45);
}
.iface-modal-form {
display: flex;
flex-direction: column;
}
.iface-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.iface-modal-header h3 {
margin: 0;
font-size: 1rem;
}
.iface-modal-header .btn-close {
background: none;
border: none;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
color: var(--fg-muted);
padding: 0 0.25rem;
}
.iface-modal-header .btn-close:hover {
color: var(--fg);
}
.iface-modal-body {
padding: 0.75rem 1rem;
}
.iface-modal-footer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-top: 1px solid var(--border);
}
.iface-fields {
border: none;
padding: 0;
margin: 0.5rem 0 0;
}
.iface-fields-unsupported {
padding: 0.5rem 0;
}
.cfg-hint {
font-size: 0.8rem;
color: var(--fg-muted);
margin-top: 0.25rem;
}
/* Inline keystore "+ New" create-key form, used by the Add Interface
modal's WiFi PSK and WireGuard private-key rows. */
.ks-picker-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ks-picker-row > select { flex: 1; min-width: 0; }
.ks-create-form {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px dashed var(--border);
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 0.35rem;
background: var(--card-bg);
}
/* `display: flex` above overrides the `[hidden]` attribute's UA-stylesheet
`display: none`, so the fold-out form was always shown. Restore the
collapsed-by-default behaviour. */
.ks-create-form[hidden] { display: none; }
.ks-create-form > input,
.ks-create-form > textarea { width: 100%; }
.ks-create-actions {
display: flex;
gap: 0.5rem;
}
/* WiFi PSK passphrase — masked text input, NOT type="password". A real
password input pulls in browser password managers; the WiFi key is a
device-config value, not a per-site credential, so we mask with CSS
instead and let the user toggle visibility via the eye button. The
webkit property covers Chrome/Edge/Safari (>90% of browsers); Firefox
shows the value in plain text. */
.cfg-input-mask {
-webkit-text-security: disc;
font-family: text-security-disc, inherit;
letter-spacing: 0.08em;
}
.cfg-input-mask.cfg-secret-shown {
-webkit-text-security: none;
font-family: inherit;
letter-spacing: normal;
}
.cfg-secret-wrap {
display: flex;
align-items: center;
gap: 0.35rem;
}
.cfg-secret-wrap > input { flex: 1; min-width: 0; }
.cfg-secret-toggle {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.25rem 0.4rem;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
}
/* Sub-table inside an inline create form. Same row layout as the rest
of the wizard (label on the left, value on the right) but indented a
touch so the user reads it as "fields belonging to the picker above". */
.info-table-sub {
margin: 0;
}
.info-table-sub th {
padding-left: 0.75rem;
font-weight: normal;
font-size: 0.85rem;
color: var(--fg-muted);
}
/* Card-level save feedback */
.cfg-card-footer {
display: flex;
@@ -2585,7 +2736,7 @@ details[open].yt-leaf-item .yt-leaf-name::before { transform: rotate(90deg); }
cursor: help;
font-size: 0.72rem;
font-weight: normal;
color: var(--text-muted);
color: var(--fg-muted);
margin-left: 0.3rem;
vertical-align: middle;
line-height: 1;
+327 -1
View File
@@ -566,6 +566,323 @@
document.addEventListener('htmx:sendError', finish);
})();
// Interface page glue (replaces inline hx-on / inline <script>, which CSP
// blocks under the `script-src 'self'` policy in middleware.go).
(function () {
// After an htmx checkbox marked [data-toggle-details] completes its request,
// show/hide the next-sibling <details> element based on its checked state.
// Used for DHCP/DHCPv6 settings fold-out on the Configure → Interface page.
// Only react to successful requests so a server rejection doesn't lie about
// the new state.
document.addEventListener('htmx:afterRequest', function (evt) {
if (!evt.detail || !evt.detail.successful) return;
var cb = evt.detail.elt;
if (!cb || !cb.matches || !cb.matches('input[type="checkbox"][data-toggle-details]')) return;
var wrapper = cb.closest('div');
var details = wrapper && wrapper.nextElementSibling;
if (details && details.tagName === 'DETAILS') details.hidden = !cb.checked;
});
// Add Interface modal — open via data-show-modal, close via
// data-close-modal. Mirrors the existing data-show/data-hide vocabulary
// for action-on-target attributes. Native <dialog>.showModal() gives us
// focus trap, ESC, and backdrop for free.
document.addEventListener('click', function (e) {
var open = e.target.closest && e.target.closest('[data-show-modal]');
if (open) {
var dlg = document.getElementById(open.getAttribute('data-show-modal'));
if (dlg && dlg.showModal) {
// Reset to server-rendered defaults so the dialog is consistent
// on every open (a previous pick + Cancel would otherwise leave
// the wrong fieldset enabled).
var form = dlg.querySelector('form');
if (form) form.reset();
var vlanName = dlg.querySelector('#add-iface-vlan-name');
if (vlanName) delete vlanName.dataset.userEdited;
var wifiName = dlg.querySelector('#add-iface-wifi-name');
if (wifiName) delete wifiName.dataset.userEdited;
dlg.showModal();
var sel = dlg.querySelector('#add-iface-type-select');
if (sel) sel.dispatchEvent(new Event('change', { bubbles: true }));
}
}
var close = e.target.closest && e.target.closest('[data-close-modal]');
if (close) {
var dlg2 = document.getElementById(close.getAttribute('data-close-modal'));
if (dlg2 && dlg2.close) dlg2.close();
}
});
// Add Interface modal, WiFi fieldset: mode (Station/AP) drives which
// security-mode optgroup is exposed, which AP-only rows are visible,
// whether the PSK row applies (open/disabled hide it), and an -ap
// suffix on the Name.
function refreshWifiSec() {
var modeAP = document.getElementById('add-iface-wifi-mode-ap');
var isAP = !!(modeAP && modeAP.checked);
var sec = document.getElementById('add-iface-wifi-sec');
if (sec) {
var groups = sec.querySelectorAll('optgroup[data-mode-group]');
groups.forEach(function (g) {
var match = g.getAttribute('data-mode-group') === (isAP ? 'access-point' : 'station');
g.hidden = !match;
g.disabled = !match;
});
// Reset to the first visible option of the active group if the
// current value belongs to the other mode.
var active = sec.options[sec.selectedIndex];
if (!active || active.parentElement.disabled) {
for (var i = 0; i < sec.options.length; i++) {
if (!sec.options[i].parentElement.disabled) { sec.selectedIndex = i; break; }
}
}
}
var hiddenRow = document.getElementById('add-iface-wifi-hidden-row');
if (hiddenRow) hiddenRow.hidden = !isAP;
refreshWifiPSK();
refreshWifiName();
}
// Auto-suffix the Name with "-ap" in AP mode and strip it in Station,
// unless the user has manually edited the Name. Matches the in-tree
// wifi naming convention (wifi0 / wifi0-ap).
function refreshWifiName() {
var name = document.getElementById('add-iface-wifi-name');
if (!name || name.dataset.userEdited === '1') return;
var modeAP = document.getElementById('add-iface-wifi-mode-ap');
var isAP = !!(modeAP && modeAP.checked);
var base = name.value.replace(/-ap$/, '');
name.value = isAP ? base + '-ap' : base;
}
function refreshWifiPSK() {
var sec = document.getElementById('add-iface-wifi-sec');
var pskRow = document.getElementById('add-iface-wifi-psk-row');
var psk = document.getElementById('add-iface-wifi-psk');
if (!sec || !pskRow || !psk) return;
var v = sec.value;
var needPSK = v !== 'disabled' && v !== 'open';
pskRow.hidden = !needPSK;
psk.required = needPSK;
psk.disabled = !needPSK;
}
document.addEventListener('change', function (e) {
var t = e.target;
if (t.id === 'add-iface-wifi-mode-sta' || t.id === 'add-iface-wifi-mode-ap') {
refreshWifiSec();
} else if (t.id === 'add-iface-wifi-sec') {
refreshWifiPSK();
}
});
// User-typed Name pins it (stops auto-suffix on mode change).
document.addEventListener('input', function (e) {
if (e.target.id === 'add-iface-wifi-name') {
e.target.dataset.userEdited = '1';
}
});
// CSP-safe successor to inline hx-on:htmx:after-request. Save buttons
// in the inline "+ New keystore key" forms carry
// data-ks-create-success="<form-id>"; on a successful HTMX swap, hide
// the named form and clear its inputs so the user sees the picker
// with their new key selected.
document.addEventListener('htmx:afterRequest', function (evt) {
if (!evt.detail || !evt.detail.successful) return;
var btn = evt.detail.elt;
if (!btn || !btn.hasAttribute || !btn.hasAttribute('data-ks-create-success')) return;
var form = document.getElementById(btn.getAttribute('data-ks-create-success'));
if (!form) return;
form.hidden = true;
form.querySelectorAll('input, textarea').forEach(function (i) { i.value = ''; });
resetMaskedInputs(form);
});
// Wizard "Edit" / "+ Add" buttons that share an inline create form.
// - data-add-form="<form-id>" clears every input/textarea before
// showing the form (handled here; data-show on the same button
// then reveals it).
// - data-edit-form="<form-id>" pre-fills the form from the picker's
// selected option. data-edit-source points at the <select>;
// data-edit-name-target (optional) names the input that should
// receive the picker's value (for keystore forms whose name input
// mirrors the picker). For the radio form, every <input>/<select>
// whose name matches a data-<name> attribute on the picker option
// is filled from that attribute (e.g. data-country fills
// name="country-code", data-band fills name="band", …).
function clearKsForm(formId) {
var form = document.getElementById(formId);
if (!form) return;
form.querySelectorAll('input, textarea').forEach(function (i) { i.value = ''; });
form.querySelectorAll('select').forEach(function (s) { s.selectedIndex = 0; });
resetMaskedInputs(form);
}
// Strip the `.cfg-secret-shown` (eye-toggled "show") class from any
// masked inputs inside `root`. Called whenever a fold-out form
// transitions (open / clear / prefill / after-save) so a previous
// session's "shown" state never leaks across the next interaction.
function resetMaskedInputs(root) {
if (!root) return;
root.querySelectorAll('.cfg-input-mask.cfg-secret-shown').forEach(function (i) {
i.classList.remove('cfg-secret-shown');
});
}
function prefillKsForm(btn) {
var formId = btn.getAttribute('data-edit-form');
var srcId = btn.getAttribute('data-edit-source');
var form = document.getElementById(formId);
var src = document.getElementById(srcId);
if (!form || !src) return;
resetMaskedInputs(form);
var opt = src.options[src.selectedIndex];
if (!opt) return;
var ksNameTarget = btn.getAttribute('data-edit-name-target');
if (ksNameTarget) {
var nameInput = document.getElementById(ksNameTarget);
if (nameInput) nameInput.value = opt.value;
}
// Generic mapping: every form field whose [name=X] matches a
// data-X attribute on the option gets that attribute's value.
form.querySelectorAll('[name]').forEach(function (field) {
var attr = 'data-' + field.getAttribute('name');
if (opt.hasAttribute(attr)) field.value = opt.getAttribute(attr) || '';
});
// For the radio form, the picker's selected radio name needs to be
// present in the inner radio-name <select> so the Save POST carries
// it. The available-radios dropdown only lists unconfigured radios,
// so inject the selected name as an extra option if missing.
var radioNameSel = form.querySelector('select[name="radio-name"]');
if (radioNameSel && opt.value) {
var found = false;
for (var i = 0; i < radioNameSel.options.length; i++) {
if (radioNameSel.options[i].value === opt.value) {
radioNameSel.selectedIndex = i;
found = true;
break;
}
}
if (!found) {
var injected = document.createElement('option');
injected.value = opt.value;
injected.textContent = opt.value + ' (currently configured)';
injected.dataset.injected = '1';
radioNameSel.insertBefore(injected, radioNameSel.firstChild);
radioNameSel.selectedIndex = 0;
}
}
}
document.addEventListener('click', function (e) {
var addBtn = e.target.closest && e.target.closest('[data-add-form]');
if (addBtn) {
clearKsForm(addBtn.getAttribute('data-add-form'));
}
var editBtn = e.target.closest && e.target.closest('[data-edit-form]');
if (editBtn) {
// Clear any injected "currently configured" options from prior
// edits before re-prefilling.
var form = document.getElementById(editBtn.getAttribute('data-edit-form'));
if (form) form.querySelectorAll('option[data-injected]').forEach(function (o) { o.remove(); });
prefillKsForm(editBtn);
}
});
// Show / hide the WiFi PSK passphrase. The input is type="text" with
// CSS masking (to dodge browser password-manager prompts); the eye
// button toggles a `.cfg-secret-shown` class that removes the mask.
document.addEventListener('click', function (e) {
var btn = e.target.closest && e.target.closest('[data-toggle-mask]');
if (!btn) return;
var input = document.getElementById(btn.getAttribute('data-toggle-mask'));
if (!input) return;
input.classList.toggle('cfg-secret-shown');
});
// Hide the Edit button when its picker has no real selection
// (placeholder option, or empty). Re-evaluated on every change to the
// picker, including the HTMX swap that follows a successful Save.
function syncKsEditButtons(root) {
var scope = root || document;
scope.querySelectorAll('[data-edit-source]').forEach(function (btn) {
var src = document.getElementById(btn.getAttribute('data-edit-source'));
if (!src) { btn.hidden = true; return; }
var opt = src.options[src.selectedIndex];
btn.hidden = !opt || !opt.value || opt.disabled;
});
}
document.addEventListener('change', function (e) {
if (e.target && e.target.tagName === 'SELECT') syncKsEditButtons();
});
document.addEventListener('htmx:afterSwap', function () { syncKsEditButtons(); });
document.addEventListener('DOMContentLoaded', function () { syncKsEditButtons(); });
// Sync once when the dialog opens so initial state matches the default
// mode (Station). The fieldset toggle below already fires on dialog
// open, so piggyback there.
// Add Interface modal, VLAN fieldset: keep the Name input synced to
// <parent>.<vid> as the user changes either side. The user can still
// override by typing into Name; once they type a custom value, we stop
// auto-syncing for that session (tracked via .dataset.userEdited).
document.addEventListener('input', function (e) {
var nameInput = document.getElementById('add-iface-vlan-name');
if (!nameInput) return;
var t = e.target;
if (t === nameInput) {
nameInput.dataset.userEdited = '1';
return;
}
if (t.id !== 'add-iface-vlan-parent' && t.id !== 'add-iface-vlan-vid') return;
if (nameInput.dataset.userEdited === '1') return;
var parent = document.getElementById('add-iface-vlan-parent');
var vid = document.getElementById('add-iface-vlan-vid');
if (parent && vid) {
nameInput.value = (parent.value || '') + '.' + (vid.value || '');
}
});
// On type select change in the Add Interface modal, show/enable the
// matching <fieldset> and hide/disable the rest. Disabling siblings is
// what keeps their inputs out of the form submission so only one
// `name="name"` reaches the server. Falls back to the "unsupported"
// panel for types whose Create path isn't implemented yet.
document.addEventListener('change', function (e) {
var sel = e.target.closest && e.target.closest('#add-iface-type-select');
if (!sel) return;
var opt = sel.options[sel.selectedIndex];
var slug = (opt && opt.getAttribute('data-slug')) || '';
var fields = document.querySelectorAll('.iface-fields');
var matched = null;
fields.forEach(function (fs) {
var isMatch = fs.id === 'iface-fields-' + slug;
fs.hidden = !isMatch;
fs.disabled = !isMatch;
if (isMatch) matched = fs;
});
if (!matched) {
var unsupported = document.getElementById('iface-fields-unsupported');
if (unsupported) {
unsupported.hidden = false;
unsupported.disabled = false;
}
}
var createBtn = document.getElementById('add-iface-create-btn');
if (createBtn) createBtn.disabled = !matched;
if (matched && matched.id === 'iface-fields-wifi') refreshWifiSec();
});
// Configure > Hardware "+ Add hardware" picker: sync the hidden class
// input from the selected option's data-class and reveal class-specific
// fields (currently WiFi country-code).
document.addEventListener('change', function (e) {
var sel = e.target.closest && e.target.closest('#add-hw-picker');
if (!sel) return;
var opt = sel.options[sel.selectedIndex];
var cls = (opt && opt.getAttribute('data-class')) || '';
var classInput = document.getElementById('add-hw-class');
if (classInput) classInput.value = cls;
var wifi = document.getElementById('add-hw-wifi-fields');
if (wifi) wifi.hidden = (cls !== 'wifi');
var country = document.getElementById('add-hw-wifi-country');
if (country) country.required = (cls === 'wifi');
});
})();
// Theme (auto / light / dark) — shared by main app and login page
(function() {
function getTheme() {
@@ -1120,15 +1437,24 @@ function openModal(message, onConfirm) {
if (!tip) {
tip = document.createElement('div');
tip.id = 'field-tip';
document.body.appendChild(tip);
}
return tip;
}
// A native <dialog>.showModal() renders in the browser's top layer,
// which is above z-index entirely; a tooltip appended to <body> ends
// up *below* the dialog regardless of z-index. Re-parent the tooltip
// into the active dialog so it shares the same top-layer context.
function parentFor(el) {
return el.closest('dialog[open]') || document.body;
}
document.addEventListener('mouseover', function(e) {
var el = e.target.closest('.field-info[data-tip]');
if (!el) return;
var t = getTip();
var parent = parentFor(el);
if (t.parentElement !== parent) parent.appendChild(t);
t.textContent = el.getAttribute('data-tip');
t.style.display = 'block';
var r = el.getBoundingClientRect();
+3
View File
@@ -0,0 +1,3 @@
{{/* Shared inline SVG icons. Loaded via fragments/*.html in server.go. */}}
{{define "icon-trash"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>{{end}}
@@ -0,0 +1,10 @@
{{define "wizard-psk-picker.html"}}
{{$id := "add-iface-wifi-psk"}}{{if .ID}}{{$id = .ID}}{{end}}
<select class="cfg-input" name="secret" id="{{$id}}">
{{if .Keys}}
{{range .Keys}}<option value="{{.Name}}" data-key-value="{{.Value}}"{{if eq .Name $.Selected}} selected{{end}}>{{.Name}}</option>{{end}}
{{else}}
<option value="" disabled selected>No keystore symmetric keys</option>
{{end}}
</select>
{{end}}
@@ -0,0 +1,14 @@
{{define "wizard-radio-picker.html"}}
{{$id := "add-iface-wifi-radio"}}{{if .ID}}{{$id = .ID}}{{end}}
<select class="cfg-input" name="radio" id="{{$id}}" required>
{{if .Radios}}
{{range $i, $r := .Radios}}<option value="{{$r.Name}}"
data-ap-ready="{{$r.APReady}}"
data-country="{{$r.Country}}"
data-band="{{$r.Band}}"
data-channel="{{$r.Channel}}"{{if eq $r.Name $.Selected}} selected{{else if and (eq $.Selected "") (eq $i 0)}} selected{{end}}>{{$r.Label}}</option>{{end}}
{{else}}
<option value="" disabled selected>No configured WiFi radios</option>
{{end}}
</select>
{{end}}
@@ -0,0 +1,9 @@
{{define "wizard-wgkey-picker.html"}}
<select class="cfg-input" name="private-key" id="add-iface-wg-key" required>
{{if .Keys}}
{{range .Keys}}<option value="{{.}}"{{if eq . $.Selected}} selected{{end}}>{{.}}</option>{{end}}
{{else}}
<option value="" disabled selected>No keystore keys</option>
{{end}}
</select>
{{end}}
+11 -1
View File
@@ -161,9 +161,19 @@
</details>
<details class="nav-group-top" id="nav-configure"
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-firewall") (eq .ActivePage "configure-routes") (eq .ActivePage "configure-users") (eq .ActivePage "configure-keystore") (eq .ActivePage "configure-tree")}}open{{end}}>
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-interfaces") (eq .ActivePage "configure-firewall") (eq .ActivePage "configure-routes") (eq .ActivePage "configure-users") (eq .ActivePage "configure-keystore") (eq .ActivePage "configure-tree")}}open{{end}}>
<summary class="nav-group-summary-top">Configure</summary>
<ul class="nav-group-items">
<li>
<a href="/configure/interfaces"
hx-get="/configure/interfaces"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "configure-interfaces"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/interfaces.svg')"></span>
Interfaces
</a>
</li>
<li>
<a href="/configure/system"
hx-get="/configure/system"
File diff suppressed because it is too large Load Diff