mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}}
|
||||
@@ -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
Reference in New Issue
Block a user