webui: styling and cleanup
Vendor a Lucide icon set under static/img/icons/ and partial:ize the inline SVGs across base, login, firmware, sidebar, and yang-tree. Reorganize the sidebar with mirrored Status/Configure section labels. Rename NACM to Users & Groups and Firmware to Software; swap Routes and Routing between Status and Configure. Split Configure > System into General, NTP Client, and DNS Client pages, which opens up for relocating the Set Time RPC from the Maintenance section to a new Date & Time card in the System General config page. Update browser tab title on htmx navigation via an HX-Trigger setPageTitle event; encode the payload as 7-bit ASCII so the middle dot in "Page · Context" survives header transit. Add missing hover-help ⓘ icons to bridge port configuration, and adjust their alignment slightly. While in interface configuration: - fix the annoying Save => collapse so one can edit multiple interface sections without having to re-expand the iface - add missing dhcpv4 settings - add missing custom mac (use advanced view for other settings) - clean up Save button mess (consolidate) Rename Firmware -> Software everywhere in the Maintenance section and fix "Install progress stuck at 10%" issue. Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
@@ -5,6 +5,8 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"infix/webui/internal/restconf"
|
||||
"infix/webui/internal/security"
|
||||
@@ -30,7 +32,44 @@ func csrfToken(ctx context.Context) string {
|
||||
return security.TokenFromContext(ctx)
|
||||
}
|
||||
|
||||
func newPageData(r *http.Request, page, title string) PageData {
|
||||
// pageContext returns the top-level nav group ("Status", "Configure",
|
||||
// "Maintenance") for a given ActivePage slug. Used to build breadcrumb-style
|
||||
// browser-tab titles ("Page · Context") without each handler having to know
|
||||
// where it lives in the sidebar.
|
||||
func pageContext(page string) string {
|
||||
switch page {
|
||||
case "software", "backup", "system-control":
|
||||
return "Maintenance"
|
||||
}
|
||||
if strings.HasPrefix(page, "configure-") {
|
||||
return "Configure"
|
||||
}
|
||||
return "Status"
|
||||
}
|
||||
|
||||
func newPageData(w http.ResponseWriter, r *http.Request, page, leaf string) PageData {
|
||||
title := leaf
|
||||
if ctx := pageContext(page); ctx != "" {
|
||||
if leaf == "" {
|
||||
title = ctx
|
||||
} else {
|
||||
title = leaf + " · " + ctx
|
||||
}
|
||||
}
|
||||
|
||||
// On HTMX swaps only #content is replaced, leaving the <title> element in
|
||||
// <head> stale. Fire a setPageTitle event so the JS listener in app.js
|
||||
// can update document.title. Safe to overwrite any prior HX-Trigger
|
||||
// header: only GET handlers reach newPageData, and those don't share
|
||||
// response paths with the save-side helpers (renderSaved /
|
||||
// renderSaveError) that also use HX-Trigger.
|
||||
// strconv.QuoteToASCII escapes non-ASCII as \uXXXX so the header value
|
||||
// survives transit as 7-bit ASCII; browsers decode header bytes as
|
||||
// ISO-8859-1, which would otherwise turn our middle-dot separator into
|
||||
// mojibake on the JS side.
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
w.Header().Set("HX-Trigger", `{"setPageTitle":`+strconv.QuoteToASCII(title)+`}`)
|
||||
}
|
||||
return PageData{
|
||||
Username: restconf.CredentialsFromContext(r.Context()).Username,
|
||||
CsrfToken: csrfToken(r.Context()),
|
||||
|
||||
@@ -119,7 +119,7 @@ func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// DeleteLeaf removes a single leaf from the candidate datastore so the YANG
|
||||
// default takes effect. Used by curated-page ↺ reset buttons.
|
||||
// default takes effect. Used by curated-page reset buttons.
|
||||
// DELETE /configure/leaf?path=...&redirect=...
|
||||
func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Query().Get("path")
|
||||
@@ -128,7 +128,9 @@ func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "path and redirect required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := h.RC.Delete(r.Context(), candidatePath+path); err != nil {
|
||||
// Swallow data-missing: the leaf was already absent, so the reset
|
||||
// semantically succeeded — there was nothing left to remove.
|
||||
if err := h.RC.Delete(r.Context(), candidatePath+path); err != nil && !restconf.IsDataMissing(err) {
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ type ConfigureFirewallHandler struct {
|
||||
// GET /configure/firewall
|
||||
func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgFirewallPageData{
|
||||
PageData: newPageData(r, "configure-firewall", "Configure: Firewall"),
|
||||
PageData: newPageData(w, r, "configure-firewall", "Firewall"),
|
||||
}
|
||||
|
||||
mgr := h.Schema.Manager()
|
||||
|
||||
@@ -102,7 +102,7 @@ type ConfigureHardwareHandler struct {
|
||||
// GET /configure/hardware
|
||||
func (h *ConfigureHardwareHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgHardwarePageData{
|
||||
PageData: newPageData(r, "configure-hardware", "Configure: Hardware"),
|
||||
PageData: newPageData(w, r, "configure-hardware", "Hardware"),
|
||||
}
|
||||
|
||||
mgr := h.Schema.Manager()
|
||||
|
||||
@@ -123,6 +123,11 @@ type cfgIfaceRow struct {
|
||||
EthDuplex string // "" / "full" / "half"
|
||||
EthAdvertised []string // identityref leaf-list, empty = advertise all
|
||||
EthSupported []string // identityref leaf-list from operational data
|
||||
// DHCP enabled flags — captured BEFORE the placeholder DHCP/DHCPv6
|
||||
// containers are seeded so the template can tell "configured" from
|
||||
// "auto-injected placeholder" apart.
|
||||
DHCPv4Enabled bool
|
||||
DHCPv6Enabled bool
|
||||
}
|
||||
|
||||
// ifaceRadioMirror is the subset of wifi-radio fields we expose on the
|
||||
@@ -204,7 +209,7 @@ type ConfigureInterfacesHandler struct {
|
||||
// GET /configure/interfaces
|
||||
func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgIfacePageData{
|
||||
PageData: newPageData(r, "configure-interfaces", "Configure: Interfaces"),
|
||||
PageData: newPageData(w, r, "configure-interfaces", "Interfaces"),
|
||||
}
|
||||
|
||||
mgr := h.Schema.Manager()
|
||||
@@ -224,6 +229,7 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req
|
||||
"description": schema.DescriptionOf(mgr, ifPath+"/description"),
|
||||
"type": schema.DescriptionOf(mgr, ifPath+"/type"),
|
||||
"enabled": schema.DescriptionOf(mgr, ifPath+"/enabled"),
|
||||
"mac": descOr(mgr, ifPath+"/infix-interfaces:custom-phys-address/static", "Override the interface's default physical (MAC) address with a static unicast value."),
|
||||
"bridge-type": descOr(mgr, ifPath+bPath+"/vlans", "Presence of bridge/vlans switches the bridge into IEEE 802.1Q VLAN-filtering mode. Pick this if downstream ports need PVID and tagged/untagged membership."),
|
||||
"stp-force": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/force-protocol"),
|
||||
"stp-hello": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/hello-time"),
|
||||
@@ -242,10 +248,12 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req
|
||||
"ipv4-prefix": schema.DescriptionOf(mgr, ifPath+ip4+"/address/prefix-length"),
|
||||
"ipv4-dhcp": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-dhcp-client:dhcp"),
|
||||
"ipv4-autoconf": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-ip:autoconf"),
|
||||
"ipv4-forwarding": schema.DescriptionOf(mgr, ifPath+ip4+"/forwarding"),
|
||||
"ipv6-address": schema.DescriptionOf(mgr, ifPath+ip6+"/address/ip"),
|
||||
"ipv6-prefix": schema.DescriptionOf(mgr, ifPath+ip6+"/address/prefix-length"),
|
||||
"ipv6-slaac": schema.DescriptionOf(mgr, ifPath+ip6+"/autoconf"),
|
||||
"ipv6-dhcp": schema.DescriptionOf(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp"),
|
||||
"ipv6-forwarding": schema.DescriptionOf(mgr, ifPath+ip6+"/forwarding"),
|
||||
|
||||
// Add Interface wizard — augment paths still don't resolve
|
||||
// through goyang (same gap as LAG mode), so each entry is
|
||||
@@ -273,6 +281,15 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req
|
||||
"eth-advertised": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/auto-negotiation/infix-ethernet-interface:advertised-pmd-types", "Restrict auto-negotiation to advertise only these PMD types. Leave empty to advertise every mode the PHY supports."),
|
||||
"eth-duplex": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/duplex", "Force half- or full-duplex. Leave on Auto to let auto-negotiation pick. Modern PMDs are full-duplex only."),
|
||||
"eth-mdix": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/infix-ethernet-interface:mdi-x", "Force the copper MDI/MDI-X crossover pinout. Leave on Auto-MDIX (default) for any link that negotiates. Force MDI/MDI-X only when negotiation is disabled and the two ends must use opposite values."),
|
||||
"bp-bridge": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/bridge", "Bridge that this port joins as a member, carrying L2 traffic on its behalf."),
|
||||
"bp-pvid": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/pvid", "Port VLAN ID — VLAN assigned to untagged frames arriving on this port. Only meaningful when the parent bridge is in IEEE 802.1Q VLAN-filtering mode."),
|
||||
"bp-flood": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/flood", "Per-traffic-class control of how unknown destinations are flooded out this port. Unticking suppresses flooding of that traffic class."),
|
||||
"bp-mc-router": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/multicast/router", "Multicast router behaviour on this port. Auto (default) lets IGMP/MLD snooping decide; permanent forces the port to always receive multicast; off blocks it."),
|
||||
"bp-mc-fast-leave": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/multicast/fast-leave", "Drop the port from a multicast group immediately on IGMP/MLD leave instead of waiting for the next query. Suitable when each port has at most one receiver."),
|
||||
"lp-lag": descOr(mgr, ifPath+"/infix-interfaces:lag-port/lag", "LAG that this port joins as a slave. The LAG itself is configured on its own row."),
|
||||
"mc-snoop": descOr(mgr, ifPath+bPath+"/multicast/snooping", "Enable IGMP/MLD snooping on the bridge so multicast is forwarded only to ports with active receivers."),
|
||||
"mc-querier": descOr(mgr, ifPath+bPath+"/multicast/querier", "Querier role: auto (only when no other querier is heard), on (always send queries), or off."),
|
||||
"mc-query-int": descOr(mgr, ifPath+bPath+"/multicast/query-interval", "Interval (seconds) between IGMP/MLD general queries when this bridge is acting as querier."),
|
||||
}
|
||||
if data.Desc["ipv6-slaac"] == "" {
|
||||
data.Desc["ipv6-slaac"] = "SLAAC (Stateless Address Autoconfiguration, RFC 4862) " +
|
||||
@@ -1101,7 +1118,9 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h
|
||||
renderSavedRedirect(w, name+" deleted", "/configure/interfaces")
|
||||
}
|
||||
|
||||
// SaveGeneral saves description and enabled for any interface.
|
||||
// SaveGeneral saves description, enabled, and optional custom MAC for any
|
||||
// interface. Empty MAC clears the override (DELETE on custom-phys-address);
|
||||
// non-empty installs it as a static override.
|
||||
// POST /configure/interfaces/{name}
|
||||
func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -1122,7 +1141,26 @@ func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http.
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Saved", "/configure/interfaces")
|
||||
|
||||
macPath := ifacePath(name) + "/infix-interfaces:custom-phys-address"
|
||||
if mac := strings.TrimSpace(r.FormValue("mac")); mac != "" {
|
||||
macBody := map[string]any{
|
||||
"infix-interfaces:custom-phys-address": map[string]any{"static": mac},
|
||||
}
|
||||
if err := h.RC.Put(r.Context(), macPath, macBody); err != nil {
|
||||
log.Printf("configure interfaces %s mac: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.RC.Delete(r.Context(), macPath); err != nil && !restconf.IsNotFound(err) {
|
||||
log.Printf("configure interfaces %s mac clear: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
renderSaved(w, "Saved")
|
||||
}
|
||||
|
||||
// AddIPv4 adds an IPv4 address to an interface.
|
||||
@@ -1233,7 +1271,7 @@ func (h *ConfigureInterfacesHandler) SaveEthernet(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
renderSavedRedirect(w, "Ethernet saved", "/configure/interfaces")
|
||||
renderSaved(w, "Ethernet saved")
|
||||
}
|
||||
|
||||
// ResetEthernetAdvertised clears the advertised-pmd-types leaf-list while
|
||||
@@ -1269,7 +1307,7 @@ func (h *ConfigureInterfacesHandler) ResetEthernetAdvertised(w http.ResponseWrit
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Reset to default", "/configure/interfaces")
|
||||
renderSaved(w, "Reset to default")
|
||||
}
|
||||
|
||||
func (h *ConfigureInterfacesHandler) SaveBridgePort(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1290,7 +1328,7 @@ func (h *ConfigureInterfacesHandler) SaveBridgePort(w http.ResponseWriter, r *ht
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Bridge port saved", "/configure/interfaces")
|
||||
renderSaved(w, "Bridge port saved")
|
||||
}
|
||||
|
||||
// buildBridgePortBody assembles the bridge-port augment from form
|
||||
@@ -1384,20 +1422,9 @@ func unwrapSingleInterface(doc map[string]any) (map[string]any, error) {
|
||||
return iface, nil
|
||||
}
|
||||
|
||||
// SaveBridgeMembers performs a diff-and-write to set the bridge's member ports.
|
||||
// POST /configure/interfaces/{name}/bridge/members
|
||||
func (h *ConfigureInterfacesHandler) SaveBridgeMembers(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.saveMembersDiff(w, r, r.PathValue("name"), "bridge",
|
||||
func(iface ifaceJSON, master string) bool {
|
||||
return iface.BridgePort != nil && iface.BridgePort.Bridge == master
|
||||
}, "Bridge members saved")
|
||||
}
|
||||
|
||||
// SaveBridge saves bridge STP settings and bridge type.
|
||||
// SaveBridge saves bridge type and member ports in one round trip from the
|
||||
// unified "Bridge Settings" form. STP/multicast keep their own foldout
|
||||
// forms; this handler covers what used to be two side-by-side forms.
|
||||
// POST /configure/interfaces/{name}/bridge
|
||||
func (h *ConfigureInterfacesHandler) SaveBridge(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -1452,7 +1479,15 @@ func (h *ConfigureInterfacesHandler) SaveBridge(w http.ResponseWriter, r *http.R
|
||||
}
|
||||
}
|
||||
|
||||
renderSavedRedirect(w, "Bridge saved", "/configure/interfaces")
|
||||
if err := h.applyMembersDiff(r, name, "bridge",
|
||||
func(iface ifaceJSON, master string) bool {
|
||||
return iface.BridgePort != nil && iface.BridgePort.Bridge == master
|
||||
}); err != nil {
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
renderSaved(w, "Bridge saved")
|
||||
}
|
||||
|
||||
// AddVLAN creates a new VLAN on an ieee8021q bridge.
|
||||
@@ -1519,7 +1554,7 @@ func (h *ConfigureInterfacesHandler) SaveVLAN(w http.ResponseWriter, r *http.Req
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "VLAN saved", "/configure/interfaces")
|
||||
renderSaved(w, "VLAN saved")
|
||||
}
|
||||
|
||||
// DeleteVLAN removes a VLAN from an ieee8021q bridge.
|
||||
@@ -1557,7 +1592,7 @@ func (h *ConfigureInterfacesHandler) SaveLagPort(w http.ResponseWriter, r *http.
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "LAG port saved", "/configure/interfaces")
|
||||
renderSaved(w, "LAG port saved")
|
||||
}
|
||||
|
||||
// SaveBridgeSTP PATCHes the bridge STP container. Split out from
|
||||
@@ -1592,7 +1627,7 @@ func (h *ConfigureInterfacesHandler) SaveBridgeSTP(w http.ResponseWriter, r *htt
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "STP saved", "/configure/interfaces")
|
||||
renderSaved(w, "STP saved")
|
||||
}
|
||||
|
||||
// SaveBridgeMulticast PATCHes the bridge multicast snooping container.
|
||||
@@ -1621,7 +1656,7 @@ func (h *ConfigureInterfacesHandler) SaveBridgeMulticast(w http.ResponseWriter,
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "Multicast saved", "/configure/interfaces")
|
||||
renderSaved(w, "Multicast saved")
|
||||
}
|
||||
|
||||
// SaveWifi PATCHes the WiFi interface container plus, when the form
|
||||
@@ -1692,7 +1727,7 @@ func (h *ConfigureInterfacesHandler) SaveWifi(w http.ResponseWriter, r *http.Req
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "WiFi saved", "/configure/interfaces")
|
||||
renderSaved(w, "WiFi saved")
|
||||
}
|
||||
|
||||
// DeleteLagPort detaches an interface from its LAG.
|
||||
@@ -1737,7 +1772,7 @@ func (h *ConfigureInterfacesHandler) SaveLAG(w http.ResponseWriter, r *http.Requ
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSavedRedirect(w, "LAG saved", "/configure/interfaces")
|
||||
renderSaved(w, "LAG saved")
|
||||
}
|
||||
|
||||
// SaveLAGMembers performs a diff-and-write to set the LAG's member ports.
|
||||
@@ -1788,11 +1823,22 @@ func indexWifiRadios(comps []hwComponentJSON) map[string]*ifaceRadioMirror {
|
||||
// kind is "bridge" or "lag"; it determines the YANG augment path and body key.
|
||||
func (h *ConfigureInterfacesHandler) saveMembersDiff(w http.ResponseWriter, r *http.Request,
|
||||
masterName, kind string, isMember func(ifaceJSON, string) bool, successMsg string) {
|
||||
if err := h.applyMembersDiff(r, masterName, kind, isMember); err != nil {
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, successMsg)
|
||||
}
|
||||
|
||||
// applyMembersDiff is the no-response-writing core of saveMembersDiff so it
|
||||
// can be reused by callers that compose multiple save steps (e.g. SaveBridge
|
||||
// which writes type + members in one form submission).
|
||||
func (h *ConfigureInterfacesHandler) applyMembersDiff(r *http.Request,
|
||||
masterName, kind string, isMember func(ifaceJSON, string) bool) error {
|
||||
|
||||
ifaces, err := h.fetchAllInterfaces(r.Context())
|
||||
if err != nil {
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
submitted := make(map[string]bool)
|
||||
@@ -1813,18 +1859,16 @@ func (h *ConfigureInterfacesHandler) saveMembersDiff(w http.ResponseWriter, r *h
|
||||
body := map[string]any{portKey: map[string]any{kind: masterName}}
|
||||
if err := h.RC.Put(r.Context(), portPath, body); err != nil {
|
||||
log.Printf("configure interfaces %s members add %s→%s: %v", kind, iface.Name, masterName, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
} else if !wantMember && currentlyMember {
|
||||
if err := h.RC.Delete(r.Context(), portPath); err != nil {
|
||||
log.Printf("configure interfaces %s members remove %s from %s: %v", kind, iface.Name, masterName, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
renderSavedRedirect(w, successMsg, "/configure/interfaces")
|
||||
return nil
|
||||
}
|
||||
|
||||
// unconfiguredPhysical returns physical Ethernet interfaces present in
|
||||
@@ -1953,6 +1997,31 @@ func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON, oper []ifaceJ
|
||||
row.ParentBridgeIs8021Q = bridgeIs8021Q[iface.BridgePort.Bridge]
|
||||
}
|
||||
row.HasIP = !row.IsBridgePort && !row.IsLagPort
|
||||
// The DHCP/DHCPv6 foldouts are always rendered so users can
|
||||
// discover the settings form before enabling the client. The
|
||||
// foldout body iterates .IPv4.DHCP / .IPv6.DHCPv6 — populate
|
||||
// placeholders here so the template can use .IPv4.DHCP.X without
|
||||
// a forest of nil guards. Whether DHCP is actually configured is
|
||||
// captured separately on .DHCPv4Enabled / .DHCPv6Enabled so the
|
||||
// checkbox state and foldout-hidden gate still reflect candidate.
|
||||
if row.HasIP {
|
||||
if row.IPv4 == nil {
|
||||
row.IPv4 = &ipCfg{}
|
||||
} else {
|
||||
row.DHCPv4Enabled = row.IPv4.DHCP != nil
|
||||
}
|
||||
if row.IPv4.DHCP == nil {
|
||||
row.IPv4.DHCP = &dhcpv4CfgJSON{}
|
||||
}
|
||||
if row.IPv6 == nil {
|
||||
row.IPv6 = &ipCfg{}
|
||||
} else {
|
||||
row.DHCPv6Enabled = row.IPv6.DHCPv6 != nil
|
||||
}
|
||||
if row.IPv6.DHCPv6 == nil {
|
||||
row.IPv6.DHCPv6 = &dhcpv6CfgJSON{}
|
||||
}
|
||||
}
|
||||
row.AddrSummary = addrSummary(iface)
|
||||
|
||||
if row.IsBridge {
|
||||
@@ -2076,40 +2145,67 @@ func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.R
|
||||
renderSavedRedirect(w, "Address removed", "/configure/interfaces")
|
||||
}
|
||||
|
||||
// SaveIPv4DHCP enables or disables the DHCPv4 client presence container.
|
||||
// POST /configure/interfaces/{name}/ipv4/dhcp
|
||||
func (h *ConfigureInterfacesHandler) SaveIPv4DHCP(w http.ResponseWriter, r *http.Request) {
|
||||
h.togglePresence(w, r,
|
||||
ifacePath(r.PathValue("name"))+"/ietf-ip:ipv4/infix-dhcp-client:dhcp",
|
||||
"infix-dhcp-client:dhcp",
|
||||
"DHCP client")
|
||||
// SaveIPv4Settings PATCHes the per-interface IPv4 group settings — forwarding
|
||||
// leaf plus the DHCP-client and link-local autoconf presence containers — in
|
||||
// a single round trip from the IPv4 settings form. Each presence container is
|
||||
// PUT (enable) or DELETE (disable) per checkbox state; forwarding is PATCHed.
|
||||
// POST /configure/interfaces/{name}/ipv4/settings
|
||||
func (h *ConfigureInterfacesHandler) SaveIPv4Settings(w http.ResponseWriter, r *http.Request) {
|
||||
h.saveIPSettings(w, r, "ietf-ip:ipv4", "IPv4", map[string]string{
|
||||
"dhcp": "infix-dhcp-client:dhcp",
|
||||
"autoconf": "infix-ip:autoconf",
|
||||
})
|
||||
}
|
||||
|
||||
// SaveIPv4Autoconf enables or disables IPv4 link-local autoconfiguration.
|
||||
// POST /configure/interfaces/{name}/ipv4/autoconf
|
||||
func (h *ConfigureInterfacesHandler) SaveIPv4Autoconf(w http.ResponseWriter, r *http.Request) {
|
||||
h.togglePresence(w, r,
|
||||
ifacePath(r.PathValue("name"))+"/ietf-ip:ipv4/infix-ip:autoconf",
|
||||
"infix-ip:autoconf",
|
||||
"IPv4 link-local")
|
||||
// SaveIPv6Settings is the IPv6 counterpart of SaveIPv4Settings. SLAAC lives at
|
||||
// the standard ietf-ip "autoconf" container; DHCPv6 is the Infix augment.
|
||||
// POST /configure/interfaces/{name}/ipv6/settings
|
||||
func (h *ConfigureInterfacesHandler) SaveIPv6Settings(w http.ResponseWriter, r *http.Request) {
|
||||
h.saveIPSettings(w, r, "ietf-ip:ipv6", "IPv6", map[string]string{
|
||||
"dhcp": "infix-dhcpv6-client:dhcp",
|
||||
"slaac": "autoconf",
|
||||
})
|
||||
}
|
||||
|
||||
// SaveIPv6SLAAC enables or disables IPv6 SLAAC (autoconf).
|
||||
// POST /configure/interfaces/{name}/ipv6/autoconf
|
||||
func (h *ConfigureInterfacesHandler) SaveIPv6SLAAC(w http.ResponseWriter, r *http.Request) {
|
||||
h.togglePresence(w, r,
|
||||
ifacePath(r.PathValue("name"))+"/ietf-ip:ipv6/autoconf",
|
||||
"autoconf",
|
||||
"IPv6 SLAAC")
|
||||
}
|
||||
// saveIPSettings is the shared body of SaveIPv4Settings / SaveIPv6Settings.
|
||||
// presenceMap maps form-field names (e.g. "dhcp") to their YANG presence
|
||||
// container key (e.g. "infix-dhcp-client:dhcp"); each one is PUT when checked
|
||||
// and DELETEd otherwise. Forwarding is always PATCHed.
|
||||
func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *http.Request, container, family string, presenceMap map[string]string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
base := ifacePath(name) + "/" + container
|
||||
|
||||
// SaveIPv6DHCP enables or disables the DHCPv6 client presence container.
|
||||
// POST /configure/interfaces/{name}/ipv6/dhcp
|
||||
func (h *ConfigureInterfacesHandler) SaveIPv6DHCP(w http.ResponseWriter, r *http.Request) {
|
||||
h.togglePresence(w, r,
|
||||
ifacePath(r.PathValue("name"))+"/ietf-ip:ipv6/infix-dhcpv6-client:dhcp",
|
||||
"infix-dhcpv6-client:dhcp",
|
||||
"DHCPv6 client")
|
||||
forwarding := r.FormValue("forwarding") == "true"
|
||||
body := map[string]any{container: map[string]any{"forwarding": forwarding}}
|
||||
if err := h.RC.Patch(r.Context(), base, body); err != nil {
|
||||
log.Printf("configure interfaces %s %s settings: forwarding: %v", name, family, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
for field, child := range presenceMap {
|
||||
path := base + "/" + child
|
||||
if r.FormValue(field) == "true" {
|
||||
b := map[string]any{child: map[string]any{}}
|
||||
if err := h.RC.Put(r.Context(), path, b); err != nil {
|
||||
log.Printf("configure interfaces %s %s settings: enable %s: %v", name, family, field, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) {
|
||||
log.Printf("configure interfaces %s %s settings: disable %s: %v", name, family, field, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderSaved(w, family+" settings saved")
|
||||
}
|
||||
|
||||
func (h *ConfigureInterfacesHandler) SaveIPv4DHCPSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -2218,29 +2314,6 @@ func (h *ConfigureInterfacesHandler) DeleteIPv6DHCPOption(w http.ResponseWriter,
|
||||
h.deleteDHCPOption(w, r, "ietf-ip:ipv6/infix-dhcpv6-client:dhcp")
|
||||
}
|
||||
|
||||
func (h *ConfigureInterfacesHandler) togglePresence(w http.ResponseWriter, r *http.Request, path, bodyKey, label string) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
if r.FormValue("enabled") == "true" {
|
||||
body := map[string]any{bodyKey: map[string]any{}}
|
||||
if err := h.RC.Put(r.Context(), path, body); err != nil {
|
||||
log.Printf("configure interfaces %s enable %s: %v", name, label, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) {
|
||||
log.Printf("configure interfaces %s disable %s: %v", name, label, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
renderSavedRedirect(w, label+" updated", "/configure/interfaces")
|
||||
}
|
||||
|
||||
func typeSlug(yangType string) string {
|
||||
s := schema.StripModulePrefix(yangType)
|
||||
// Normalise iana-if-type identities to infix slugs where relevant.
|
||||
@@ -2339,7 +2412,7 @@ type wifiRadioOption struct {
|
||||
// pulldown. Sorted by typeOrder then alphabetical. Includes types whose
|
||||
// Create path is not yet implemented (gre, gretap, vxlan, wireguard,
|
||||
// wifi, …) — the modal's "unsupported" panel handles those by pointing
|
||||
// the user at the Advanced YANG tree.
|
||||
// the user at the Edit-all YANG tree.
|
||||
// buildWifiRadioOptions filters detected hardware components down to
|
||||
// configured WiFi radios (class=wifi with a wifi-radio container present
|
||||
// in running config) and returns picker entries with a label that hints
|
||||
|
||||
@@ -33,9 +33,15 @@ type cfgKeystorePageData struct {
|
||||
}
|
||||
|
||||
type cfgSymKeyEntry struct {
|
||||
Name string
|
||||
Format string
|
||||
Value string
|
||||
Name string
|
||||
Format string // short label shown in the read-only column ("passphrase")
|
||||
FormatID string // raw identityref ("infix-crypto-types:passphrase-key-format")
|
||||
// — Format is shortFormat-stripped for display, FormatID is what the
|
||||
// edit-row <select> needs to match its <option value=...> against.
|
||||
// Comparing the two in the template silently mismatches and the
|
||||
// browser falls back to the first <option>, rewriting the key with
|
||||
// the wrong format on Save.
|
||||
Value string
|
||||
}
|
||||
|
||||
type cfgCertEntry struct {
|
||||
@@ -63,7 +69,7 @@ type ConfigureKeystoreHandler struct {
|
||||
// GET /configure/keystore
|
||||
func (h *ConfigureKeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgKeystorePageData{
|
||||
PageData: newPageData(r, "configure-keystore", "Configure: Keystore"),
|
||||
PageData: newPageData(w, r, "configure-keystore", "Keystore"),
|
||||
}
|
||||
|
||||
var ks keystoreWrapper
|
||||
@@ -79,9 +85,10 @@ func (h *ConfigureKeystoreHandler) Overview(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey {
|
||||
data.SymmetricKeys = append(data.SymmetricKeys, cfgSymKeyEntry{
|
||||
Name: k.Name,
|
||||
Format: shortFormat(k.KeyFormat),
|
||||
Value: decodeSymmetricValue(k),
|
||||
Name: k.Name,
|
||||
Format: shortFormat(k.KeyFormat),
|
||||
FormatID: k.KeyFormat,
|
||||
Value: decodeSymmetricValue(k),
|
||||
})
|
||||
}
|
||||
for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey {
|
||||
|
||||
@@ -79,18 +79,18 @@ type cfgRoutesPageData struct {
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ConfigureRoutesHandler serves the Configure > Routes page.
|
||||
// ConfigureRoutesHandler serves the Configure > Routing page.
|
||||
type ConfigureRoutesHandler struct {
|
||||
Template *template.Template
|
||||
RC restconf.Fetcher
|
||||
Schema *schema.Cache
|
||||
}
|
||||
|
||||
// Overview renders the Configure > Routes page reading from the candidate.
|
||||
// Overview renders the Configure > Routing page reading from the candidate.
|
||||
// GET /configure/routes
|
||||
func (h *ConfigureRoutesHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgRoutesPageData{
|
||||
PageData: newPageData(r, "configure-routes", "Configure: Routes"),
|
||||
PageData: newPageData(w, r, "configure-routes", "Routing"),
|
||||
}
|
||||
|
||||
mgr := h.Schema.Manager()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"infix/webui/internal/restconf"
|
||||
"infix/webui/internal/schema"
|
||||
@@ -70,16 +71,15 @@ type cfgDNSAddrJSON struct {
|
||||
|
||||
type cfgSystemPageData struct {
|
||||
PageData
|
||||
Loading bool // true while YANG schema is still downloading
|
||||
Error string
|
||||
Hostname string
|
||||
Contact string
|
||||
Location string
|
||||
Timezone string
|
||||
NTP cfgNTPJSON
|
||||
DNS cfgDNSJSON
|
||||
MotdBanner string // decoded from YANG binary
|
||||
TextEditor string // e.g. "infix-system:emacs"
|
||||
Loading bool // true while YANG schema is still downloading
|
||||
Error string
|
||||
Hostname string
|
||||
Contact string
|
||||
Location string
|
||||
Timezone string
|
||||
CurrentDatetime string // device clock from system-state, empty when unavailable
|
||||
MotdBanner string // decoded from YANG binary
|
||||
TextEditor string // e.g. "infix-system:emacs"
|
||||
|
||||
// Schema-enriched fields — only populated when Loading is false.
|
||||
TextEditorOptions []schema.IdentityOption
|
||||
@@ -87,47 +87,116 @@ type cfgSystemPageData struct {
|
||||
Desc map[string]string // leaf name → YANG description
|
||||
}
|
||||
|
||||
type cfgNTPPageData struct {
|
||||
PageData
|
||||
Error string
|
||||
NTP cfgNTPJSON
|
||||
}
|
||||
|
||||
type cfgDNSPageData struct {
|
||||
PageData
|
||||
Error string
|
||||
DNS cfgDNSJSON
|
||||
}
|
||||
|
||||
// ─── Handler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ConfigureSystemHandler serves the Configure > System page.
|
||||
// ConfigureSystemHandler serves the Configure > General, NTP Client, and
|
||||
// DNS Client pages, all of which share the same candidate-datastore source.
|
||||
type ConfigureSystemHandler struct {
|
||||
Template *template.Template
|
||||
RC restconf.Fetcher
|
||||
Schema *schema.Cache
|
||||
Template *template.Template
|
||||
NTPTemplate *template.Template
|
||||
DNSTemplate *template.Template
|
||||
RC restconf.Fetcher
|
||||
Schema *schema.Cache
|
||||
}
|
||||
|
||||
const candidatePath = "/ds/ietf-datastores:candidate"
|
||||
|
||||
// Overview renders the Configure > System page reading from the candidate datastore.
|
||||
// GET /configure/system
|
||||
func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgSystemPageData{
|
||||
PageData: newPageData(r, "configure-system", "Configure: System"),
|
||||
}
|
||||
|
||||
// loadSystem reads /ietf-system:system from the candidate datastore, falling
|
||||
// back to running when candidate is uninitialised. The returned errMsg is
|
||||
// non-empty only for real errors that should surface to the user.
|
||||
func (h *ConfigureSystemHandler) loadSystem(r *http.Request) (cfgSystemJSON, string) {
|
||||
var raw cfgSystemWrapper
|
||||
if err := h.RC.Get(r.Context(), candidatePath+"/ietf-system:system", &raw); err != nil {
|
||||
if !restconf.IsNotFound(err) {
|
||||
log.Printf("configure system: %v", err)
|
||||
data.Error = "Could not read candidate configuration"
|
||||
} else if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) {
|
||||
// Candidate not initialised — fall back to running; only real errors surface.
|
||||
return cfgSystemJSON{}, "Could not read candidate configuration"
|
||||
}
|
||||
if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) {
|
||||
log.Printf("configure system (running fallback): %v", fallErr)
|
||||
data.Error = "Could not read system configuration"
|
||||
return cfgSystemJSON{}, "Could not read system configuration"
|
||||
}
|
||||
}
|
||||
if data.Error == "" {
|
||||
s := raw.System
|
||||
return raw.System, ""
|
||||
}
|
||||
|
||||
// render swaps the root template for "content" on HTMX requests so a partial
|
||||
// reply skips the base shell. pageName is the same key passed to newPageData
|
||||
// (e.g. "configure-ntp") — the corresponding template file is "<pageName>.html".
|
||||
func (h *ConfigureSystemHandler) render(w http.ResponseWriter, r *http.Request, tmpl *template.Template, pageName string, data any) {
|
||||
tmplName := pageName + ".html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
tmplName = "content"
|
||||
}
|
||||
if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Overview renders the Configure > General page (identity, clock, preferences).
|
||||
// GET /configure/system
|
||||
func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgSystemPageData{
|
||||
PageData: newPageData(w, r, "configure-system", "General"),
|
||||
}
|
||||
|
||||
// Fetch candidate config and operational clock in parallel — they hit
|
||||
// different RESTCONF resources and the round-trips are independent.
|
||||
var (
|
||||
s cfgSystemJSON
|
||||
errMsg string
|
||||
clockResp struct {
|
||||
SystemState struct {
|
||||
Clock struct {
|
||||
CurrentDatetime string `json:"current-datetime"`
|
||||
} `json:"clock"`
|
||||
} `json:"ietf-system:system-state"`
|
||||
}
|
||||
clockErr error
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); s, errMsg = h.loadSystem(r) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
clockErr = h.RC.Get(r.Context(), "/data/ietf-system:system-state/clock", &clockResp)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
data.Error = errMsg
|
||||
if errMsg == "" {
|
||||
data.Hostname = s.Hostname
|
||||
data.Contact = s.Contact
|
||||
data.Location = s.Location
|
||||
data.Timezone = s.Clock.TimezoneName
|
||||
data.NTP = s.NTP
|
||||
data.DNS = s.DNS
|
||||
data.MotdBanner = string(s.MotdBanner)
|
||||
data.TextEditor = s.TextEditor
|
||||
}
|
||||
|
||||
// Operational clock for the Date & Time card. Best-effort; the
|
||||
// template renders "Device clock unavailable" when CurrentDatetime
|
||||
// stays empty. Truncate the RFC 3339 offset; the Timezone row
|
||||
// below gives the user the zone context.
|
||||
if clockErr == nil {
|
||||
dt := clockResp.SystemState.Clock.CurrentDatetime
|
||||
if len(dt) > 19 {
|
||||
dt = dt[:19]
|
||||
}
|
||||
data.CurrentDatetime = dt
|
||||
}
|
||||
|
||||
mgr := h.Schema.Manager()
|
||||
data.Loading = mgr == nil
|
||||
if mgr != nil {
|
||||
@@ -154,14 +223,35 @@ func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
}
|
||||
|
||||
tmplName := "configure-system.html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
tmplName = "content"
|
||||
h.render(w, r, h.Template, "configure-system", data)
|
||||
}
|
||||
|
||||
// OverviewNTP renders the Configure > NTP Client page.
|
||||
// GET /configure/ntp
|
||||
func (h *ConfigureSystemHandler) OverviewNTP(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgNTPPageData{
|
||||
PageData: newPageData(w, r, "configure-ntp", "NTP Client"),
|
||||
}
|
||||
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
s, errMsg := h.loadSystem(r)
|
||||
data.Error = errMsg
|
||||
if errMsg == "" {
|
||||
data.NTP = s.NTP
|
||||
}
|
||||
h.render(w, r, h.NTPTemplate, "configure-ntp", data)
|
||||
}
|
||||
|
||||
// OverviewDNS renders the Configure > DNS Client page.
|
||||
// GET /configure/dns
|
||||
func (h *ConfigureSystemHandler) OverviewDNS(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgDNSPageData{
|
||||
PageData: newPageData(w, r, "configure-dns", "DNS Client"),
|
||||
}
|
||||
s, errMsg := h.loadSystem(r)
|
||||
data.Error = errMsg
|
||||
if errMsg == "" {
|
||||
data.DNS = s.DNS
|
||||
}
|
||||
h.render(w, r, h.DNSTemplate, "configure-dns", data)
|
||||
}
|
||||
|
||||
// SaveIdentity patches hostname / contact / location to the candidate datastore.
|
||||
@@ -195,6 +285,20 @@ func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
// Empty value = the "UTC (default)" placeholder. Treat it as a leaf
|
||||
// delete so the candidate matches Infix's "unset means UTC" convention;
|
||||
// swallow data-missing for idempotency (the leaf may already be absent).
|
||||
if r.FormValue("timezone") == "" {
|
||||
err := h.RC.Delete(r.Context(), candidatePath+"/ietf-system:system/clock/timezone-name")
|
||||
if err != nil && !restconf.IsDataMissing(err) {
|
||||
log.Printf("configure system clock: %v", err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Timezone saved")
|
||||
return
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"ietf-system:system": map[string]any{
|
||||
"clock": map[string]any{
|
||||
@@ -207,7 +311,7 @@ func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Reques
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
renderSaved(w, "Clock saved")
|
||||
renderSaved(w, "Timezone saved")
|
||||
}
|
||||
|
||||
// SaveNTP replaces the NTP server list in the candidate datastore.
|
||||
|
||||
@@ -84,7 +84,7 @@ const nacmGroupsPath = candidatePath + "/ietf-netconf-acm:nacm/groups"
|
||||
// GET /configure/users
|
||||
func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := cfgUsersPageData{
|
||||
PageData: newPageData(r, "configure-users", "Configure: Users & Groups"),
|
||||
PageData: newPageData(w, r, "configure-users", "Users & Groups"),
|
||||
}
|
||||
|
||||
// Read via the full system path (same as configure-system) to avoid
|
||||
|
||||
@@ -81,7 +81,7 @@ type ContainersHandler struct {
|
||||
// Overview renders the containers list page.
|
||||
func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := containersData{
|
||||
PageData: newPageData(r, "containers", "Containers"),
|
||||
PageData: newPageData(w, r, "containers", "Containers"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
|
||||
@@ -205,7 +205,7 @@ type dashboardData struct {
|
||||
OSVersion string
|
||||
Machine string
|
||||
CurrentTime string
|
||||
Firmware string
|
||||
Software string
|
||||
Uptime string
|
||||
MemTotal int64
|
||||
MemUsed int64
|
||||
@@ -249,6 +249,7 @@ type diskEntry struct {
|
||||
Available string
|
||||
Percent int
|
||||
Class string // "" / "is-warn" / "is-crit"
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// DashboardHandler serves the main dashboard page.
|
||||
@@ -260,7 +261,7 @@ type DashboardHandler struct {
|
||||
// Index renders the dashboard (GET /).
|
||||
func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
data := dashboardData{
|
||||
PageData: newPageData(r, "dashboard", "Overview"),
|
||||
PageData: newPageData(w, r, "dashboard", "Overview"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
@@ -312,7 +313,7 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
if data.Machine == "arm64" {
|
||||
data.Machine = "aarch64"
|
||||
}
|
||||
data.Firmware = firmwareVersion(ss.Software)
|
||||
data.Software = softwareVersion(ss.Software)
|
||||
data.Uptime = computeUptime(ss.Clock.BootDatetime, ss.Clock.CurrentDatetime)
|
||||
data.CurrentTime = formatCurrentTime(ss.Clock.CurrentDatetime)
|
||||
|
||||
@@ -347,23 +348,33 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
for _, fs := range ss.Resource.Filesystem {
|
||||
size := int64(fs.Size)
|
||||
used := int64(fs.Used)
|
||||
avail := int64(fs.Available)
|
||||
pct := 0
|
||||
if size > 0 {
|
||||
pct = int(float64(used) / float64(size) * 100)
|
||||
}
|
||||
// Read-only signature: used == size, no slack at all.
|
||||
// Squashfs/erofs rootfs reports this — pinning it at 100 %
|
||||
// for the lifetime of the running image, with nothing the
|
||||
// operator can do about it. Skip the crit/warn coloring so
|
||||
// it doesn't read as an actionable alert.
|
||||
readOnly := size > 0 && used == size && avail == 0
|
||||
diskClass := ""
|
||||
switch {
|
||||
case pct >= 90:
|
||||
diskClass = "is-crit"
|
||||
case pct >= 70:
|
||||
diskClass = "is-warn"
|
||||
if !readOnly {
|
||||
switch {
|
||||
case pct >= 90:
|
||||
diskClass = "is-crit"
|
||||
case pct >= 70:
|
||||
diskClass = "is-warn"
|
||||
}
|
||||
}
|
||||
data.Disks = append(data.Disks, diskEntry{
|
||||
Mount: fs.MountPoint,
|
||||
Size: humanKiB(size),
|
||||
Available: humanKiB(int64(fs.Available)),
|
||||
Available: humanKiB(avail),
|
||||
Percent: pct,
|
||||
Class: diskClass,
|
||||
ReadOnly: readOnly,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -412,8 +423,8 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// firmwareVersion returns the version string for the booted software slot.
|
||||
func firmwareVersion(sw software) string {
|
||||
// softwareVersion returns the version string for the booted software slot.
|
||||
func softwareVersion(sw software) string {
|
||||
for _, slot := range sw.Slot {
|
||||
if slot.Name == sw.Booted {
|
||||
return slot.Version
|
||||
|
||||
@@ -60,7 +60,7 @@ type DHCPHandler struct {
|
||||
// Overview renders the DHCP page (GET /dhcp).
|
||||
func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := dhcpPageData{
|
||||
PageData: newPageData(r, "dhcp", "DHCP Server"),
|
||||
PageData: newPageData(w, r, "dhcp", "DHCP"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -138,7 +138,7 @@ type FirewallHandler struct {
|
||||
// Overview renders the firewall overview (GET /firewall).
|
||||
func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := firewallData{
|
||||
PageData: newPageData(r, "firewall", "Firewall"),
|
||||
PageData: newPageData(w, r, "firewall", "Firewall"),
|
||||
}
|
||||
|
||||
var fw firewallWrapper
|
||||
|
||||
@@ -74,7 +74,7 @@ type HardwareHandler struct {
|
||||
|
||||
func (h *HardwareHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := hardwarePageData{
|
||||
PageData: newPageData(r, "hardware", "Hardware"),
|
||||
PageData: newPageData(w, r, "hardware", "Hardware"),
|
||||
}
|
||||
|
||||
// Detach from r.Context() so the RESTCONF call (and yanger behind it)
|
||||
|
||||
@@ -27,16 +27,17 @@ 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"`
|
||||
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"`
|
||||
CustomPhysAddress *customPhysAddress `json:"infix-interfaces:custom-phys-address"`
|
||||
IfIndex int `json:"if-index"`
|
||||
// Operational link rate from ietf-interfaces (yang:gauge64 bits/s).
|
||||
// The same-named leaf inside the ethernet container is obsolete.
|
||||
Speed yangInt64 `json:"speed"`
|
||||
Speed yangInt64 `json:"speed"`
|
||||
IPv4 *ipCfg `json:"ietf-ip:ipv4"`
|
||||
IPv6 *ipCfg `json:"ietf-ip:ipv6"`
|
||||
Statistics *ifaceStats `json:"statistics"`
|
||||
@@ -50,6 +51,13 @@ type ifaceJSON struct {
|
||||
WireGuard *wireGuardJSON `json:"infix-interfaces:wireguard"`
|
||||
}
|
||||
|
||||
// customPhysAddress mirrors infix-interfaces:custom-phys-address. Only the
|
||||
// static-MAC case is exposed in the WebUI; chassis-derived addresses are
|
||||
// uncommon and editable from the Advanced YANG tree.
|
||||
type customPhysAddress struct {
|
||||
Static string `json:"static"`
|
||||
}
|
||||
|
||||
type vlanCfgJSON struct {
|
||||
ID int `json:"id"`
|
||||
TagType string `json:"tag-type"`
|
||||
@@ -187,12 +195,13 @@ type dhcpv6CfgJSON struct {
|
||||
}
|
||||
|
||||
type ipCfg struct {
|
||||
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"`
|
||||
Address []ipAddr `json:"address"`
|
||||
MTU int `json:"mtu"`
|
||||
Forwarding bool `json:"forwarding"`
|
||||
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 {
|
||||
@@ -295,7 +304,7 @@ type InterfacesHandler struct {
|
||||
// Overview renders the interfaces page (GET /interfaces).
|
||||
func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := interfacesData{
|
||||
PageData: newPageData(r, "interfaces", "Interfaces"),
|
||||
PageData: newPageData(w, r, "interfaces", "Interfaces"),
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -948,7 +957,7 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
data := buildDetailData(r, iface)
|
||||
data.PageData = newPageData(r, "interfaces", "Interface "+name)
|
||||
data.PageData = newPageData(w, r, "interfaces", name)
|
||||
data.Desc = h.fieldDescriptions()
|
||||
|
||||
tmplName := "iface-detail.html"
|
||||
|
||||
@@ -46,7 +46,7 @@ type LLDPHandler struct {
|
||||
// Overview renders the LLDP page (GET /lldp).
|
||||
func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := lldpPageData{
|
||||
PageData: newPageData(r, "lldp", "LLDP Neighbors"),
|
||||
PageData: newPageData(w, r, "lldp", "LLDP"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -97,7 +97,7 @@ type MDNSHandler struct {
|
||||
// Overview renders the mDNS page (GET /mdns).
|
||||
func (h *MDNSHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := mdnsPageData{
|
||||
PageData: newPageData(r, "mdns", "mDNS"),
|
||||
PageData: newPageData(w, r, "mdns", "mDNS"),
|
||||
}
|
||||
|
||||
var raw mdnsWrapper
|
||||
|
||||
@@ -125,7 +125,7 @@ type NACMHandler struct {
|
||||
// Overview renders the NACM page (GET /nacm).
|
||||
func (h *NACMHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := nacmPageData{
|
||||
PageData: newPageData(r, "nacm", "NACM"),
|
||||
PageData: newPageData(w, r, "nacm", "Users & Groups"),
|
||||
}
|
||||
|
||||
var nacmRaw nacmWrapper
|
||||
|
||||
@@ -56,7 +56,7 @@ type NTPHandler struct {
|
||||
// Overview renders the NTP page (GET /ntp).
|
||||
func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := ntpPageData{
|
||||
PageData: newPageData(r, "ntp", "NTP"),
|
||||
PageData: newPageData(w, r, "ntp", "NTP"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -191,7 +191,7 @@ type ospfNeighborJSON struct {
|
||||
|
||||
func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := routingData{
|
||||
PageData: newPageData(r, "routing", "Routing"),
|
||||
PageData: newPageData(w, r, "routing", "Routes"),
|
||||
}
|
||||
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
|
||||
@@ -79,7 +79,7 @@ type ServicesHandler struct {
|
||||
// Overview renders the services page (GET /services).
|
||||
func (h *ServicesHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := servicesPageData{
|
||||
PageData: newPageData(r, "services", "Services"),
|
||||
PageData: newPageData(w, r, "services", "Services"),
|
||||
}
|
||||
|
||||
var raw servicesWrapper
|
||||
|
||||
@@ -23,10 +23,10 @@ import (
|
||||
|
||||
// raucInstallationStatus reads RAUC's Operation/Progress/LastError D-Bus
|
||||
// properties directly via the rauc-installation-status helper. Used during
|
||||
// installs because the RESTCONF path goes through yanger, which runs
|
||||
// `rauc status` and blocks while RAUC is busy.
|
||||
func raucInstallationStatus(ctx context.Context) (fwInstallerState, error) {
|
||||
var inst fwInstallerState
|
||||
// installs because the RESTCONF path goes through the operational-state
|
||||
// machinery, which runs `rauc status` and blocks while RAUC is busy.
|
||||
func raucInstallationStatus(ctx context.Context) (swInstallerState, error) {
|
||||
var inst swInstallerState
|
||||
out, err := exec.CommandContext(ctx, "/usr/bin/rauc-installation-status").Output()
|
||||
if err != nil {
|
||||
return inst, err
|
||||
@@ -35,27 +35,26 @@ func raucInstallationStatus(ctx context.Context) (fwInstallerState, error) {
|
||||
return inst, err
|
||||
}
|
||||
|
||||
// SystemHandler provides reboot, config download, and firmware update actions.
|
||||
// SystemHandler provides reboot, config download, and software install actions.
|
||||
type SystemHandler struct {
|
||||
RC *restconf.Client
|
||||
Template *template.Template // firmware page template
|
||||
Template *template.Template // software page template
|
||||
SysCtrlTmpl *template.Template // system control page template
|
||||
BackupTmpl *template.Template // backup & restore page template
|
||||
|
||||
// fwSlots caches the last successfully-fetched Software card payload
|
||||
// so /firmware?installing=1 can keep rendering slot details — RESTCONF
|
||||
// /data/ietf-system:system-state blocks on `rauc status` while RAUC
|
||||
// is busy, and the user wants to read (and adjust) boot order even
|
||||
// between install attempts.
|
||||
fwSlots fwSlotSnapshot
|
||||
// swSlots caches the last successfully-fetched Software card payload
|
||||
// so /software?installing=1 can keep rendering slot details — the
|
||||
// RESTCONF path blocks on `rauc status` while RAUC is busy, and the
|
||||
// user wants to read (and adjust) boot order even between install
|
||||
// attempts.
|
||||
swSlots swSlotSnapshot
|
||||
}
|
||||
|
||||
// fwSlotSnapshot is a tiny RWMutex-guarded copy of the Firmware page's
|
||||
// Software card body. Mirrors the schema.Cache shape (rw lock + payload)
|
||||
// from internal/schema/refresh.go.
|
||||
type fwSlotSnapshot struct {
|
||||
// swSlotSnapshot is a tiny RWMutex-guarded copy of the Software page's
|
||||
// card body. Mirrors the schema.Cache shape (rw lock + payload) from
|
||||
// internal/schema/refresh.go.
|
||||
type swSlotSnapshot struct {
|
||||
mu sync.RWMutex
|
||||
machine string
|
||||
bootOrder []string
|
||||
slots []slotEntry
|
||||
}
|
||||
@@ -93,28 +92,12 @@ const rebootSpinnerHTML = `<div class="reboot-overlay">
|
||||
|
||||
type systemControlData struct {
|
||||
PageData
|
||||
CurrentDatetime string // device clock, empty when unavailable
|
||||
}
|
||||
|
||||
// SystemControl renders the System Control maintenance page.
|
||||
func (h *SystemHandler) SystemControl(w http.ResponseWriter, r *http.Request) {
|
||||
data := systemControlData{
|
||||
PageData: newPageData(r, "system-control", "System Control"),
|
||||
}
|
||||
|
||||
var clockResp struct {
|
||||
SystemState struct {
|
||||
Clock struct {
|
||||
CurrentDatetime string `json:"current-datetime"`
|
||||
} `json:"clock"`
|
||||
} `json:"ietf-system:system-state"`
|
||||
}
|
||||
if err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/clock", &clockResp); err == nil {
|
||||
dt := clockResp.SystemState.Clock.CurrentDatetime
|
||||
if len(dt) > 19 {
|
||||
dt = dt[:19]
|
||||
}
|
||||
data.CurrentDatetime = strings.Replace(dt, "T", " ", 1) + " UTC"
|
||||
PageData: newPageData(w, r, "system-control", "System Control"),
|
||||
}
|
||||
|
||||
tmplName := "system-control.html"
|
||||
@@ -133,28 +116,32 @@ func (h *SystemHandler) SetDatetime(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
raw := r.FormValue("datetime") // YYYY-MM-DDTHH:MM from datetime-local input
|
||||
raw := r.FormValue("datetime") // YYYY-MM-DDTHH:MM or :SS, ISO 24h
|
||||
if raw == "" {
|
||||
http.Error(w, "datetime required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Accept either YYYY-MM-DDTHH:MM (16 chars) or with :SS (19 chars).
|
||||
if len(raw) == 16 {
|
||||
raw += ":00"
|
||||
}
|
||||
|
||||
body := map[string]map[string]string{
|
||||
"ietf-system:input": {"current-datetime": raw + ":00+00:00"},
|
||||
"ietf-system:input": {"current-datetime": raw + "+00:00"},
|
||||
}
|
||||
err := h.RC.PostJSON(r.Context(), "/operations/ietf-system:set-current-datetime", body)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err != nil {
|
||||
if err := h.RC.PostJSON(r.Context(), "/operations/ietf-system:set-current-datetime", body); err != nil {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "ntp-active") {
|
||||
fmt.Fprint(w, `<span class="sc-fd-err">NTP is active — disable NTP first under Configure > System.</span>`)
|
||||
} else {
|
||||
fmt.Fprintf(w, `<span class="sc-fd-err">Failed: %s</span>`, template.HTMLEscapeString(msg))
|
||||
msg = "NTP is active — disable NTP first under Configure > System"
|
||||
}
|
||||
log.Printf("set datetime: %v", err)
|
||||
b, _ := json.Marshal(msg)
|
||||
w.Header().Set("HX-Trigger", `{"cfgError":`+string(b)+`}`)
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `<span class="sc-fd-ok">✓ System time updated</span>`)
|
||||
w.Header().Set("HX-Trigger", `{"cfgSaved":"System time updated"}`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// Shutdown triggers a device power-off via the ietf-system:system-shutdown RPC.
|
||||
@@ -211,7 +198,7 @@ const factoryResetSpinnerHTML = `<div class="reboot-overlay">
|
||||
|
||||
// Backup renders the Backup & Restore maintenance page.
|
||||
func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) {
|
||||
data := newPageData(r, "backup", "Backup & Restore")
|
||||
data := newPageData(w, r, "backup", "Backup & Restore")
|
||||
tmplName := "backup.html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
tmplName = "content"
|
||||
@@ -311,62 +298,58 @@ func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// RESTCONF JSON structures for infix-system:software state.
|
||||
|
||||
type fwSoftwareWrapper struct {
|
||||
type swStateWrapper struct {
|
||||
SystemState struct {
|
||||
Platform struct {
|
||||
Machine string `json:"machine"`
|
||||
} `json:"platform"`
|
||||
Software fwSoftwareState `json:"infix-system:software"`
|
||||
Software swState `json:"infix-system:software"`
|
||||
} `json:"ietf-system:system-state"`
|
||||
}
|
||||
|
||||
type fwSoftwareState struct {
|
||||
type swState struct {
|
||||
Compatible string `json:"compatible"`
|
||||
Variant string `json:"variant"`
|
||||
Booted string `json:"booted"`
|
||||
BootOrder []string `json:"boot-order"`
|
||||
Installer fwInstallerState `json:"installer"`
|
||||
Slots []fwSlot `json:"slot"`
|
||||
Installer swInstallerState `json:"installer"`
|
||||
Slots []swSlot `json:"slot"`
|
||||
}
|
||||
|
||||
type fwInstallerState struct {
|
||||
type swInstallerState struct {
|
||||
Operation string `json:"operation"`
|
||||
Progress fwInstallerProgress `json:"progress"`
|
||||
Progress swInstallerProgress `json:"progress"`
|
||||
LastError string `json:"last-error"`
|
||||
}
|
||||
|
||||
// IsIdle reports whether RAUC has no install in flight. The YANG model leaves
|
||||
// Operation empty when no install has run yet and "idle" once one has completed.
|
||||
func (s fwInstallerState) IsIdle() bool {
|
||||
func (s swInstallerState) IsIdle() bool {
|
||||
return s.Operation == "" || s.Operation == "idle"
|
||||
}
|
||||
|
||||
type fwInstallerProgress struct {
|
||||
type swInstallerProgress struct {
|
||||
Percentage int `json:"percentage"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type fwSlot struct {
|
||||
type swSlot struct {
|
||||
Name string `json:"name"`
|
||||
BootName string `json:"bootname"`
|
||||
Class string `json:"class"`
|
||||
State string `json:"state"`
|
||||
Bundle fwSlotBundle `json:"bundle"`
|
||||
Bundle swSlotBundle `json:"bundle"`
|
||||
Installed struct {
|
||||
Datetime string `json:"datetime"`
|
||||
} `json:"installed"`
|
||||
}
|
||||
|
||||
type fwSlotBundle struct {
|
||||
type swSlotBundle struct {
|
||||
Compatible string `json:"compatible"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Template data for the firmware page.
|
||||
// Template data for the software page.
|
||||
|
||||
type firmwareData struct {
|
||||
type softwareData struct {
|
||||
PageData
|
||||
Machine string
|
||||
BootOrder []string
|
||||
Slots []slotEntry
|
||||
Installer *installerEntry
|
||||
@@ -394,42 +377,38 @@ type installerEntry struct {
|
||||
Success bool // Done with no error
|
||||
}
|
||||
|
||||
// Firmware renders the firmware overview page (GET /firmware).
|
||||
func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
|
||||
data := firmwareData{
|
||||
PageData: newPageData(r, "firmware", "Firmware"),
|
||||
// Software renders the software overview page (GET /software).
|
||||
func (h *SystemHandler) Software(w http.ResponseWriter, r *http.Request) {
|
||||
data := softwareData{
|
||||
PageData: newPageData(w, r, "software", "Software"),
|
||||
Message: r.URL.Query().Get("msg"),
|
||||
Installing: r.URL.Query().Get("installing") == "1",
|
||||
AutoReboot: r.URL.Query().Get("auto-reboot") == "1",
|
||||
}
|
||||
|
||||
// When an install is in progress, RESTCONF/yanger blocks on `rauc status`
|
||||
// until RAUC is done. Skip the slow path and just read the installer
|
||||
// state directly so the progress card can render immediately; SSE then
|
||||
// drives the visual update during the install. The Software card body
|
||||
// falls back to the last cached slot snapshot so it doesn't go blank.
|
||||
// When an install is in progress, the RESTCONF path blocks on
|
||||
// `rauc status` until RAUC is done. Skip the slow path and read
|
||||
// the installer state directly so the progress card can render
|
||||
// immediately; SSE then drives the visual update during the
|
||||
// install. The Software card body falls back to the last cached
|
||||
// slot snapshot so it doesn't go blank.
|
||||
if data.Installing {
|
||||
if inst, err := raucInstallationStatus(r.Context()); err == nil {
|
||||
data.Installer = newInstallerEntry(inst)
|
||||
} else {
|
||||
log.Printf("firmware page (installing): %v", err)
|
||||
log.Printf("software page (installing): %v", err)
|
||||
}
|
||||
h.fwSlots.mu.RLock()
|
||||
data.Machine = h.fwSlots.machine
|
||||
data.BootOrder = slices.Clone(h.fwSlots.bootOrder)
|
||||
data.Slots = slices.Clone(h.fwSlots.slots)
|
||||
h.fwSlots.mu.RUnlock()
|
||||
h.swSlots.mu.RLock()
|
||||
data.BootOrder = slices.Clone(h.swSlots.bootOrder)
|
||||
data.Slots = slices.Clone(h.swSlots.slots)
|
||||
h.swSlots.mu.RUnlock()
|
||||
} else {
|
||||
var sw fwSoftwareWrapper
|
||||
var sw swStateWrapper
|
||||
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"
|
||||
log.Printf("restconf software: %v", err)
|
||||
data.Error = "Could not fetch software status"
|
||||
} else {
|
||||
data.Machine = sw.SystemState.Platform.Machine
|
||||
if data.Machine == "arm64" {
|
||||
data.Machine = "aarch64"
|
||||
}
|
||||
data.BootOrder = sw.SystemState.Software.BootOrder
|
||||
for _, s := range sw.SystemState.Software.Slots {
|
||||
if s.Class != "rootfs" {
|
||||
@@ -454,15 +433,14 @@ func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
data.Installer = newInstallerEntry(sw.SystemState.Software.Installer)
|
||||
|
||||
h.fwSlots.mu.Lock()
|
||||
h.fwSlots.machine = data.Machine
|
||||
h.fwSlots.bootOrder = slices.Clone(data.BootOrder)
|
||||
h.fwSlots.slots = slices.Clone(data.Slots)
|
||||
h.fwSlots.mu.Unlock()
|
||||
h.swSlots.mu.Lock()
|
||||
h.swSlots.bootOrder = slices.Clone(data.BootOrder)
|
||||
h.swSlots.slots = slices.Clone(data.Slots)
|
||||
h.swSlots.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
tmplName := "firmware.html"
|
||||
tmplName := "software.html"
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
tmplName = "content"
|
||||
}
|
||||
@@ -502,13 +480,13 @@ func (h *SystemHandler) SetBootOrder(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// FirmwareUpload accepts a .pkg file upload, saves it to a temp file, and
|
||||
// SoftwareUpload accepts a .pkg file upload, saves it to a temp file, and
|
||||
// kicks off the install-bundle RPC asynchronously so the response (a
|
||||
// plain-text redirect target) reaches the browser before RAUC starts
|
||||
// writing slots. Upload size is capped at the nginx layer.
|
||||
func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if h.raucBusy(r.Context()) {
|
||||
http.Error(w, "firmware install already in progress", http.StatusConflict)
|
||||
http.Error(w, "software install already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -531,9 +509,9 @@ func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tmp, err := os.CreateTemp("", "webui-fw-*.pkg")
|
||||
tmp, err := os.CreateTemp("", "webui-bundle-*.pkg")
|
||||
if err != nil {
|
||||
log.Printf("firmware upload: create temp: %v", err)
|
||||
log.Printf("software upload: create temp: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -542,8 +520,8 @@ func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := io.Copy(tmp, file); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
log.Printf("firmware upload: write: %v", err)
|
||||
http.Error(w, "failed to save firmware", http.StatusInternalServerError)
|
||||
log.Printf("software upload: write: %v", err)
|
||||
http.Error(w, "failed to save bundle", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmp.Close()
|
||||
@@ -554,7 +532,7 @@ func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) {
|
||||
creds := restconf.CredentialsFromContext(r.Context())
|
||||
go h.runInstall(creds, body, tmpPath)
|
||||
|
||||
target := "/firmware?installing=1"
|
||||
target := "/software?installing=1"
|
||||
if r.FormValue("auto-reboot") == "1" {
|
||||
target += "&auto-reboot=1"
|
||||
}
|
||||
@@ -574,7 +552,7 @@ func (h *SystemHandler) runInstall(creds restconf.Credentials, body any, tmpPath
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := h.RC.PostJSON(ctx, "/operations/infix-system:install-bundle", body); err != nil {
|
||||
log.Printf("firmware upload: install-bundle: %v", err)
|
||||
log.Printf("software upload: install-bundle: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -596,8 +574,8 @@ func (h *SystemHandler) runInstall(creds restconf.Credentials, body any, tmpPath
|
||||
}
|
||||
}
|
||||
|
||||
// FirmwareInstall triggers a firmware install via the install-bundle RPC (POST /firmware/install).
|
||||
func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request) {
|
||||
// SoftwareInstall triggers a bundle install via the install-bundle RPC (POST /software/install).
|
||||
func (h *SystemHandler) SoftwareInstall(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
@@ -617,13 +595,13 @@ func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
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())
|
||||
log.Printf("software install: %v", err)
|
||||
w.Header().Set("HX-Redirect", "/software?msg=Install+failed:+"+err.Error())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
target := "/firmware?installing=1"
|
||||
target := "/software?installing=1"
|
||||
if r.FormValue("auto-reboot") == "1" {
|
||||
target += "&auto-reboot=1"
|
||||
}
|
||||
@@ -631,10 +609,10 @@ func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// FirmwareProgress streams installer status as SSE so the Go server does the
|
||||
// SoftwareProgress streams installer status as SSE so the Go server does the
|
||||
// polling and the browser just receives rendered HTML fragments.
|
||||
// GET /firmware/progress
|
||||
func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request) {
|
||||
// GET /software/progress
|
||||
func (h *SystemHandler) SoftwareProgress(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
||||
@@ -653,6 +631,7 @@ func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request)
|
||||
defer ticker.Stop()
|
||||
|
||||
var lastKey string // change-detection: suppress redundant SSE frames
|
||||
lastFrame := time.Now()
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -667,13 +646,27 @@ func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request)
|
||||
key = fmt.Sprintf("%s|%d|%s|%s", data.Installer.Operation, data.Installer.Percentage, data.Installer.Message, data.Installer.LastError)
|
||||
}
|
||||
if key == lastKey && key != "" {
|
||||
// RAUC sometimes parks Progress at the same percentage
|
||||
// for tens of seconds (e.g., "Checking bundle" while
|
||||
// verifying signatures, or quiet during slot write).
|
||||
// Emit a comment as a keep-alive every 15 s so nginx's
|
||||
// proxy_read_timeout and the browser EventSource both
|
||||
// keep the stream warm — otherwise the connection gets
|
||||
// torn down mid-install and the UI freezes on the last
|
||||
// rendered frame until a manual page reload.
|
||||
if time.Since(lastFrame) > 15*time.Second {
|
||||
fmt.Fprint(w, ": keep-alive\n\n")
|
||||
flusher.Flush()
|
||||
lastFrame = time.Now()
|
||||
}
|
||||
continue
|
||||
}
|
||||
lastKey = key
|
||||
lastFrame = time.Now()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := h.Template.ExecuteTemplate(&buf, "fw-progress-body", data); err != nil {
|
||||
log.Printf("firmware progress template: %v", err)
|
||||
if err := h.Template.ExecuteTemplate(&buf, "sw-progress-body", data); err != nil {
|
||||
log.Printf("software progress template: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -701,10 +694,10 @@ func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// installerSnapshot reads RAUC's installer state via rauc-installation-status
|
||||
// (direct D-Bus property read) and builds the template data for the
|
||||
// fw-progress-body fragment. The RESTCONF path is avoided because yanger runs
|
||||
// sw-progress-body fragment. The RESTCONF path is avoided because it runs
|
||||
// `rauc status`, which blocks while an install is in progress.
|
||||
func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firmwareProgressData {
|
||||
data := firmwareProgressData{
|
||||
func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) softwareProgressData {
|
||||
data := softwareProgressData{
|
||||
AutoReboot: autoReboot,
|
||||
}
|
||||
|
||||
@@ -712,7 +705,7 @@ func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firm
|
||||
if err != nil {
|
||||
// Leave Installer nil so the template renders an indeterminate
|
||||
// "Installing…" state on transient failures.
|
||||
log.Printf("firmware progress poll: %v", err)
|
||||
log.Printf("software progress poll: %v", err)
|
||||
return data
|
||||
}
|
||||
data.Installer = newInstallerEntry(inst)
|
||||
@@ -720,7 +713,7 @@ func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firm
|
||||
}
|
||||
|
||||
// newInstallerEntry converts a raw YANG installer state to the template-facing struct.
|
||||
func newInstallerEntry(inst fwInstallerState) *installerEntry {
|
||||
func newInstallerEntry(inst swInstallerState) *installerEntry {
|
||||
idle := inst.IsIdle()
|
||||
done := idle && (inst.Progress.Percentage > 0 || inst.LastError != "")
|
||||
return &installerEntry{
|
||||
@@ -734,8 +727,8 @@ func newInstallerEntry(inst fwInstallerState) *installerEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// firmwareProgressData is the template data for the fw-progress-body fragment.
|
||||
type firmwareProgressData struct {
|
||||
// softwareProgressData is the template data for the sw-progress-body fragment.
|
||||
type softwareProgressData struct {
|
||||
AutoReboot bool
|
||||
Installer *installerEntry
|
||||
}
|
||||
|
||||
@@ -45,23 +45,23 @@ type WGTunnel struct {
|
||||
Peers []WGPeer
|
||||
}
|
||||
|
||||
// vpnData is the template data struct for the VPN page.
|
||||
// vpnData is the template data struct for the WireGuard status page.
|
||||
type vpnData struct {
|
||||
PageData
|
||||
Tunnels []WGTunnel
|
||||
Error string
|
||||
}
|
||||
|
||||
// VPNHandler serves the VPN/WireGuard status page.
|
||||
// VPNHandler serves the WireGuard status page.
|
||||
type VPNHandler struct {
|
||||
Template *template.Template
|
||||
RC *restconf.Client
|
||||
}
|
||||
|
||||
// Overview renders the VPN page (GET /vpn).
|
||||
// Overview renders the WireGuard page (GET /vpn).
|
||||
func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := vpnData{
|
||||
PageData: newPageData(r, "vpn", "VPN"),
|
||||
PageData: newPageData(w, r, "vpn", "WireGuard"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
|
||||
@@ -152,7 +152,7 @@ type WiFiHandler struct {
|
||||
// Overview renders the WiFi page (GET /wifi).
|
||||
func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
data := wifiData{
|
||||
PageData: newPageData(r, "wifi", "WiFi"),
|
||||
PageData: newPageData(w, r, "wifi", "WiFi"),
|
||||
}
|
||||
|
||||
// Detach from the request context so that RESTCONF calls survive
|
||||
|
||||
@@ -197,14 +197,14 @@ type leafGroupItem struct {
|
||||
// When path is set the right pane auto-loads the node on page load.
|
||||
func (h *TreeHandler) Overview(w http.ResponseWriter, r *http.Request) {
|
||||
activePage := "configure-tree"
|
||||
title := "Advanced Configuration"
|
||||
title := "Edit all"
|
||||
if h.ReadOnly {
|
||||
activePage = "status-tree"
|
||||
title = "Advanced Status"
|
||||
title = "View all"
|
||||
}
|
||||
base := h.treeBase()
|
||||
data := yangTreePageData{
|
||||
PageData: newPageData(r, activePage, title),
|
||||
PageData: newPageData(w, r, activePage, title),
|
||||
InitialPath: r.URL.Query().Get("path"),
|
||||
ReadOnly: h.ReadOnly,
|
||||
TreeBase: base,
|
||||
|
||||
@@ -19,10 +19,11 @@ func IsNotFound(err error) bool {
|
||||
return errors.As(err, &e) && e.StatusCode == http.StatusNotFound
|
||||
}
|
||||
|
||||
// IsDataMissing reports whether err is a RESTCONF "data-missing" tag error
|
||||
// (RFC 8040 §7.6.2). Returned by DELETE on a leaf that is already absent.
|
||||
// Useful when the caller is explicitly trying to reach an "absent" state
|
||||
// and treats already-absent the same as just-deleted.
|
||||
// IsDataMissing reports whether err carries the RESTCONF "data-missing"
|
||||
// error-tag, returned when an operation targets a leaf or container that
|
||||
// isn't present in the datastore (e.g. a reset on a leaf that was never
|
||||
// set). Callers use this to swallow no-op failures so the UI stays
|
||||
// consistent regardless of whether the leaf was already absent.
|
||||
func IsDataMissing(err error) bool {
|
||||
var e *Error
|
||||
return errors.As(err, &e) && e.Tag == "data-missing"
|
||||
|
||||
@@ -25,7 +25,7 @@ func New(
|
||||
) (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")
|
||||
loginTmpl, err := template.ParseFS(templateFS, "layouts/icons.html", "pages/login.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func New(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-keystore.html")
|
||||
ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-keystore.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func New(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fwrTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/firmware.html")
|
||||
swTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/software.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -109,23 +109,31 @@ func New(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgSysTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-system.html")
|
||||
cfgSysTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-system.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgUsersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-users.html")
|
||||
cfgNTPTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-ntp.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-routes.html")
|
||||
cfgDNSTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-dns.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgFwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-firewall.html")
|
||||
cfgUsersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-users.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgHwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-hardware.html")
|
||||
cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.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")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfgHwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-hardware.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -166,7 +174,7 @@ func New(
|
||||
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")
|
||||
cfgIfTmpl, err := template.New("").Funcs(ifFuncs).ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.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
|
||||
}
|
||||
@@ -184,7 +192,7 @@ func New(
|
||||
"fragments/yang-node-detail.html",
|
||||
"fragments/yang-leaf-group.html",
|
||||
"fragments/yang-list-table.html",
|
||||
"fragments/icons.html")
|
||||
"layouts/icons.html")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -223,7 +231,7 @@ func New(
|
||||
|
||||
sys := &handlers.SystemHandler{
|
||||
RC: rc,
|
||||
Template: fwrTmpl,
|
||||
Template: swTmpl,
|
||||
SysCtrlTmpl: sysCtrlTmpl,
|
||||
BackupTmpl: backupTmpl,
|
||||
}
|
||||
@@ -240,7 +248,13 @@ func New(
|
||||
services := &handlers.ServicesHandler{Template: servicesTmpl, RC: rc}
|
||||
containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc}
|
||||
cfg := &handlers.ConfigureHandler{RC: rc}
|
||||
cfgSys := &handlers.ConfigureSystemHandler{Template: cfgSysTmpl, RC: rc, Schema: schemaCache}
|
||||
cfgSys := &handlers.ConfigureSystemHandler{
|
||||
Template: cfgSysTmpl,
|
||||
NTPTemplate: cfgNTPTmpl,
|
||||
DNSTemplate: cfgDNSTmpl,
|
||||
RC: rc,
|
||||
Schema: schemaCache,
|
||||
}
|
||||
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}
|
||||
@@ -282,12 +296,12 @@ func New(
|
||||
mux.HandleFunc("GET /keystore", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/configure/keystore", http.StatusMovedPermanently)
|
||||
})
|
||||
mux.HandleFunc("GET /firmware", sys.Firmware)
|
||||
mux.HandleFunc("GET /firmware/progress", sys.FirmwareProgress)
|
||||
mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall)
|
||||
mux.HandleFunc("POST /firmware/upload", sys.FirmwareUpload)
|
||||
mux.HandleFunc("POST /firmware/boot-order", sys.SetBootOrder)
|
||||
mux.HandleFunc("POST /reboot", sys.Reboot) // kept for firmware page "Reboot to activate"
|
||||
mux.HandleFunc("GET /software", sys.Software)
|
||||
mux.HandleFunc("GET /software/progress", sys.SoftwareProgress)
|
||||
mux.HandleFunc("POST /software/install", sys.SoftwareInstall)
|
||||
mux.HandleFunc("POST /software/upload", sys.SoftwareUpload)
|
||||
mux.HandleFunc("POST /software/boot-order", sys.SetBootOrder)
|
||||
mux.HandleFunc("POST /reboot", sys.Reboot) // kept for software page "Reboot to activate"
|
||||
mux.HandleFunc("GET /config", sys.DownloadConfig)
|
||||
mux.HandleFunc("GET /maintenance/backup", sys.Backup)
|
||||
mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig)
|
||||
@@ -317,6 +331,8 @@ func New(
|
||||
mux.HandleFunc("POST /configure/save", cfg.Save)
|
||||
mux.HandleFunc("DELETE /configure/leaf", cfg.DeleteLeaf)
|
||||
mux.HandleFunc("GET /configure/system", cfgSys.Overview)
|
||||
mux.HandleFunc("GET /configure/ntp", cfgSys.OverviewNTP)
|
||||
mux.HandleFunc("GET /configure/dns", cfgSys.OverviewDNS)
|
||||
mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity)
|
||||
mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock)
|
||||
mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP)
|
||||
@@ -332,15 +348,13 @@ func New(
|
||||
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/settings", cfgIf.SaveIPv4Settings)
|
||||
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/settings", cfgIf.SaveIPv6Settings)
|
||||
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)
|
||||
@@ -351,7 +365,6 @@ func New(
|
||||
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)
|
||||
|
||||
@@ -543,6 +543,19 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
|
||||
color: var(--fg);
|
||||
}
|
||||
.disk-pct { font-size: 0.8rem; color: var(--fg-muted); }
|
||||
.disk-tag {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fg-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0 0.3rem;
|
||||
margin-left: 0.4rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.disk-stats {
|
||||
font-size: 0.78rem;
|
||||
color: var(--fg-muted);
|
||||
@@ -944,7 +957,7 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
|
||||
.page-content { display: contents; }
|
||||
|
||||
/* ==========================================================================
|
||||
Domain Specific: Firewall, Keystore, Firmware
|
||||
Domain Specific: Firewall, Keystore, Software
|
||||
========================================================================== */
|
||||
|
||||
/* Zone Matrix */
|
||||
@@ -1439,34 +1452,34 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
/* Firmware & Reboot */
|
||||
.fw-install-grid {
|
||||
/* Software & Reboot */
|
||||
.sw-install-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.fw-install-grid > .info-card { margin: 0; }
|
||||
.sw-install-grid > .info-card { margin: 0; }
|
||||
|
||||
.fw-card-muted { opacity: 0.6; pointer-events: none; }
|
||||
.sw-card-muted { opacity: 0.6; pointer-events: none; }
|
||||
|
||||
.fw-help-text {
|
||||
.sw-help-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--fg-muted);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.fw-help-text a { color: var(--primary); }
|
||||
.fw-help-text code, .fw-hint-body code { font-family: var(--font-mono); font-size: 0.8em; }
|
||||
.sw-help-text a { color: var(--primary); }
|
||||
.sw-help-text code, .sw-hint-body code { font-family: var(--font-mono); font-size: 0.8em; }
|
||||
|
||||
.fw-hint {
|
||||
.sw-hint {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.fw-hint summary {
|
||||
.sw-hint summary {
|
||||
cursor: pointer;
|
||||
padding: 0.55rem 0.8rem;
|
||||
color: var(--fg-muted);
|
||||
@@ -1476,16 +1489,16 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.fw-hint summary::-webkit-details-marker { display: none; }
|
||||
.fw-hint summary::before { content: '›'; display: inline-block; transition: transform 0.15s; }
|
||||
details.fw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
.fw-hint-body {
|
||||
.sw-hint summary::-webkit-details-marker { display: none; }
|
||||
.sw-hint summary::before { content: '›'; display: inline-block; transition: transform 0.15s; }
|
||||
details.sw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
.sw-hint-body {
|
||||
padding: 0.75rem 0.8rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fw-hint-body p { margin: 0 0 0.5rem; color: var(--fg-muted); font-size: 0.85rem; }
|
||||
.fw-hint-body p:last-child { margin-bottom: 0; }
|
||||
.fw-hint-code {
|
||||
.sw-hint-body p { margin: 0 0 0.5rem; color: var(--fg-muted); font-size: 0.85rem; }
|
||||
.sw-hint-body p:last-child { margin-bottom: 0; }
|
||||
.sw-hint-code {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
@@ -1499,7 +1512,7 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.fw-boot-order-row {
|
||||
.sw-boot-order-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
@@ -1507,7 +1520,7 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.fw-boot-order-label {
|
||||
.sw-boot-order-label {
|
||||
color: var(--fg-muted);
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
@@ -1516,37 +1529,37 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
|
||||
.fw-boot-slots { display: flex; gap: 0.3rem; align-items: center; flex: 1; }
|
||||
.fw-boot-badge { cursor: grab; user-select: none; }
|
||||
.fw-boot-badge.fw-boot-dragging { opacity: 0.35; }
|
||||
.fw-boot-badge.fw-boot-drop-before { box-shadow: -3px 0 0 var(--primary); }
|
||||
.sw-boot-slots { display: flex; gap: 0.3rem; align-items: center; flex: 1; }
|
||||
.sw-boot-badge { cursor: grab; user-select: none; }
|
||||
.sw-boot-badge.sw-boot-dragging { opacity: 0.35; }
|
||||
.sw-boot-badge.sw-boot-drop-before { box-shadow: -3px 0 0 var(--primary); }
|
||||
|
||||
.fw-slot-list { display: flex; flex-direction: column; }
|
||||
.fw-slot-item {
|
||||
.sw-slot-list { display: flex; flex-direction: column; }
|
||||
.sw-slot-item {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.fw-slot-item:last-child { border-bottom: none; }
|
||||
.fw-slot-primary {
|
||||
.sw-slot-item:last-child { border-bottom: none; }
|
||||
.sw-slot-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.fw-slot-name { font-weight: 600; font-size: 0.9rem; }
|
||||
.fw-slot-version {
|
||||
.sw-slot-name { font-weight: 600; font-size: 0.9rem; }
|
||||
.sw-slot-version {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.fw-slot-date {
|
||||
.sw-slot-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--fg-muted);
|
||||
opacity: 0.75;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.fw-upload-placeholder {
|
||||
.sw-upload-placeholder {
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2rem 1rem;
|
||||
@@ -1560,10 +1573,10 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.firmware-form .form-group { margin-bottom: 0.75rem; }
|
||||
.firmware-form .fw-checkbox-row { margin-bottom: 0.75rem; }
|
||||
.software-form .form-group { margin-bottom: 0.75rem; }
|
||||
.software-form .sw-checkbox-row { margin-bottom: 0.75rem; }
|
||||
|
||||
.fw-checkbox-row {
|
||||
.sw-checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -1572,7 +1585,7 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.fw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; }
|
||||
.sw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; }
|
||||
|
||||
/* Multi-select dropdown built on <details> + checkboxes so the summary
|
||||
row looks like a native <select> trigger and the body holds the
|
||||
@@ -1637,21 +1650,21 @@ details[open] > .cfg-multi-summary {
|
||||
}
|
||||
.cfg-multi-item input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; }
|
||||
|
||||
.fw-result {
|
||||
.sw-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.fw-result svg { flex-shrink: 0; }
|
||||
.fw-result-ok { color: var(--success); }
|
||||
.fw-result-err { color: var(--danger); }
|
||||
.fw-result-body { display: flex; flex-direction: column; }
|
||||
.fw-result-actions { margin-top: 1rem; }
|
||||
.sw-result svg { flex-shrink: 0; }
|
||||
.sw-result-ok { color: var(--success); }
|
||||
.sw-result-err { color: var(--danger); }
|
||||
.sw-result-body { display: flex; flex-direction: column; }
|
||||
.sw-result-actions { margin-top: 1rem; }
|
||||
|
||||
/* Compact pill badges used in the firmware slot list and boot-order row. */
|
||||
.fw-install-grid .badge-neutral {
|
||||
/* Compact pill badges used in the software slot list and boot-order row. */
|
||||
.sw-install-grid .badge-neutral {
|
||||
background: var(--slate-200);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
@@ -1662,9 +1675,9 @@ details[open] > .cfg-multi-summary {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
|
||||
.sw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
|
||||
}
|
||||
.dark .fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
|
||||
.dark .sw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); }
|
||||
|
||||
.progress-bar-wrap--flush { margin-top: 0; }
|
||||
|
||||
@@ -2217,6 +2230,13 @@ select.cfg-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cfg-input-sm { max-width: 8rem; }
|
||||
/* Inputs in info-table cells must shrink to the column; without this the
|
||||
intrinsic min-width of a datetime-local (wide in Firefox) forces the table
|
||||
past the card edge. min-width:0 isn't enough on its own for datetime-local,
|
||||
so cards with one use .info-table-fixed (table-layout: fixed + <colgroup>)
|
||||
to cap the columns and make the input shrink. */
|
||||
.info-table .cfg-input { min-width: 0; }
|
||||
.info-table-fixed { table-layout: fixed; }
|
||||
.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); }
|
||||
@@ -2833,12 +2853,14 @@ details[open].yt-leaf-item .yt-leaf-name::before { transform: rotate(90deg); }
|
||||
.field-info {
|
||||
display: inline-block;
|
||||
cursor: help;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.85em;
|
||||
font-weight: normal;
|
||||
color: var(--fg-muted);
|
||||
margin-left: 0.3rem;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
#field-tip {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Sidebar icons
|
||||
|
||||
All icons in this directory are from [Lucide][1] (ISC-licensed),
|
||||
vendored locally so the WebUI works on air-gapped industrial sites
|
||||
without an external CDN.
|
||||
|
||||
Each file is the upstream `<lucide>/icons/<name>.svg` with the `width`
|
||||
and `height` attributes stripped so the icon scales to its CSS-defined
|
||||
size in the sidebar. Stroke styling (`fill="none"`,
|
||||
`stroke="currentColor"`, `stroke-width="2"`, rounded caps/joins) is left
|
||||
untouched.
|
||||
|
||||
| File | Lucide source |
|
||||
|-------------------|-----------------------|
|
||||
| advanced.svg | folder-git-2 |
|
||||
| backup.svg | archive-restore |
|
||||
| containers.svg | container |
|
||||
| dashboard.svg | circle-gauge |
|
||||
| dhcp.svg | network |
|
||||
| dns.svg | globe |
|
||||
| firewall.svg | shield-check |
|
||||
| hardware.svg | cpu |
|
||||
| interfaces.svg | ethernet-port |
|
||||
| keystore.svg | key-round |
|
||||
| lldp.svg | radio-tower |
|
||||
| mdns.svg | radio |
|
||||
| nacm.svg | users |
|
||||
| ntp.svg | clock |
|
||||
| routes.svg | git-compare-arrows |
|
||||
| routing.svg | git-compare-arrows |
|
||||
| services.svg | library-big |
|
||||
| software.svg | hard-drive-download |
|
||||
| status-tree.svg | text-search |
|
||||
| system-control.svg| power |
|
||||
| system.svg | settings |
|
||||
| wifi.svg | wifi |
|
||||
| wireguard.svg | globe-lock |
|
||||
|
||||
## Replacing or adding an icon
|
||||
|
||||
1. Pick the icon at [lucide.dev/icons][2].
|
||||
2. Download the SVG (or copy from `lucide-icons/lucide`'s `icons/`
|
||||
directory on GitHub).
|
||||
3. Drop the `width` and `height` attributes from the opening `<svg>`
|
||||
tag.
|
||||
4. Save under the destination filename (the sidebar template references
|
||||
files by purpose, not by upstream Lucide name).
|
||||
5. Update the table above.
|
||||
|
||||
## License
|
||||
|
||||
Lucide is distributed under the [ISC license][3]. The license text is
|
||||
preserved in the upstream repository.
|
||||
|
||||
[1]: https://lucide.dev
|
||||
[2]: https://lucide.dev/icons/
|
||||
[3]: https://github.com/lucide-icons/lucide/blob/main/LICENSE
|
||||
@@ -1,11 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="21" y1="4" x2="14" y2="4"/>
|
||||
<line x1="10" y1="4" x2="3" y2="4"/>
|
||||
<line x1="21" y1="12" x2="12" y2="12"/>
|
||||
<line x1="8" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="20" x2="16" y2="20"/>
|
||||
<line x1="12" y1="20" x2="3" y2="20"/>
|
||||
<line x1="14" y1="2" x2="14" y2="6"/>
|
||||
<line x1="8" y1="10" x2="8" y2="14"/>
|
||||
<line x1="16" y1="18" x2="16" y2="22"/>
|
||||
<path d="M18 19a5 5 0 0 1-5-5v8" />
|
||||
<path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5" />
|
||||
<circle cx="13" cy="12" r="2" />
|
||||
<circle cx="20" cy="19" r="2" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 532 B After Width: | Height: | Size: 392 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="5" x="2" y="3" rx="1" />
|
||||
<path d="M4 8v11a2 2 0 0 0 2 2h2" />
|
||||
<path d="M20 8v11a2 2 0 0 1-2 2h-2" />
|
||||
<path d="m9 15 3-3 3 3" />
|
||||
<path d="M12 12v9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 351 B |
@@ -1,5 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"></line>
|
||||
</svg>
|
||||
<path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" />
|
||||
<path d="M10 21.9V14L2.1 9.1" />
|
||||
<path d="m10 14 11.9-6.9" />
|
||||
<path d="M14 19.8v-8.1" />
|
||||
<path d="M18 17.5V9.4" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 421 B After Width: | Height: | Size: 464 B |
@@ -1,6 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
<path d="M15.6 2.7a10 10 0 1 0 5.7 5.7" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
<path d="M13.4 10.6 19 5" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 277 B |
@@ -1,9 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="6" y="3" width="12" height="5" rx="1"/>
|
||||
<line x1="9" y1="8" x2="6" y2="15"/>
|
||||
<line x1="12" y1="8" x2="12" y2="15"/>
|
||||
<line x1="15" y1="8" x2="18" y2="15"/>
|
||||
<circle cx="6" cy="17" r="2"/>
|
||||
<circle cx="12" cy="17" r="2"/>
|
||||
<circle cx="18" cy="17" r="2"/>
|
||||
<rect x="16" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="2" y="16" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="2" width="6" height="6" rx="1" />
|
||||
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
|
||||
<path d="M12 12V8" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 439 B After Width: | Height: | Size: 403 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||
<path d="M2 12h20" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -1,3 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
</svg>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 373 B |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect>
|
||||
<rect x="9" y="9" width="6" height="6"></rect>
|
||||
<path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 15h3M1 9h3M1 15h3"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 356 B |
@@ -1,5 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2"/>
|
||||
<rect x="9" y="9" width="6" height="6"/>
|
||||
<path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20v2" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M17 20v2" />
|
||||
<path d="M17 2v2" />
|
||||
<path d="M2 12h2" />
|
||||
<path d="M2 17h2" />
|
||||
<path d="M2 7h2" />
|
||||
<path d="M20 12h2" />
|
||||
<path d="M20 17h2" />
|
||||
<path d="M20 7h2" />
|
||||
<path d="M7 20v2" />
|
||||
<path d="M7 2v2" />
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<rect x="8" y="8" width="8" height="8" rx="1" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 355 B After Width: | Height: | Size: 548 B |
@@ -1,8 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="2" width="6" height="4"/>
|
||||
<rect x="2" y="18" width="6" height="4"/>
|
||||
<rect x="16" y="18" width="6" height="4"/>
|
||||
<line x1="12" y1="6" x2="12" y2="13"/>
|
||||
<line x1="5" y1="18" x2="12" y2="13"/>
|
||||
<line x1="19" y1="18" x2="12" y2="13"/>
|
||||
<path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" />
|
||||
<path d="M6 8v1" />
|
||||
<path d="M10 8v1" />
|
||||
<path d="M14 8v1" />
|
||||
<path d="M18 8v1" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 354 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="5" y="11" width="14" height="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
<path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
|
||||
<circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 265 B After Width: | Height: | Size: 411 B |
@@ -1,7 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="6" r="3"></circle>
|
||||
<circle cx="6" cy="18" r="3"></circle>
|
||||
<circle cx="18" cy="12" r="3"></circle>
|
||||
<line x1="9" y1="6" x2="15" y2="10"></line>
|
||||
<line x1="9" y1="18" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
<path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9" />
|
||||
<path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5" />
|
||||
<circle cx="12" cy="9" r="2" />
|
||||
<path d="M16.2 4.8c2 2 2.26 5.11.8 7.47" />
|
||||
<path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1" />
|
||||
<path d="M9.5 18h5" />
|
||||
<path d="m8 22 4-11 4 11" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 381 B After Width: | Height: | Size: 443 B |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M6.3 6.3a8 8 0 0 0 0 11.4"></path>
|
||||
<path d="M17.7 6.3a8 8 0 0 1 0 11.4"></path>
|
||||
<path d="M3.5 3.5a14 14 0 0 0 0 17"></path>
|
||||
<path d="M20.5 3.5a14 14 0 0 1 0 17"></path>
|
||||
<path d="M16.247 7.761a6 6 0 0 1 0 8.478" />
|
||||
<path d="M19.075 4.933a10 10 0 0 1 0 14.134" />
|
||||
<path d="M4.925 19.067a10 10 0 0 1 0-14.134" />
|
||||
<path d="M7.753 16.239a6 6 0 0 1 0-8.478" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 394 B After Width: | Height: | Size: 395 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
||||
<path d="M16 3.128a4 4 0 0 1 0 7.744" />
|
||||
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 358 B After Width: | Height: | Size: 341 B |
@@ -1,4 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<path d="M12 6v6l4 2"></path>
|
||||
</svg>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 229 B |
@@ -1,7 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="5" cy="12" r="2"/>
|
||||
<circle cx="19" cy="6" r="2"/>
|
||||
<circle cx="19" cy="18" r="2"/>
|
||||
<line x1="7" y1="11" x2="17" y2="7"/>
|
||||
<line x1="7" y1="13" x2="17" y2="17"/>
|
||||
<circle cx="5" cy="6" r="3" />
|
||||
<path d="M12 6h5a2 2 0 0 1 2 2v7" />
|
||||
<path d="m15 9-3-3 3-3" />
|
||||
<circle cx="19" cy="18" r="3" />
|
||||
<path d="M12 18H7a2 2 0 0 1-2-2V9" />
|
||||
<path d="m9 15 3 3-3 3" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 347 B After Width: | Height: | Size: 371 B |
@@ -1,8 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="4" cy="12" r="2"/>
|
||||
<line x1="6" y1="12" x2="11" y2="12"/>
|
||||
<line x1="11" y1="12" x2="18" y2="6"/>
|
||||
<line x1="11" y1="12" x2="18" y2="18"/>
|
||||
<circle cx="20" cy="5" r="2"/>
|
||||
<circle cx="20" cy="19" r="2"/>
|
||||
<circle cx="5" cy="6" r="3" />
|
||||
<path d="M12 6h5a2 2 0 0 1 2 2v7" />
|
||||
<path d="m15 9-3-3 3-3" />
|
||||
<circle cx="19" cy="18" r="3" />
|
||||
<path d="M12 18H7a2 2 0 0 1-2-2V9" />
|
||||
<path d="m9 15 3 3-3 3" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 390 B After Width: | Height: | Size: 371 B |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="4" rx="1"/>
|
||||
<rect x="2" y="10" width="20" height="4" rx="1"/>
|
||||
<rect x="2" y="17" width="20" height="4" rx="1"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="8" height="18" x="3" y="3" rx="1" />
|
||||
<path d="M7 3v18" />
|
||||
<path d="M20.4 18.9c.2.5-.1 1.1-.6 1.3l-1.9.7c-.5.2-1.1-.1-1.3-.6L11.1 5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 367 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v8" />
|
||||
<path d="m16 6-4 4-4-4" />
|
||||
<rect width="20" height="8" x="2" y="14" rx="2" />
|
||||
<path d="M6 18h.01" />
|
||||
<path d="M10 18h.01" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
@@ -1,4 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M21 5H3" />
|
||||
<path d="M10 12H3" />
|
||||
<path d="M10 19H3" />
|
||||
<circle cx="17" cy="15" r="3" />
|
||||
<path d="m21 19-1.9-1.9" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 273 B After Width: | Height: | Size: 302 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2v10" />
|
||||
<path d="M18.4 6.6a9 9 0 1 1-12.77.04" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
@@ -1,5 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
<path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 428 B After Width: | Height: | Size: 544 B |
@@ -1,6 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"></line>
|
||||
</svg>
|
||||
<path d="M12 20h.01" />
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0" />
|
||||
<path d="M5 12.859a10 10 0 0 1 14 0" />
|
||||
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 362 B After Width: | Height: | Size: 315 B |
@@ -1,5 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
||||
<circle cx="12" cy="11" r="2"></circle>
|
||||
<path d="M12 13v4"></path>
|
||||
</svg>
|
||||
<path d="M15.686 15A14.5 14.5 0 0 1 12 22a14.5 14.5 0 0 1 0-20 10 10 0 1 0 9.542 13" />
|
||||
<path d="M2 12h8.5" />
|
||||
<path d="M20 6V4a2 2 0 1 0-4 0v2" />
|
||||
<rect width="8" height="5" x="14" y="6" rx="1" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 300 B After Width: | Height: | Size: 372 B |
@@ -14,17 +14,69 @@
|
||||
});
|
||||
}
|
||||
|
||||
// HTMX page navigation swaps only #content, leaving the <title> in <head>
|
||||
// stale. Each rendered page emits a setPageTitle HX-Trigger event (set in
|
||||
// handlers/common.go newPageData) carrying the full "Page · Context" title
|
||||
// string ready to apply.
|
||||
function initPageTitle() {
|
||||
document.body.addEventListener('setPageTitle', function (evt) {
|
||||
if (evt.detail && typeof evt.detail.value === 'string') {
|
||||
document.title = evt.detail.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The banner reflects htmx request outcomes — both user interactions and
|
||||
// the `hx-trigger="every 30s"` watchdog div in base.html.
|
||||
// the `hx-trigger="every 5s"` watchdog div in base.html.
|
||||
//
|
||||
// The watchdog request gets a 5 s timeout (via configRequest), bounding
|
||||
// disconnect detection at ~30 s + 5 s. Without it the XHR would sit on the
|
||||
// disconnect detection at ~5 s + 5 s. Without it the XHR would sit on the
|
||||
// OS TCP timeout (1–2 min) before any error fires.
|
||||
//
|
||||
// Once we see a failure, a dead-man timer counts down to a forced
|
||||
// navigation to /login. The device may have been rebooted or upgraded
|
||||
// out of band; in that case its in-memory session is gone and the next
|
||||
// poll would 401 → HX-Redirect → /login automatically. But if the
|
||||
// device is truly unreachable (cable pulled, route flap), no response
|
||||
// ever comes, so we time out client-side instead of letting the user
|
||||
// stare at a stale page.
|
||||
function initDeviceStatusBanner() {
|
||||
var banner = document.getElementById('conn-banner');
|
||||
if (!banner) return;
|
||||
var show = function () { banner.hidden = false; };
|
||||
var hide = function () { banner.hidden = true; };
|
||||
|
||||
var REDIRECT_AFTER_MS = 90 * 1000;
|
||||
var firstFailureMs = null;
|
||||
var tickerId = null;
|
||||
|
||||
function tick() {
|
||||
var remaining = Math.max(0, Math.ceil(
|
||||
(firstFailureMs + REDIRECT_AFTER_MS - Date.now()) / 1000
|
||||
));
|
||||
banner.textContent =
|
||||
'Device unreachable — returning to login in ' + remaining + ' s';
|
||||
if (remaining === 0) {
|
||||
clearInterval(tickerId);
|
||||
window.location.replace('/login');
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
banner.hidden = false;
|
||||
if (tickerId !== null) return;
|
||||
firstFailureMs = Date.now();
|
||||
tick();
|
||||
tickerId = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
banner.hidden = true;
|
||||
banner.textContent = 'Device unreachable';
|
||||
firstFailureMs = null;
|
||||
if (tickerId !== null) {
|
||||
clearInterval(tickerId);
|
||||
tickerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('htmx:configRequest', function (evt) {
|
||||
if (evt.detail && evt.detail.path === '/device-status') {
|
||||
@@ -38,7 +90,15 @@
|
||||
if (s === 502 || s === 503 || s === 504) show();
|
||||
});
|
||||
document.addEventListener('htmx:afterRequest', function (evt) {
|
||||
if (evt.detail && evt.detail.successful) hide();
|
||||
if (!evt.detail) return;
|
||||
var xhr = evt.detail.xhr;
|
||||
var status = xhr ? xhr.status : 0;
|
||||
// Any HTTP response < 500 means the server is reachable — hide.
|
||||
// (Pre-fix the banner stuck on after the next poll returned 404
|
||||
// because the old check used `successful`, which is true only for
|
||||
// 2xx.) status === 0 = no response, leave the banner alone;
|
||||
// sendError / timeout handles it.
|
||||
if (status > 0 && status < 500) hide();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,8 +149,8 @@
|
||||
initProgressBars(root);
|
||||
startRebootOverlay(root);
|
||||
initDatetimePicker(root);
|
||||
fwBootInit(root);
|
||||
fwUploadInit(root);
|
||||
swBootInit(root);
|
||||
swUploadInit(root);
|
||||
initRestoreCheckbox(root);
|
||||
initYangTree(root);
|
||||
initMultiDropdown(root);
|
||||
@@ -165,11 +225,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
function fwUploadInit(scope) {
|
||||
var btn = (scope || document).querySelector('#fw-upload-btn');
|
||||
function swUploadInit(scope) {
|
||||
var btn = (scope || document).querySelector('#sw-upload-btn');
|
||||
if (!btn || btn.dataset.init) return;
|
||||
btn.dataset.init = 'true';
|
||||
btn.addEventListener('click', window.fwUpload);
|
||||
btn.addEventListener('click', window.swUpload);
|
||||
}
|
||||
|
||||
function initRestoreCheckbox(scope) {
|
||||
@@ -179,36 +239,36 @@
|
||||
cb.addEventListener('change', function () { window.scRestoreCheckbox(cb); });
|
||||
}
|
||||
|
||||
// ─── Firmware: boot order drag-and-drop ──────────────────────────────────
|
||||
function fwBootInit(scope) {
|
||||
var slots = (scope || document).querySelector('#fw-boot-slots');
|
||||
// ─── Software: boot order drag-and-drop ──────────────────────────────────
|
||||
function swBootInit(scope) {
|
||||
var slots = (scope || document).querySelector('#sw-boot-slots');
|
||||
if (!slots || slots.dataset.dndInit) return;
|
||||
slots.dataset.dndInit = 'true';
|
||||
|
||||
// Stash the page-load order so Reset can restore it without a page refresh.
|
||||
// Slot names are a fixed enum (primary | secondary | net), so comma-joining is safe.
|
||||
var initialOrder = [];
|
||||
slots.querySelectorAll('.fw-boot-badge').forEach(function (b) { initialOrder.push(b.dataset.slot); });
|
||||
slots.querySelectorAll('.sw-boot-badge').forEach(function (b) { initialOrder.push(b.dataset.slot); });
|
||||
slots.dataset.originalOrder = initialOrder.join(',');
|
||||
|
||||
var dragging = null;
|
||||
var insertRef = undefined; // node to insertBefore; undefined = not set, null = append
|
||||
|
||||
function clearIndicators() {
|
||||
slots.querySelectorAll('.fw-boot-drop-before').forEach(function (el) {
|
||||
el.classList.remove('fw-boot-drop-before');
|
||||
slots.querySelectorAll('.sw-boot-drop-before').forEach(function (el) {
|
||||
el.classList.remove('sw-boot-drop-before');
|
||||
});
|
||||
}
|
||||
|
||||
slots.addEventListener('dragstart', function (e) {
|
||||
dragging = e.target.closest('.fw-boot-badge');
|
||||
dragging = e.target.closest('.sw-boot-badge');
|
||||
if (!dragging) return;
|
||||
dragging.classList.add('fw-boot-dragging');
|
||||
dragging.classList.add('sw-boot-dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
slots.addEventListener('dragend', function () {
|
||||
if (dragging) dragging.classList.remove('fw-boot-dragging');
|
||||
if (dragging) dragging.classList.remove('sw-boot-dragging');
|
||||
dragging = null;
|
||||
insertRef = undefined;
|
||||
clearIndicators();
|
||||
@@ -220,17 +280,17 @@
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (!dragging) return;
|
||||
var target = e.target.closest('.fw-boot-badge');
|
||||
var target = e.target.closest('.sw-boot-badge');
|
||||
clearIndicators();
|
||||
if (!target || target === dragging) return;
|
||||
var rect = target.getBoundingClientRect();
|
||||
if (e.clientX < rect.left + rect.width / 2) {
|
||||
insertRef = target;
|
||||
target.classList.add('fw-boot-drop-before');
|
||||
target.classList.add('sw-boot-drop-before');
|
||||
} else {
|
||||
insertRef = target.nextElementSibling || null;
|
||||
if (insertRef && insertRef.classList.contains('fw-boot-badge')) {
|
||||
insertRef.classList.add('fw-boot-drop-before');
|
||||
if (insertRef && insertRef.classList.contains('sw-boot-badge')) {
|
||||
insertRef.classList.add('sw-boot-drop-before');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -243,23 +303,23 @@
|
||||
insertRef = undefined;
|
||||
});
|
||||
|
||||
var saveBtn = document.getElementById('fw-boot-save-btn');
|
||||
if (saveBtn) saveBtn.addEventListener('click', function () { window.fwBootSave(saveBtn); });
|
||||
var saveBtn = document.getElementById('sw-boot-save-btn');
|
||||
if (saveBtn) saveBtn.addEventListener('click', function () { window.swBootSave(saveBtn); });
|
||||
|
||||
var resetBtn = document.getElementById('fw-boot-reset-btn');
|
||||
if (resetBtn) resetBtn.addEventListener('click', window.fwBootReset);
|
||||
var resetBtn = document.getElementById('sw-boot-reset-btn');
|
||||
if (resetBtn) resetBtn.addEventListener('click', window.swBootReset);
|
||||
}
|
||||
|
||||
// Restore the boot-order row to the page-load order — i.e. what RAUC
|
||||
// reported as the current device boot order before any drag/drop.
|
||||
// Set will push the displayed order to the device; Reset just undoes
|
||||
// local rearrangement without a server round-trip.
|
||||
window.fwBootReset = function () {
|
||||
var slots = document.getElementById('fw-boot-slots');
|
||||
window.swBootReset = function () {
|
||||
var slots = document.getElementById('sw-boot-slots');
|
||||
if (!slots) return;
|
||||
var original = (slots.dataset.originalOrder || '').split(',');
|
||||
var existing = {};
|
||||
slots.querySelectorAll('.fw-boot-badge').forEach(function (b) {
|
||||
slots.querySelectorAll('.sw-boot-badge').forEach(function (b) {
|
||||
existing[b.dataset.slot] = b;
|
||||
});
|
||||
original.forEach(function (slot) {
|
||||
@@ -267,8 +327,8 @@
|
||||
});
|
||||
};
|
||||
|
||||
window.fwBootSave = function (btn) {
|
||||
var badges = document.querySelectorAll('#fw-boot-slots .fw-boot-badge');
|
||||
window.swBootSave = function (btn) {
|
||||
var badges = document.querySelectorAll('#sw-boot-slots .sw-boot-badge');
|
||||
var params = new URLSearchParams();
|
||||
badges.forEach(function (b) { params.append('boot-order', b.dataset.slot); });
|
||||
|
||||
@@ -280,7 +340,7 @@
|
||||
|
||||
btnSet('Setting\u2026', true);
|
||||
|
||||
fetch('/firmware/boot-order', {
|
||||
fetch('/software/boot-order', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -291,7 +351,7 @@
|
||||
if (r.ok) {
|
||||
// Device now holds the displayed order; refresh the Reset baseline
|
||||
// so a follow-up \u21ba doesn't revert to a state the device no longer has.
|
||||
var slots = document.getElementById('fw-boot-slots');
|
||||
var slots = document.getElementById('sw-boot-slots');
|
||||
if (slots) {
|
||||
var saved = [];
|
||||
badges.forEach(function (b) { saved.push(b.dataset.slot); });
|
||||
@@ -311,28 +371,95 @@
|
||||
});
|
||||
};
|
||||
|
||||
// ─── System Control: datetime picker ─────────────────────────────────────
|
||||
// Pre-fills #sc-dt-input with the browser's current UTC time on page load
|
||||
// and after HTMX swaps. Also exposed as window.scSyncTime() for the
|
||||
// "Browser time" button.
|
||||
function utcDatetimeLocal() {
|
||||
// ─── Configure > Date & Time card ────────────────────────────────────────
|
||||
// Two pieces working together:
|
||||
// 1. #sc-dt-input is pre-filled with the browser's current local time so
|
||||
// "Set now" defaults to "synchronize device to my clock".
|
||||
// 2. #sc-system-time is a JS-driven live clock seeded from the server's
|
||||
// data-server-time attribute (ISO string). We compute the offset
|
||||
// between device clock and browser clock once, then tick locally and
|
||||
// render via toLocaleString() so each user sees their own locale.
|
||||
// After a successful POST /maintenance/system/datetime we recompute
|
||||
// the offset from the value the user submitted so the visible clock
|
||||
// jumps to the new time and continues ticking.
|
||||
var systemTimeOffsetMs = null; // device - browser
|
||||
|
||||
function localDatetimeLocal() {
|
||||
// Format Date as YYYY-MM-DDTHH:MM:SS in *local* time, matching what
|
||||
// <input type="datetime-local"> stores in its .value field.
|
||||
var d = new Date();
|
||||
return d.getUTCFullYear() + '-' +
|
||||
String(d.getUTCMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getUTCDate()).padStart(2, '0') + 'T' +
|
||||
String(d.getUTCHours()).padStart(2, '0') + ':' +
|
||||
String(d.getUTCMinutes()).padStart(2, '0');
|
||||
return d.getFullYear() + '-' +
|
||||
String(d.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(d.getDate()).padStart(2, '0') + 'T' +
|
||||
String(d.getHours()).padStart(2, '0') + ':' +
|
||||
String(d.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(d.getSeconds()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function renderSystemTime(span) {
|
||||
if (systemTimeOffsetMs === null) return;
|
||||
var d = new Date(Date.now() + systemTimeOffsetMs);
|
||||
span.textContent = d.toLocaleString();
|
||||
}
|
||||
|
||||
function initDatetimePicker(root) {
|
||||
var el = (root || document).querySelector('#sc-dt-input:not([data-init])');
|
||||
if (!el) return;
|
||||
el.dataset.init = 'true';
|
||||
el.value = utcDatetimeLocal();
|
||||
var btn = (root || document).querySelector('#sc-sync-time-btn');
|
||||
if (btn) btn.addEventListener('click', function () { el.value = utcDatetimeLocal(); });
|
||||
// Always pre-fill an empty picker; htmx swaps replace #content so each
|
||||
// visit gets a fresh element, and overwriting an empty value is a no-op.
|
||||
var input = (root || document).querySelector('#sc-dt-input');
|
||||
if (input && !input.value) {
|
||||
input.value = localDatetimeLocal();
|
||||
}
|
||||
|
||||
var span = (root || document).querySelector('#sc-system-time');
|
||||
if (span && span.dataset.serverTime) {
|
||||
// Server omits the timezone offset; treat the value as the device's
|
||||
// local wall clock, which is what the Timezone row tells the user.
|
||||
var deviceMs = new Date(span.dataset.serverTime).getTime();
|
||||
systemTimeOffsetMs = deviceMs - Date.now();
|
||||
renderSystemTime(span);
|
||||
// Cancel any prior tick from an earlier htmx navigation — htmx
|
||||
// replaces #content but doesn't notify JS to clean up timers,
|
||||
// so without this each visit would leak a 1 Hz interval pinning
|
||||
// an orphaned span.
|
||||
if (window.scSystemTimeTimer) clearInterval(window.scSystemTimeTimer);
|
||||
window.scSystemTimeTimer = setInterval(function () { renderSystemTime(span); }, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// The <input type="datetime-local"> reports its value in the user's local
|
||||
// time without a zone suffix; the server appends "+00:00" unconditionally,
|
||||
// so without this hook a CEST user typing 18:55 would set the device to
|
||||
// 18:55 UTC instead of 16:55 UTC. Convert the parameter to UTC just
|
||||
// before it goes on the wire so the device clock matches the moment the
|
||||
// user actually selected. Attached to `document` (not document.body)
|
||||
// because this code runs at IIFE parse time before <body> exists.
|
||||
document.addEventListener('htmx:configRequest', function (evt) {
|
||||
if (!evt.detail || evt.detail.path !== '/maintenance/system/datetime') return;
|
||||
var local = evt.detail.parameters && evt.detail.parameters.datetime;
|
||||
if (!local) return;
|
||||
var d = new Date(local);
|
||||
if (isNaN(d.getTime())) return;
|
||||
// toISOString gives "YYYY-MM-DDTHH:MM:SS.sssZ"; strip millis so the
|
||||
// server's "+00:00" append produces valid RFC 3339.
|
||||
evt.detail.parameters.datetime = d.toISOString().slice(0, 19);
|
||||
});
|
||||
|
||||
// After a successful Set POST, re-sync the live clock to the value the
|
||||
// user just submitted so the display jumps instead of waiting for the next
|
||||
// page load.
|
||||
document.addEventListener('htmx:afterRequest', function (evt) {
|
||||
if (!evt.detail || !evt.detail.successful) return;
|
||||
var path = evt.detail.requestConfig && evt.detail.requestConfig.path;
|
||||
if (path !== '/maintenance/system/datetime') return;
|
||||
var input = document.getElementById('sc-dt-input');
|
||||
var span = document.getElementById('sc-system-time');
|
||||
if (!input || !span || !input.value) return;
|
||||
var submittedMs = new Date(input.value).getTime();
|
||||
if (isNaN(submittedMs)) return;
|
||||
systemTimeOffsetMs = submittedMs - Date.now();
|
||||
renderSystemTime(span);
|
||||
});
|
||||
|
||||
window.scRestoreCheckbox = function (cb) {
|
||||
var form = document.getElementById('restore-form');
|
||||
if (!form) return;
|
||||
@@ -341,16 +468,16 @@
|
||||
: 'Apply this configuration to the running system?');
|
||||
};
|
||||
|
||||
// Locate or inject the shared #fw-progress-card. Server-rendered when the
|
||||
// Locate or inject the shared #sw-progress-card. Server-rendered when the
|
||||
// page loads with ?installing=1; injected here when the upload flow starts
|
||||
// from /firmware. The same DOM element later receives SSE-driven swaps.
|
||||
// from /software. The same DOM element later receives SSE-driven swaps.
|
||||
function ensureProgressCard(headerText, message) {
|
||||
var card = document.getElementById('fw-progress-card');
|
||||
var card = document.getElementById('sw-progress-card');
|
||||
if (!card) {
|
||||
card = document.createElement('section');
|
||||
card.id = 'fw-progress-card';
|
||||
card.id = 'sw-progress-card';
|
||||
card.className = 'info-card';
|
||||
var grid = document.querySelector('.fw-install-grid');
|
||||
var grid = document.querySelector('.sw-install-grid');
|
||||
var parent = grid && grid.parentNode;
|
||||
if (parent) parent.insertBefore(card, grid);
|
||||
else (document.getElementById('content') || document.body).appendChild(card);
|
||||
@@ -368,16 +495,16 @@
|
||||
return card;
|
||||
}
|
||||
|
||||
window.fwUpload = function () {
|
||||
var fileInput = document.getElementById('fw-file');
|
||||
window.swUpload = function () {
|
||||
var fileInput = document.getElementById('sw-file');
|
||||
if (!fileInput || !fileInput.files.length) return;
|
||||
if (!confirm('Upload and install this firmware? The current installation may be overwritten.')) return;
|
||||
if (!confirm('Upload and install this software bundle? The current installation may be overwritten.')) return;
|
||||
|
||||
var file = fileInput.files[0];
|
||||
var autoReboot = !!document.querySelector('#fw-upload-auto-reboot:checked');
|
||||
var btn = document.getElementById('fw-upload-btn');
|
||||
var autoReboot = !!document.querySelector('#sw-upload-auto-reboot:checked');
|
||||
var btn = document.getElementById('sw-upload-btn');
|
||||
|
||||
var sseURL = new URL((btn && btn.getAttribute('data-sse-url')) || '/firmware/progress', window.location.origin);
|
||||
var sseURL = new URL((btn && btn.getAttribute('data-sse-url')) || '/software/progress', window.location.origin);
|
||||
if (autoReboot) sseURL.searchParams.set('auto-reboot', '1');
|
||||
|
||||
var formData = new FormData();
|
||||
@@ -385,7 +512,7 @@
|
||||
if (autoReboot) formData.append('auto-reboot', '1');
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
var card = ensureProgressCard('Uploading Firmware', 'Uploading firmware image… 0%');
|
||||
var card = ensureProgressCard('Uploading Bundle', 'Uploading bundle… 0%');
|
||||
var bar = card.querySelector('.progress-bar');
|
||||
var text = card.querySelector('.progress-text');
|
||||
|
||||
@@ -395,7 +522,7 @@
|
||||
if (!e.lengthComputable) return;
|
||||
var pct = Math.round(e.loaded / e.total * 100);
|
||||
bar.style.width = pct + '%';
|
||||
text.textContent = 'Uploading firmware image\u2026 ' + pct + '%';
|
||||
text.textContent = 'Uploading bundle\u2026 ' + pct + '%';
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
@@ -405,13 +532,13 @@
|
||||
return;
|
||||
}
|
||||
// pushState lets a mid-install reload resume the progress card.
|
||||
var target = xhr.responseText.trim() || '/firmware?installing=1';
|
||||
var target = xhr.responseText.trim() || '/software?installing=1';
|
||||
if (window.history && window.history.pushState) {
|
||||
window.history.pushState({}, '', target);
|
||||
}
|
||||
text.textContent = 'Starting installation\u2026';
|
||||
card.setAttribute('data-sse-src', sseURL.pathname + sseURL.search);
|
||||
initFirmwareProgress(document);
|
||||
initSoftwareProgress(document);
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
@@ -419,25 +546,25 @@
|
||||
if (btn) btn.disabled = false;
|
||||
};
|
||||
|
||||
xhr.open('POST', '/firmware/upload');
|
||||
xhr.open('POST', '/software/upload');
|
||||
xhr.setRequestHeader('X-CSRF-Token', getCSRFToken());
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
// SSE-driven firmware progress card.
|
||||
// SSE-driven software install progress card.
|
||||
// The Go server polls RESTCONF and streams rendered HTML fragments; we just
|
||||
// swap them into the card and let the server close the stream when done.
|
||||
var fwEventSource = null;
|
||||
var swEventSource = null;
|
||||
|
||||
function initFirmwareProgress(root) {
|
||||
function initSoftwareProgress(root) {
|
||||
var scope = root || document;
|
||||
var card = scope.querySelector('#fw-progress-card[data-sse-src]');
|
||||
var card = scope.querySelector('#sw-progress-card[data-sse-src]');
|
||||
if (!card || card.dataset.sseInit) return;
|
||||
card.dataset.sseInit = 'true';
|
||||
|
||||
var src = card.getAttribute('data-sse-src');
|
||||
if (fwEventSource) { fwEventSource.close(); }
|
||||
fwEventSource = new EventSource(src);
|
||||
if (swEventSource) { swEventSource.close(); }
|
||||
swEventSource = new EventSource(src);
|
||||
|
||||
function swap(html) {
|
||||
card.innerHTML = html;
|
||||
@@ -446,24 +573,24 @@
|
||||
}
|
||||
|
||||
function endStream() {
|
||||
fwEventSource.close();
|
||||
fwEventSource = null;
|
||||
swEventSource.close();
|
||||
swEventSource = null;
|
||||
// Drop the stream URL and re-arm the upload button so a follow-up
|
||||
// install can run on the same page without a reload.
|
||||
card.removeAttribute('data-sse-src');
|
||||
delete card.dataset.sseInit;
|
||||
var btn = document.getElementById('fw-upload-btn');
|
||||
var btn = document.getElementById('sw-upload-btn');
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
|
||||
fwEventSource.addEventListener('progress', function(e) { swap(e.data); });
|
||||
swEventSource.addEventListener('progress', function(e) { swap(e.data); });
|
||||
|
||||
fwEventSource.addEventListener('done', function(e) {
|
||||
swEventSource.addEventListener('done', function(e) {
|
||||
swap(e.data);
|
||||
endStream();
|
||||
});
|
||||
|
||||
fwEventSource.addEventListener('reboot', function(e) {
|
||||
swEventSource.addEventListener('reboot', function(e) {
|
||||
swap(e.data);
|
||||
endStream();
|
||||
// Auto-reboot: POST /reboot and show the reboot overlay.
|
||||
@@ -478,14 +605,23 @@
|
||||
});
|
||||
});
|
||||
|
||||
fwEventSource.onerror = endStream;
|
||||
// Let EventSource auto-reconnect on transient errors. Only tear down
|
||||
// when the browser gives up (readyState === CLOSED) — otherwise an
|
||||
// nginx read-timeout or a brief network blip would silently kill the
|
||||
// stream and leave the progress card stuck on stale data.
|
||||
swEventSource.onerror = function () {
|
||||
if (swEventSource && swEventSource.readyState === EventSource.CLOSED) {
|
||||
endStream();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initCSRF();
|
||||
initPageTitle();
|
||||
initDeviceStatusBanner();
|
||||
initDynamicUI(document);
|
||||
initFirmwareProgress(document);
|
||||
initSoftwareProgress(document);
|
||||
|
||||
// Attach the htmx swap listener here, not at IIFE parse time — at parse
|
||||
// time the script runs inside <head> before <body> exists, so the
|
||||
@@ -493,14 +629,14 @@
|
||||
// navigations wouldn't re-init dynamic UI.
|
||||
if (window.htmx) {
|
||||
document.body.addEventListener('htmx:afterSwap', function (evt) {
|
||||
// Close any open SSE stream if the firmware progress card is no longer present.
|
||||
if (fwEventSource && !document.getElementById('fw-progress-card')) {
|
||||
fwEventSource.close();
|
||||
fwEventSource = null;
|
||||
// Close any open SSE stream if the software install progress card is no longer present.
|
||||
if (swEventSource && !document.getElementById('sw-progress-card')) {
|
||||
swEventSource.close();
|
||||
swEventSource = null;
|
||||
}
|
||||
var scope = (evt.detail && evt.detail.target) || document;
|
||||
initDynamicUI(scope);
|
||||
initFirmwareProgress(scope);
|
||||
initSoftwareProgress(scope);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -619,18 +755,17 @@
|
||||
// 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;
|
||||
// A checkbox marked [data-fold-target="<id>"] toggles the matching <details>
|
||||
// (or any element with that id) immediately on change. Used for the DHCP /
|
||||
// DHCPv6 settings foldouts on Configure → Interface: the foldout is always
|
||||
// in the DOM but starts hidden when the client isn't enabled. This lets the
|
||||
// user see the settings form before clicking Save IPvX Settings and confirms
|
||||
// the section exists even when DHCP is off.
|
||||
document.addEventListener('change', function (evt) {
|
||||
var cb = evt.target;
|
||||
if (!cb || !cb.matches || !cb.matches('input[type="checkbox"][data-fold-target]')) return;
|
||||
var target = document.getElementById(cb.getAttribute('data-fold-target'));
|
||||
if (target) target.hidden = !cb.checked;
|
||||
});
|
||||
|
||||
// Add Interface modal — open via data-show-modal, close via
|
||||
@@ -1617,11 +1752,34 @@ function renderCfgLog() {
|
||||
}
|
||||
});
|
||||
|
||||
// findSaveStatusSpan locates the status span associated with the form that
|
||||
// triggered an htmx event. Lookup order:
|
||||
// 1. .cfg-save-status inside the form
|
||||
// 2. [data-cfg-status-for="<form-id>"] anywhere on the page — used when the
|
||||
// Save button is bound to the form via the HTML5 `form` attribute and
|
||||
// lives outside the form element
|
||||
// 3. .cfg-save-status inside the enclosing .info-card (shared feedback slot)
|
||||
function findSaveStatusSpan(e) {
|
||||
var form = e.target && e.target.closest('form');
|
||||
if (form) {
|
||||
var inside = form.querySelector('.cfg-save-status');
|
||||
if (inside) return inside;
|
||||
if (form.id) {
|
||||
var bound = document.querySelector('.cfg-save-status[data-cfg-status-for="' + form.id + '"]');
|
||||
if (bound) return bound;
|
||||
}
|
||||
}
|
||||
var card = e.target && e.target.closest('.info-card');
|
||||
return card ? card.querySelector('.cfg-save-status') : null;
|
||||
}
|
||||
|
||||
// cfgError: show error message in the .cfg-save-status span of the submitting form.
|
||||
// Falls back to the enclosing .info-card when the form itself has no status
|
||||
// span — useful when multiple forms in one card share a single feedback slot
|
||||
// (e.g. Date & Time's Set-now and Save-timezone forms).
|
||||
document.addEventListener('cfgError', function(e) {
|
||||
var msg = e.detail && e.detail.value ? e.detail.value : 'Save failed';
|
||||
var form = e.target && e.target.closest('form');
|
||||
var span = form ? form.querySelector('.cfg-save-status') : null;
|
||||
var span = findSaveStatusSpan(e);
|
||||
if (span) {
|
||||
span.textContent = '✗ ' + msg;
|
||||
span.classList.add('error');
|
||||
@@ -1642,7 +1800,7 @@ function renderCfgLog() {
|
||||
|
||||
// Show "Saved ✓" feedback when a card Save succeeds.
|
||||
(function() {
|
||||
var LS_KEY = 'fw-url-history';
|
||||
var LS_KEY = 'sw-url-history';
|
||||
var MAX_HIST = 10;
|
||||
|
||||
function loadHistory() {
|
||||
@@ -1658,7 +1816,7 @@ function renderCfgLog() {
|
||||
}
|
||||
|
||||
function populateDatalist() {
|
||||
var dl = document.getElementById('fw-url-history');
|
||||
var dl = document.getElementById('sw-url-history');
|
||||
if (!dl) return;
|
||||
dl.innerHTML = loadHistory().map(function(u) {
|
||||
return '<option value="' + u.replace(/&/g, '&').replace(/"/g, '"') + '">';
|
||||
@@ -1669,7 +1827,7 @@ function renderCfgLog() {
|
||||
document.addEventListener('htmx:afterSettle', populateDatalist);
|
||||
|
||||
document.addEventListener('submit', function(e) {
|
||||
var form = e.target.closest('.firmware-form');
|
||||
var form = e.target.closest('.software-form');
|
||||
if (!form) return;
|
||||
var input = form.querySelector('input[name="url"]');
|
||||
if (input && input.value) saveURL(input.value);
|
||||
@@ -1679,9 +1837,7 @@ function renderCfgLog() {
|
||||
document.addEventListener('cfgSaved', function(e) {
|
||||
var msg = e.detail && e.detail.value ? e.detail.value : 'Saved';
|
||||
cfgLog('ok', msg);
|
||||
// Find the status span closest to the form that triggered the event.
|
||||
var form = e.target && e.target.closest('form');
|
||||
var span = form ? form.querySelector('.cfg-save-status') : null;
|
||||
var span = findSaveStatusSpan(e);
|
||||
if (span) {
|
||||
span.textContent = '✓ ' + msg;
|
||||
span.classList.add('saved');
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{{/* 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}}
|
||||
@@ -82,11 +82,7 @@
|
||||
<div class="yt-binary-row">
|
||||
<span class="yt-binary-hint">binary data</span>
|
||||
<button type="button" class="btn-icon yt-binary-eye" title="Show/hide">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
{{template "icon-eye"}}
|
||||
</button>
|
||||
</div>
|
||||
<code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code>
|
||||
@@ -139,7 +135,7 @@
|
||||
hx-delete="/configure/tree/node?path={{.Path | urlquery}}"
|
||||
hx-target="#yang-detail"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Reset {{.Name}} to its YANG default?">↺</button>
|
||||
hx-confirm="Reset {{.Name}} to its YANG default?">{{template "icon-reset"}}</button>
|
||||
{{end}}
|
||||
</summary>
|
||||
{{if $hasHelp}}
|
||||
@@ -191,10 +187,7 @@
|
||||
hx-swap="innerHTML">
|
||||
{{if $list.Complex}}
|
||||
<td class="yt-nav-col">
|
||||
<svg class="yt-row-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
{{template "icon-yt-row-arrow"}}
|
||||
</td>
|
||||
{{end}}
|
||||
{{range $list.Columns}}<td>{{index $row.Values .Name}}</td>{{end}}
|
||||
@@ -303,11 +296,7 @@
|
||||
<div class="yt-binary-row">
|
||||
<span class="yt-binary-hint">binary data</span>
|
||||
<button type="button" class="btn-icon yt-binary-eye" title="Show/hide">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
{{template "icon-eye"}}
|
||||
</button>
|
||||
</div>
|
||||
<code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code>
|
||||
@@ -357,7 +346,7 @@
|
||||
hx-delete="/configure/tree/node?path={{.Path | urlquery}}"
|
||||
hx-target="#yang-detail"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Reset {{.Name}} to its YANG default?">↺</button>
|
||||
hx-confirm="Reset {{.Name}} to its YANG default?">{{template "icon-reset"}}</button>
|
||||
{{end}}
|
||||
</summary>
|
||||
{{if $hasHelp}}
|
||||
@@ -399,11 +388,7 @@
|
||||
hx-target="#yang-detail"
|
||||
hx-swap="innerHTML">
|
||||
<span class="yt-subsection-name">{{.Name}}</span>
|
||||
<svg class="yt-subsection-arrow" width="14" height="14" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
{{template "icon-yt-subsection-arrow"}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
hx-get="{{.TreeBase}}/node?path={{.Path | urlquery}}"
|
||||
hx-target="#yang-detail"
|
||||
hx-swap="innerHTML">
|
||||
<svg class="yt-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
{{template "icon-yt-chevron"}}
|
||||
<span class="yt-name">{{.Name}}</span>
|
||||
</summary>
|
||||
<ul class="yt-children"></ul>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<meta name="csrf-token" content="{{.CsrfToken}}">
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles": false}'>
|
||||
{{if .RetryAfter}}<meta http-equiv="refresh" content="{{.RetryAfter}}">{{end}}
|
||||
<title>{{if .PageTitle}}{{.PageTitle}} — {{end}}Management</title>
|
||||
<title>{{.PageTitle}}</title>
|
||||
<link rel="icon" type="image/png" href="/assets/img/jack.png">
|
||||
<link rel="stylesheet" href="/assets/css/style.css">
|
||||
<script src="/assets/js/htmx.min.js"></script>
|
||||
@@ -20,69 +20,60 @@
|
||||
<div hidden hx-get="/device-status" hx-trigger="every 5s" hx-swap="none"></div>
|
||||
<div class="topbar">
|
||||
<button id="hamburger-btn" class="hamburger-btn" aria-label="Open menu" aria-expanded="false">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||
</svg>
|
||||
{{template "icon-menu"}}
|
||||
</button>
|
||||
<img src="/assets/img/logo.png" alt="Infix" class="topbar-logo">
|
||||
<div class="topbar-right">
|
||||
<div class="user-menu" id="user-menu">
|
||||
<button class="user-menu-btn" id="user-menu-btn" aria-haspopup="true" aria-expanded="false">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
{{template "icon-user"}}
|
||||
<span>{{.Username}}</span>
|
||||
<svg class="chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
{{template "icon-chevron-down"}}
|
||||
</button>
|
||||
<div class="user-dropdown" id="user-dropdown">
|
||||
<div class="dropdown-section-label">Theme</div>
|
||||
<button class="dropdown-item theme-opt" data-theme="auto">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"></path></svg>
|
||||
{{template "icon-contrast"}}
|
||||
Auto
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{{template "icon-check"}}
|
||||
</button>
|
||||
<button class="dropdown-item theme-opt" data-theme="light">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
||||
{{template "icon-sun"}}
|
||||
Light
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{{template "icon-check"}}
|
||||
</button>
|
||||
<button class="dropdown-item theme-opt" data-theme="dark">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg>
|
||||
{{template "icon-moon"}}
|
||||
Dark
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{{template "icon-check"}}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-section-label">Auto-logout</div>
|
||||
<button class="dropdown-item timeout-opt" data-timeout="300">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><polyline points="12 7 12 12 15 15"></polyline></svg>
|
||||
{{template "icon-clock"}}
|
||||
5 min
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{{template "icon-check"}}
|
||||
</button>
|
||||
<button class="dropdown-item timeout-opt" data-timeout="900">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><polyline points="12 7 12 12 15 15"></polyline></svg>
|
||||
{{template "icon-clock"}}
|
||||
15 min
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{{template "icon-check"}}
|
||||
</button>
|
||||
<button class="dropdown-item timeout-opt" data-timeout="1800">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><polyline points="12 7 12 12 15 15"></polyline></svg>
|
||||
{{template "icon-clock"}}
|
||||
30 min
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{{template "icon-check"}}
|
||||
</button>
|
||||
<button class="dropdown-item timeout-opt" data-timeout="0">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
|
||||
{{template "icon-circle-slash"}}
|
||||
Off
|
||||
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
{{template "icon-check"}}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<form method="POST" action="/logout">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<button type="submit" class="dropdown-item dropdown-item-danger">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>
|
||||
{{template "icon-log-out"}}
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
@@ -96,12 +87,7 @@
|
||||
<div class="main-column">
|
||||
{{if .CfgUnsaved}}
|
||||
<div id="cfg-unsaved-banner" class="cfg-unsaved-banner">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
{{template "icon-triangle-alert"}}
|
||||
Running config has unsaved changes — will be lost on reboot.
|
||||
<button type="button" class="btn btn-sm btn-primary cfg-unsaved-save-btn"
|
||||
title="Copy running config to startup config so changes survive a reboot."
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{{/* Shared inline SVG icons. Each is one Lucide source baked at its
|
||||
use-site size and CSS class so the call site stays a one-liner.
|
||||
Vendored from https://lucide.dev (ISC). */}}
|
||||
|
||||
{{/* ⓘ hover-help glyph. Pass the description string; renders nothing
|
||||
if empty so callers don't need to guard. */}}
|
||||
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}">ⓘ</span>{{end}}{{end}}
|
||||
|
||||
|
||||
{{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}}
|
||||
{{define "icon-reset"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" /></svg>{{end}}
|
||||
{{define "icon-menu"}}<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M4 5h16" /><path d="M4 12h16" /><path d="M4 19h16" /></svg>{{end}}
|
||||
{{define "icon-user"}}<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>{{end}}
|
||||
{{define "icon-user-lg"}}<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" /></svg>{{end}}
|
||||
{{define "icon-chevron-down"}}<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="chevron" aria-hidden="true" stroke-width="2.5"><path d="m6 9 6 6 6-6" /></svg>{{end}}
|
||||
{{define "icon-contrast"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10" /><path d="M12 18a6 6 0 0 0 0-12v12z" /></svg>{{end}}
|
||||
{{define "icon-check"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="theme-check" stroke-width="2.5"><path d="M20 6 9 17l-5-5" /></svg>{{end}}
|
||||
{{define "icon-sun"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" /></svg>{{end}}
|
||||
{{define "icon-moon"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></svg>{{end}}
|
||||
{{define "icon-clock"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10" /><path d="M12 6v6l4 2" /></svg>{{end}}
|
||||
{{define "icon-circle-slash"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><circle cx="12" cy="12" r="10" /><line x1="9" x2="15" y1="15" y2="9" /></svg>{{end}}
|
||||
{{define "icon-log-out"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /></svg>{{end}}
|
||||
{{define "icon-triangle-alert"}}<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" /><path d="M12 9v4" /><path d="M12 17h.01" /></svg>{{end}}
|
||||
{{define "icon-login-theme-auto"}}<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" id="lti-auto"><circle cx="12" cy="12" r="10" /><path d="M12 18a6 6 0 0 0 0-12v12z" /></svg>{{end}}
|
||||
{{define "icon-login-theme-light"}}<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" id="lti-light" style="display:none"><circle cx="12" cy="12" r="4" /><path d="M12 2v2" /><path d="M12 20v2" /><path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" /><path d="M2 12h2" /><path d="M20 12h2" /><path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" /></svg>{{end}}
|
||||
{{define "icon-login-theme-dark"}}<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" id="lti-dark" style="display:none"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401" /></svg>{{end}}
|
||||
{{define "icon-check-lg"}}<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" stroke-width="2.5"><path d="M20 6 9 17l-5-5" /></svg>{{end}}
|
||||
{{define "icon-circle-x"}}<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" stroke-width="2.5"><circle cx="12" cy="12" r="10" /><path d="m15 9-6 6" /><path d="m9 9 6 6" /></svg>{{end}}
|
||||
{{define "icon-yt-chevron"}}<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="yt-chevron" stroke-width="2.5"><path d="m9 18 6-6-6-6" /></svg>{{end}}
|
||||
{{define "icon-yt-row-arrow"}}<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="yt-row-arrow" stroke-width="2.5"><path d="m9 18 6-6-6-6" /></svg>{{end}}
|
||||
{{define "icon-yt-subsection-arrow"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" class="yt-subsection-arrow" stroke-width="2.5"><path d="m9 18 6-6-6-6" /></svg>{{end}}
|
||||
{{define "icon-search-empty"}}<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" style="margin-bottom:.75rem;color:var(--fg-muted)"><path d="m21 21-4.34-4.34" /><circle cx="11" cy="11" r="8" /></svg>{{end}}
|
||||
{{define "icon-eye"}}<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></svg>{{end}}
|
||||
@@ -39,6 +39,36 @@
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="/services"
|
||||
hx-get="/services"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "services"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/services.svg')"></span>
|
||||
Services
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/keystore"
|
||||
hx-get="/keystore"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "keystore"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/keystore.svg')"></span>
|
||||
Keystore
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/nacm"
|
||||
hx-get="/nacm"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "nacm"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/nacm.svg')"></span>
|
||||
Users & Groups
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-label">Network</div>
|
||||
@@ -53,65 +83,6 @@
|
||||
Interfaces
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/routing"
|
||||
hx-get="/routing"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "routing"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/routing.svg')"></span>
|
||||
Routing
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-label">Security</div>
|
||||
<ul class="nav-group-items">
|
||||
<li>
|
||||
<a href="/firewall"
|
||||
hx-get="/firewall"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "firewall"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
|
||||
Firewall
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/vpn"
|
||||
hx-get="/vpn"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "vpn"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/wireguard.svg')"></span>
|
||||
WireGuard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/nacm"
|
||||
hx-get="/nacm"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "nacm"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/nacm.svg')"></span>
|
||||
NACM
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-label">Services</div>
|
||||
<ul class="nav-group-items">
|
||||
<li>
|
||||
<a href="/services"
|
||||
hx-get="/services"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "services"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/services.svg')"></span>
|
||||
Services
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/dhcp"
|
||||
hx-get="/dhcp"
|
||||
@@ -122,16 +93,6 @@
|
||||
DHCP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/ntp"
|
||||
hx-get="/ntp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "ntp"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/ntp.svg')"></span>
|
||||
NTP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/lldp"
|
||||
hx-get="/lldp"
|
||||
@@ -152,6 +113,58 @@
|
||||
mDNS
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/ntp"
|
||||
hx-get="/ntp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "ntp"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/ntp.svg')"></span>
|
||||
NTP
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/routing"
|
||||
hx-get="/routing"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "routing"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/routing.svg')"></span>
|
||||
Routes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/firewall"
|
||||
hx-get="/firewall"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "firewall"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
|
||||
Firewall
|
||||
</a>
|
||||
</li>
|
||||
{{if and .Capabilities (.Capabilities.Has "wifi")}}
|
||||
<li>
|
||||
<a href="/wifi"
|
||||
hx-get="/wifi"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "wifi"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/wifi.svg')"></span>
|
||||
WiFi
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="/vpn"
|
||||
hx-get="/vpn"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "vpn"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/wireguard.svg')"></span>
|
||||
WireGuard
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-label">Advanced</div>
|
||||
@@ -163,7 +176,7 @@
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "status-tree"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/status-tree.svg')"></span>
|
||||
Advanced
|
||||
View all
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -171,19 +184,11 @@
|
||||
</details>
|
||||
|
||||
<details class="nav-group-top" id="nav-configure"
|
||||
{{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}}>
|
||||
{{if or (eq .ActivePage "configure-system") (eq .ActivePage "configure-ntp") (eq .ActivePage "configure-dns") (eq .ActivePage "configure-hardware") (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>
|
||||
|
||||
<div class="nav-section-label">System</div>
|
||||
<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"
|
||||
@@ -191,7 +196,7 @@
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-system"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/system.svg')"></span>
|
||||
System
|
||||
General
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -204,36 +209,6 @@
|
||||
Hardware
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/firewall"
|
||||
hx-get="/configure/firewall"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-firewall"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
|
||||
Firewall
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/routes"
|
||||
hx-get="/configure/routes"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-routes"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/routes.svg')"></span>
|
||||
Routes
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/users"
|
||||
hx-get="/configure/users"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-users"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/nacm.svg')"></span>
|
||||
Users
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/keystore"
|
||||
hx-get="/configure/keystore"
|
||||
@@ -244,6 +219,74 @@
|
||||
Keystore
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/users"
|
||||
hx-get="/configure/users"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-users"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/nacm.svg')"></span>
|
||||
Users & Groups
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-label">Network</div>
|
||||
<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/routes"
|
||||
hx-get="/configure/routes"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-routes"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/routes.svg')"></span>
|
||||
Routing
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/firewall"
|
||||
hx-get="/configure/firewall"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-firewall"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/firewall.svg')"></span>
|
||||
Firewall
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/dns"
|
||||
hx-get="/configure/dns"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-dns"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/dns.svg')"></span>
|
||||
DNS Client
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/configure/ntp"
|
||||
hx-get="/configure/ntp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-ntp"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/ntp.svg')"></span>
|
||||
NTP Client
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-label">Advanced</div>
|
||||
<ul class="nav-group-items">
|
||||
<li>
|
||||
<a href="/configure/tree"
|
||||
hx-get="/configure/tree"
|
||||
@@ -251,23 +294,24 @@
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "configure-tree"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/advanced.svg')"></span>
|
||||
Advanced
|
||||
Edit all
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<details class="nav-group-top">
|
||||
<details class="nav-group-top"
|
||||
{{if or (eq .ActivePage "software") (eq .ActivePage "backup") (eq .ActivePage "system-control")}}open{{end}}>
|
||||
<summary class="nav-group-summary-top">Maintenance</summary>
|
||||
<ul class="nav-group-items">
|
||||
<li>
|
||||
<a href="/firmware"
|
||||
hx-get="/firmware"
|
||||
<a href="/software"
|
||||
hx-get="/software"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "firmware"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/firmware.svg')"></span>
|
||||
Firmware
|
||||
class="nav-link {{if eq .ActivePage "software"}}active{{end}}">
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/software.svg')"></span>
|
||||
Software
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
@@ -276,7 +320,7 @@
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "backup"}}active{{end}}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/backup.svg')"></span>
|
||||
Backup & Restore
|
||||
</a>
|
||||
</li>
|
||||
@@ -286,7 +330,7 @@
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="nav-link {{if eq .ActivePage "system-control"}}active{{end}}">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>
|
||||
<span class="nav-icon" style="--icon: url('/assets/img/icons/system-control.svg')"></span>
|
||||
System Control
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{{define "configure-dns.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="configure-page">
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form class="info-card"
|
||||
hx-put="/configure/system/dns"
|
||||
hx-swap="none">
|
||||
<div class="card-header">DNS</div>
|
||||
|
||||
<div class="section-subtitle">Search domains</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="dns-search-table">
|
||||
<thead>
|
||||
<tr><th>Domain</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="dns-search-tbody">
|
||||
{{range $i, $d := .DNS.Search}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_search_{{$i}}" value="{{$d}}"></td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
|
||||
{{template "icon-trash"}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding: 0 0.75rem;">
|
||||
<button type="button" class="btn-add-row" data-table="dns-search-tbody"
|
||||
data-template="dns-search">+ Add domain</button>
|
||||
</div>
|
||||
|
||||
<div class="section-subtitle" style="margin-top:0.75rem">Servers</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="dns-server-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Key</th>
|
||||
<th>Address</th>
|
||||
<th class="cfg-input-sm">Port</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-server-tbody">
|
||||
{{range $i, $s := .DNS.Servers}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_name_{{$i}}" value="{{$s.Name}}" required></td>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_addr_{{$i}}" value="{{$s.UDPAndTCP.Address}}"></td>
|
||||
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
|
||||
name="dns_port_{{$i}}" value="{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{end}}"
|
||||
placeholder="53"></td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
|
||||
{{template "icon-trash"}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cfg-card-footer">
|
||||
<button type="button" class="btn-add-row" data-table="dns-server-tbody"
|
||||
data-template="dns-server">+ Add server</button>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
{{template "configure-toolbar" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -54,7 +54,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/infix-firewall:firewall/enabled&redirect=/configure/firewall"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset enabled to its YANG default?">↺</button>
|
||||
hx-confirm="Reset enabled to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -73,7 +73,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/infix-firewall:firewall/default&redirect=/configure/firewall"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset default zone to its YANG default?">↺</button>
|
||||
hx-confirm="Reset default zone to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -90,7 +90,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/infix-firewall:firewall/logging&redirect=/configure/firewall"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset logging to its YANG default?">↺</button>
|
||||
hx-confirm="Reset logging to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -166,7 +166,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/zone=%s/action" .Name | urlquery}}&redirect=/configure/firewall"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset action to its YANG default?">↺</button>
|
||||
hx-confirm="Reset action to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -177,7 +177,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/zone=%s/description" .Name | urlquery}}&redirect=/configure/firewall"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset description to its YANG default?">↺</button>
|
||||
hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{if .Network}}
|
||||
@@ -396,7 +396,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/policy=%s/action" .Name | urlquery}}&redirect=/configure/firewall"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset action to its YANG default?">↺</button>
|
||||
hx-confirm="Reset action to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -443,7 +443,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{printf "/infix-firewall:firewall/policy=%s/masquerade" .Name | urlquery}}&redirect=/configure/firewall"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset masquerade to its YANG default?">↺</button>
|
||||
hx-confirm="Reset masquerade to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -732,4 +732,3 @@
|
||||
|
||||
{{end}}
|
||||
|
||||
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}">ⓘ</span>{{end}}{{end}}
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$p}}/description&redirect=/configure/hardware"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset description to its YANG default?">↺</button>
|
||||
hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -170,7 +170,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$p}}/state/admin-state&redirect=/configure/hardware"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset state to its YANG default?">↺</button>
|
||||
hx-confirm="Reset state to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -196,7 +196,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$p}}/description&redirect=/configure/hardware"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset description to its YANG default?">↺</button>
|
||||
hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -222,7 +222,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$rp}}/band&redirect=/configure/hardware"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset band to its YANG default?">↺</button>
|
||||
hx-confirm="Reset band to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -236,7 +236,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$rp}}/channel&redirect=/configure/hardware"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset channel to its YANG default?">↺</button>
|
||||
hx-confirm="Reset channel to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -261,7 +261,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$p}}/description&redirect=/configure/hardware"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset description to its YANG default?">↺</button>
|
||||
hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -272,4 +272,3 @@
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}">ⓘ</span>{{end}}{{end}}
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$ip}}/description&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset description to its YANG default?">↺</button>
|
||||
hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -102,7 +102,23 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$ip}}/enabled&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset enabled to its YANG default?">↺</button>
|
||||
hx-confirm="Reset enabled to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>MAC address{{template "field-info" (index $d "mac")}}</th>
|
||||
<td>
|
||||
<input class="cfg-input cfg-input-mono" type="text" name="mac"
|
||||
value="{{if .CustomPhysAddress}}{{.CustomPhysAddress.Static}}{{end}}"
|
||||
placeholder="{{if .PhysAddress}}{{.PhysAddress}} (default){{else}}aa:bb:cc:dd:ee:ff{{end}}"
|
||||
pattern="[0-9a-fA-F][02468aAcCeE](:[0-9a-fA-F]{2}){5}"
|
||||
title="Unicast MAC, lower-case or upper-case hex, e.g. 02:11:22:33:44:55">
|
||||
</td>
|
||||
<td class="cfg-reset-col">
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Clear custom MAC"
|
||||
hx-delete="/configure/leaf?path={{$ip}}/infix-interfaces:custom-phys-address&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Clear the custom MAC override?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -118,31 +134,38 @@
|
||||
<div style="margin-top:1rem">
|
||||
<h4 class="cfg-section-head">IP Addresses</h4>
|
||||
|
||||
{{/* IPv4 */}}
|
||||
{{/* IPv4. Toggles, DHCP foldout, address list, and add-address
|
||||
row visually compose one IPv4 group; the Save button at the
|
||||
bottom is bound to the toggles form via the HTML5 `form`
|
||||
attribute, so users see the whole group before clicking Save. */}}
|
||||
{{$ipv4Form := printf "ipv4-form-%s" $r.Name}}
|
||||
{{$ipv4Fold := printf "ipv4-dhcp-%s" $r.Name}}
|
||||
<h5 class="cfg-subsection-head">IPv4{{template "field-info" (index $d "ipv4-address")}}</h5>
|
||||
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox"
|
||||
name="enabled" value="true"
|
||||
hx-trigger="change"
|
||||
hx-post="/configure/interfaces/{{.Name}}/ipv4/dhcp"
|
||||
hx-swap="none"
|
||||
data-toggle-details
|
||||
{{if and .IPv4 .IPv4.DHCP}}checked{{end}}>
|
||||
DHCP client{{template "field-info" (index $d "ipv4-dhcp")}}
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox"
|
||||
name="enabled" value="true"
|
||||
hx-trigger="change"
|
||||
hx-post="/configure/interfaces/{{.Name}}/ipv4/autoconf"
|
||||
hx-swap="none"
|
||||
{{if and .IPv4 .IPv4.Autoconf}}checked{{end}}>
|
||||
Link-Local (Zeroconf){{template "field-info" (index $d "ipv4-autoconf")}}
|
||||
</label>
|
||||
</div>
|
||||
{{if and .IPv4 .IPv4.DHCP}}
|
||||
<details class="cfg-fold">
|
||||
<form id="{{$ipv4Form}}" hx-post="/configure/interfaces/{{.Name}}/ipv4/settings" hx-swap="none">
|
||||
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox" name="forwarding" value="true"
|
||||
{{if and .IPv4 .IPv4.Forwarding}}checked{{end}}>
|
||||
Forwarding{{template "field-info" (index $d "ipv4-forwarding")}}
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox" name="dhcp" value="true" data-fold-target="{{$ipv4Fold}}"
|
||||
{{if .DHCPv4Enabled}}checked{{end}}>
|
||||
DHCP client{{template "field-info" (index $d "ipv4-dhcp")}}
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox" name="autoconf" value="true"
|
||||
{{if and .IPv4 .IPv4.Autoconf}}checked{{end}}>
|
||||
Link-Local (Zeroconf){{template "field-info" (index $d "ipv4-autoconf")}}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* DHCPv4 foldout — always in the DOM, hidden when DHCP is off.
|
||||
The data-fold-target hook on the DHCP checkbox toggles
|
||||
this without waiting for a server round-trip so the
|
||||
foldout is discoverable before clicking Save. */}}
|
||||
<details id="{{$ipv4Fold}}" class="cfg-fold" {{if not .DHCPv4Enabled}}hidden{{end}}>
|
||||
<summary>DHCP Settings & Options</summary>
|
||||
<div class="cfg-fold-body">
|
||||
<form hx-post="/configure/interfaces/{{.Name}}/ipv4/dhcp/settings" hx-swap="none">
|
||||
@@ -205,7 +228,6 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{if and .IPv4 .IPv4.Address}}
|
||||
<table class="info-table" style="margin-bottom:0.5rem">
|
||||
@@ -239,31 +261,37 @@
|
||||
<span class="cfg-save-status"></span>
|
||||
</form>
|
||||
|
||||
{{/* IPv6 */}}
|
||||
<h5 class="cfg-subsection-head" style="margin-top:0.75rem">IPv6{{template "field-info" (index $d "ipv6-address")}}</h5>
|
||||
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox"
|
||||
name="enabled" value="true"
|
||||
hx-trigger="change"
|
||||
hx-post="/configure/interfaces/{{.Name}}/ipv6/dhcp"
|
||||
hx-swap="none"
|
||||
data-toggle-details
|
||||
{{if and .IPv6 .IPv6.DHCPv6}}checked{{end}}>
|
||||
DHCPv6 client{{template "field-info" (index $d "ipv6-dhcp")}}
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox"
|
||||
name="enabled" value="true"
|
||||
hx-trigger="change"
|
||||
hx-post="/configure/interfaces/{{.Name}}/ipv6/autoconf"
|
||||
hx-swap="none"
|
||||
{{if and .IPv6 .IPv6.SLAACv6}}checked{{end}}>
|
||||
SLAAC{{template "field-info" (index $d "ipv6-slaac")}}
|
||||
</label>
|
||||
<div class="cfg-card-footer" style="padding-left:0">
|
||||
<button form="{{$ipv4Form}}" type="submit" class="btn btn-primary btn-sm">Save IPv4 Settings</button>
|
||||
<span class="cfg-save-status" data-cfg-status-for="{{$ipv4Form}}"></span>
|
||||
</div>
|
||||
{{if and .IPv6 .IPv6.DHCPv6}}
|
||||
<details class="cfg-fold">
|
||||
|
||||
{{/* IPv6 — mirrors the IPv4 form structure above. */}}
|
||||
{{/* IPv6 — mirrors the IPv4 layout above. */}}
|
||||
{{$ipv6Form := printf "ipv6-form-%s" $r.Name}}
|
||||
{{$ipv6Fold := printf "ipv6-dhcp-%s" $r.Name}}
|
||||
<h5 class="cfg-subsection-head" style="margin-top:0.75rem">IPv6{{template "field-info" (index $d "ipv6-address")}}</h5>
|
||||
<form id="{{$ipv6Form}}" hx-post="/configure/interfaces/{{.Name}}/ipv6/settings" hx-swap="none">
|
||||
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox" name="forwarding" value="true"
|
||||
{{if and .IPv6 .IPv6.Forwarding}}checked{{end}}>
|
||||
Forwarding{{template "field-info" (index $d "ipv6-forwarding")}}
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox" name="dhcp" value="true" data-fold-target="{{$ipv6Fold}}"
|
||||
{{if .DHCPv6Enabled}}checked{{end}}>
|
||||
DHCPv6 client{{template "field-info" (index $d "ipv6-dhcp")}}
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
|
||||
<input type="checkbox" name="slaac" value="true"
|
||||
{{if and .IPv6 .IPv6.SLAACv6}}checked{{end}}>
|
||||
SLAAC{{template "field-info" (index $d "ipv6-slaac")}}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<details id="{{$ipv6Fold}}" class="cfg-fold" {{if not .DHCPv6Enabled}}hidden{{end}}>
|
||||
<summary>DHCPv6 Settings & Options</summary>
|
||||
<div class="cfg-fold-body">
|
||||
<form hx-post="/configure/interfaces/{{.Name}}/ipv6/dhcp/settings" hx-swap="none">
|
||||
@@ -326,7 +354,6 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{if and .IPv6 .IPv6.Address}}
|
||||
<table class="info-table" style="margin-bottom:0.5rem">
|
||||
@@ -359,6 +386,11 @@
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</form>
|
||||
|
||||
<div class="cfg-card-footer" style="padding-left:0">
|
||||
<button form="{{$ipv6Form}}" type="submit" class="btn btn-primary btn-sm">Save IPv6 Settings</button>
|
||||
<span class="cfg-save-status" data-cfg-status-for="{{$ipv6Form}}"></span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}{{/* HasIP */}}
|
||||
|
||||
@@ -370,14 +402,14 @@
|
||||
<form hx-post="/configure/interfaces/{{.Name}}/bridge-port" hx-swap="none">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Bridge</th>
|
||||
<th>Bridge{{template "field-info" (index $d "bp-bridge")}}</th>
|
||||
<td><input class="cfg-input cfg-input-sm" type="text" name="bridge"
|
||||
value="{{if .BridgePort}}{{.BridgePort.Bridge}}{{end}}" required></td>
|
||||
</tr>
|
||||
{{$bpPath := printf "/ietf-interfaces:interfaces/interface=%s/infix-interfaces:bridge-port" .Name}}
|
||||
{{if .ParentBridgeIs8021Q}}
|
||||
<tr>
|
||||
<th>PVID</th>
|
||||
<th>PVID{{template "field-info" (index $d "bp-pvid")}}</th>
|
||||
<td><input class="cfg-input cfg-input-sm" type="number" name="pvid"
|
||||
{{if and .BridgePort .BridgePort.PVID}}value="{{deref .BridgePort.PVID}}"{{end}}
|
||||
min="1" max="4094" placeholder="—"></td>
|
||||
@@ -385,12 +417,12 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$bpPath}}/pvid&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset PVID to its YANG default?">↺</button>
|
||||
hx-confirm="Reset PVID to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr>
|
||||
<th>Flood unknown</th>
|
||||
<th>Flood unknown{{template "field-info" (index $d "bp-flood")}}</th>
|
||||
<td>
|
||||
<div class="yt-bool-group">
|
||||
<label><input type="checkbox" name="flood-broadcast"
|
||||
@@ -405,11 +437,11 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG defaults"
|
||||
hx-delete="/configure/leaf?path={{$bpPath}}/flood&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset flood flags to YANG defaults?">↺</button>
|
||||
hx-confirm="Reset flood flags to YANG defaults?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Multicast router</th>
|
||||
<th>Multicast router{{template "field-info" (index $d "bp-mc-router")}}</th>
|
||||
<td>
|
||||
{{/* YANG default is auto. */}}
|
||||
{{$mr := "auto"}}{{if and .BridgePort .BridgePort.Multicast .BridgePort.Multicast.Router}}{{$mr = .BridgePort.Multicast.Router}}{{end}}
|
||||
@@ -423,11 +455,11 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$bpPath}}/multicast/router&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset multicast router to YANG default?">↺</button>
|
||||
hx-confirm="Reset multicast router to YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Multicast fast-leave</th>
|
||||
<th>Multicast fast-leave{{template "field-info" (index $d "bp-mc-fast-leave")}}</th>
|
||||
<td>
|
||||
{{/* Hidden companion lets the handler distinguish
|
||||
"form omitted the field" from "user unchecked". */}}
|
||||
@@ -440,7 +472,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$bpPath}}/multicast/fast-leave&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset fast-leave to YANG default?">↺</button>
|
||||
hx-confirm="Reset fast-leave to YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -467,7 +499,7 @@
|
||||
<form hx-post="/configure/interfaces/{{.Name}}/lag-port" hx-swap="none">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>LAG</th>
|
||||
<th>LAG{{template "field-info" (index $d "lp-lag")}}</th>
|
||||
<td><input class="cfg-input cfg-input-sm" type="text" name="lag"
|
||||
value="{{if .LagPort}}{{.LagPort.LAG}}{{end}}" required></td>
|
||||
</tr>
|
||||
@@ -609,7 +641,7 @@
|
||||
<input type="hidden" name="mode" value="{{.WifiMode}}">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Radio</th>
|
||||
<th>Radio{{template "field-info" (index $d "wifi-radio")}}</th>
|
||||
<td>
|
||||
<div class="ks-picker-row">
|
||||
{{template "wizard-radio-picker.html" (dict "Radios" $.WizardWifiRadios "Selected" $curRadio "ID" $radioPickerID)}}
|
||||
@@ -679,12 +711,12 @@
|
||||
{{if and .WiFi .WiFi.AccessPoint}}
|
||||
{{$ap := .WiFi.AccessPoint}}
|
||||
<tr>
|
||||
<th>SSID</th>
|
||||
<th>SSID{{template "field-info" (index $d "wifi-ssid")}}</th>
|
||||
<td><input class="cfg-input cfg-input-sm" type="text" name="ssid"
|
||||
value="{{$ap.SSID}}" maxlength="32" required></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Hidden SSID</th>
|
||||
<th>Hidden SSID{{template "field-info" (index $d "wifi-hidden")}}</th>
|
||||
<td>
|
||||
<label><input type="checkbox" name="hidden"
|
||||
{{if and $ap.Hidden (deref $ap.Hidden)}}checked{{end}}>
|
||||
@@ -692,7 +724,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Security mode</th>
|
||||
<th>Security mode{{template "field-info" (index $d "wifi-sec-mode")}}</th>
|
||||
<td>
|
||||
{{$sm := "wpa2-wpa3-personal"}}{{if and $ap.Security $ap.Security.Mode}}{{$sm = $ap.Security.Mode}}{{end}}
|
||||
<select class="cfg-input cfg-input-sm" name="sec-mode">
|
||||
@@ -705,12 +737,12 @@
|
||||
{{else if and .WiFi .WiFi.Station}}
|
||||
{{$sta := .WiFi.Station}}
|
||||
<tr>
|
||||
<th>SSID</th>
|
||||
<th>SSID{{template "field-info" (index $d "wifi-ssid")}}</th>
|
||||
<td><input class="cfg-input cfg-input-sm" type="text" name="ssid"
|
||||
value="{{$sta.SSID}}" maxlength="32" required></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Security mode</th>
|
||||
<th>Security mode{{template "field-info" (index $d "wifi-sec-mode")}}</th>
|
||||
<td>
|
||||
{{$sm := "auto"}}{{if and $sta.Security $sta.Security.Mode}}{{$sm = $sta.Security.Mode}}{{end}}
|
||||
<select class="cfg-input cfg-input-sm" name="sec-mode">
|
||||
@@ -722,7 +754,7 @@
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr>
|
||||
<th>PSK</th>
|
||||
<th>PSK{{template "field-info" (index $d "wifi-secret")}}</th>
|
||||
<td>
|
||||
<div class="ks-picker-row">
|
||||
{{template "wizard-psk-picker.html" (dict "Keys" $.WizardSymKeys "Selected" $curSecret "ID" $pskPickerID)}}
|
||||
@@ -809,10 +841,27 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="vertical-align:top">Ports</th>
|
||||
<td colspan="2">
|
||||
{{if $r.PortCandidates}}
|
||||
<div class="fw-check-scroll">
|
||||
<div class="fw-check-grid">
|
||||
{{range $r.PortCandidates}}
|
||||
<label><input type="checkbox" name="members" value="{{.}}"
|
||||
{{if index $r.BridgeMemberSet .}}checked{{end}}> {{.}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<span class="text-muted" style="font-size:0.85em">No candidate port interfaces available.</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div class="cfg-card-footer" style="padding-left:0">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save Bridge Settings</button>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save Bridge</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
@@ -821,8 +870,9 @@
|
||||
{{/* STP — own form, save inside the fold-out so the
|
||||
section is self-contained. YANG defaults are
|
||||
rendered inline (value="..." not a placeholder)
|
||||
so the user sees the actual default; the ↺ button
|
||||
drops the candidate value and lets YANG re-apply. */}}
|
||||
so the user sees the actual default; the reset
|
||||
button drops the candidate value and lets YANG
|
||||
re-apply. */}}
|
||||
<form hx-post="/configure/interfaces/{{.Name}}/bridge/stp" hx-swap="none">
|
||||
<details style="margin-top:0.75rem">
|
||||
<summary style="cursor:pointer;font-size:0.9em;color:var(--text-muted);padding:0.25rem 0">
|
||||
@@ -843,7 +893,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/stp/force-protocol&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset force-protocol to YANG default (rstp)?">↺</button>
|
||||
hx-confirm="Reset force-protocol to YANG default (rstp)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -857,7 +907,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/stp/hello-time&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset hello time to YANG default (2)?">↺</button>
|
||||
hx-confirm="Reset hello time to YANG default (2)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -871,7 +921,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/stp/forward-delay&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset forward delay to YANG default (15)?">↺</button>
|
||||
hx-confirm="Reset forward delay to YANG default (15)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -885,7 +935,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/stp/max-age&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset max age to YANG default (20)?">↺</button>
|
||||
hx-confirm="Reset max age to YANG default (20)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -899,7 +949,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/stp/transmit-hold-count&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset transmit hold count to YANG default (6)?">↺</button>
|
||||
hx-confirm="Reset transmit hold count to YANG default (6)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -913,7 +963,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/stp/max-hops&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset max hops to YANG default (20)?">↺</button>
|
||||
hx-confirm="Reset max hops to YANG default (20)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -933,7 +983,7 @@
|
||||
</summary>
|
||||
<table class="info-table" style="margin-top:0.5rem">
|
||||
<tr>
|
||||
<th>Snooping</th>
|
||||
<th>Snooping{{template "field-info" (index $d "mc-snoop")}}</th>
|
||||
<td>
|
||||
<label><input type="checkbox" name="snooping"
|
||||
{{if or (not .Bridge) (not .Bridge.Multicast) (not .Bridge.Multicast.Snooping) (deref .Bridge.Multicast.Snooping)}}checked{{end}}>
|
||||
@@ -943,11 +993,11 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/multicast/snooping&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset snooping to YANG default (true)?">↺</button>
|
||||
hx-confirm="Reset snooping to YANG default (true)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Querier</th>
|
||||
<th>Querier{{template "field-info" (index $d "mc-querier")}}</th>
|
||||
<td>
|
||||
{{$q := "auto"}}{{if and .Bridge .Bridge.Multicast .Bridge.Multicast.Querier}}{{$q = .Bridge.Multicast.Querier}}{{end}}
|
||||
<select class="cfg-input cfg-input-sm" name="querier">
|
||||
@@ -960,11 +1010,11 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/multicast/querier&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset querier to YANG default (auto)?">↺</button>
|
||||
hx-confirm="Reset querier to YANG default (auto)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Query interval (s)</th>
|
||||
<th>Query interval (s){{template "field-info" (index $d "mc-query-int")}}</th>
|
||||
<td>
|
||||
<input class="cfg-input cfg-input-sm" type="number" name="query-interval"
|
||||
value="{{if and .Bridge .Bridge.Multicast .Bridge.Multicast.QueryInterval}}{{deref .Bridge.Multicast.QueryInterval}}{{else}}125{{end}}"
|
||||
@@ -974,7 +1024,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path={{$brPath}}/multicast/query-interval&redirect=/configure/interfaces"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset query interval to YANG default (125)?">↺</button>
|
||||
hx-confirm="Reset query interval to YANG default (125)?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -985,27 +1035,6 @@
|
||||
</details>
|
||||
</form>
|
||||
|
||||
{{/* Bridge Ports */}}
|
||||
<h4 class="cfg-section-head" style="margin-top:1rem">Bridge Ports</h4>
|
||||
{{if $r.PortCandidates}}
|
||||
<form hx-post="/configure/interfaces/{{.Name}}/bridge/members" hx-swap="none">
|
||||
<div class="fw-check-scroll" style="margin-bottom:0.5rem">
|
||||
<div class="fw-check-grid">
|
||||
{{range $r.PortCandidates}}
|
||||
<label><input type="checkbox" name="members" value="{{.}}"
|
||||
{{if index $r.BridgeMemberSet .}}checked{{end}}> {{.}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cfg-card-footer" style="padding-left:0">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save Members</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<p class="text-muted" style="font-size:0.85em">No candidate port interfaces available.</p>
|
||||
{{end}}
|
||||
|
||||
{{/* 802.1Q VLANs — matrix layout: rows = VLAN, columns
|
||||
= bridge ports + self, cells = U/T/-. Click VID to
|
||||
expand the existing per-port checkbox editor. */}}
|
||||
@@ -1810,4 +1839,3 @@
|
||||
|
||||
{{end}}
|
||||
|
||||
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}">ⓘ</span>{{end}}{{end}}
|
||||
|
||||
@@ -74,10 +74,10 @@
|
||||
<th>Format</th>
|
||||
<td>
|
||||
<select class="cfg-input" name="format">
|
||||
{{$fmt := .Format}}
|
||||
{{$fmt := .FormatID}}
|
||||
{{range $.SymKeyFormats}}
|
||||
<option value="{{.Value}}"
|
||||
{{if eq $fmt .Label}}selected{{end}}>{{.Label}}</option>
|
||||
{{if eq $fmt .Value}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
{{define "configure-ntp.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="configure-page">
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form class="info-card"
|
||||
hx-put="/configure/system/ntp"
|
||||
hx-swap="none">
|
||||
<div class="card-header">NTP Servers</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="ntp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Key</th>
|
||||
<th>Address</th>
|
||||
<th class="cfg-input-sm">Port</th>
|
||||
<th>Prefer</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ntp-tbody">
|
||||
{{range $i, $s := .NTP.Servers}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="ntp_name_{{$i}}" value="{{$s.Name}}" required></td>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="ntp_addr_{{$i}}" value="{{$s.UDP.Address}}"></td>
|
||||
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
|
||||
name="ntp_port_{{$i}}" value="{{if $s.UDP.Port}}{{$s.UDP.Port}}{{end}}"
|
||||
placeholder="123"></td>
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" name="ntp_prefer_{{$i}}" {{if $s.Prefer}}checked{{end}}>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row"
|
||||
title="Remove">
|
||||
{{template "icon-trash"}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cfg-card-footer">
|
||||
<button type="button" class="btn-add-row" data-table="ntp-tbody"
|
||||
data-template="ntp">+ Add server</button>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
{{template "configure-toolbar" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -220,4 +220,3 @@
|
||||
|
||||
{{end}}
|
||||
|
||||
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}">ⓘ</span>{{end}}{{end}}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/contact&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset contact to its YANG default?">↺</button>
|
||||
hx-confirm="Reset contact to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -50,7 +50,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/location&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset location to its YANG default?">↺</button>
|
||||
hx-confirm="Reset location to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -60,17 +60,59 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ── Clock ───────────────────────────────────────────────────────── */}}
|
||||
<form class="info-card"
|
||||
hx-post="/configure/system/clock"
|
||||
hx-swap="none">
|
||||
<div class="card-header">Clock</div>
|
||||
<table class="info-table">
|
||||
{{/* ── Date & Time ──────────────────────────────────────────────────
|
||||
One table for all three rows so the value and action columns
|
||||
line up. The two write paths have different semantics (Set time
|
||||
= immediate RPC, Timezone = candidate save), so they're declared
|
||||
as sibling <form>s and bound to the table's inputs / buttons via
|
||||
the HTML5 form="…" attribute. The System time row's
|
||||
data-server-time seeds the JS-driven live tick (initDatetimePicker
|
||||
in app.js). */}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">Date & Time</div>
|
||||
|
||||
<form id="cfg-clock-now" hx-post="/maintenance/system/datetime"
|
||||
hx-swap="none"></form>
|
||||
|
||||
<table class="info-table info-table-fixed">
|
||||
<colgroup>
|
||||
<col style="width:9rem">
|
||||
<col>
|
||||
<col style="width:5.5rem">
|
||||
</colgroup>
|
||||
<tr>
|
||||
<th>System time</th>
|
||||
<td colspan="2">
|
||||
{{/* Span pads itself to match the Set time input's border+left
|
||||
padding (1px + 0.6rem), so plain text and the input text
|
||||
start at the same x-position. */}}
|
||||
{{if .CurrentDatetime}}<span id="sc-system-time" data-server-time="{{.CurrentDatetime}}" style="display:inline-block;padding-left:calc(0.6rem + 1px);"></span>{{else}}<span class="text-muted">unavailable</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Set time{{template "field-info" "Manually set the device system clock. The time you enter is interpreted in your browser's local timezone and converted to UTC on submit."}}</th>
|
||||
<td>
|
||||
<input type="datetime-local" id="sc-dt-input" name="datetime"
|
||||
form="cfg-clock-now"
|
||||
class="cfg-input" step="1" required>
|
||||
</td>
|
||||
<td class="cfg-reset-col">
|
||||
<button type="submit" form="cfg-clock-now"
|
||||
class="btn btn-primary btn-sm"
|
||||
title="Set the system clock immediately.">Set</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Timezone{{template "field-info" (index $d "timezone")}}</th>
|
||||
<td>
|
||||
<select class="cfg-input" name="timezone">
|
||||
{{$tz := .Timezone}}
|
||||
{{$tz := .Timezone}}
|
||||
<select class="cfg-input" name="timezone" form="cfg-clock-tz">
|
||||
{{/* The IANA YANG enum has no UTC / Etc-UTC entry, so when
|
||||
timezone-name isn't configured we surface that explicitly
|
||||
instead of letting the browser default to the
|
||||
alphabetically-first option. Infix interprets an unset
|
||||
leaf as UTC. */}}
|
||||
{{if eq $tz ""}}<option value="" selected>UTC (default)</option>{{end}}
|
||||
{{range .TimezoneOptions}}<option {{if eq . $tz}}selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</td>
|
||||
@@ -78,134 +120,18 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/clock/timezone-name&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset timezone to its YANG default?">↺</button>
|
||||
hx-confirm="Reset timezone to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="cfg-card-footer">
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ── NTP servers ──────────────────────────────────────────────────── */}}
|
||||
<form class="info-card info-grid-span-2"
|
||||
hx-put="/configure/system/ntp"
|
||||
hx-swap="none">
|
||||
<div class="card-header">NTP Servers</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="ntp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Key</th>
|
||||
<th>Address</th>
|
||||
<th class="cfg-input-sm">Port</th>
|
||||
<th>Prefer</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="ntp-tbody">
|
||||
{{range $i, $s := .NTP.Servers}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="ntp_name_{{$i}}" value="{{$s.Name}}" required></td>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="ntp_addr_{{$i}}" value="{{$s.UDP.Address}}"></td>
|
||||
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
|
||||
name="ntp_port_{{$i}}" value="{{if $s.UDP.Port}}{{$s.UDP.Port}}{{end}}"
|
||||
placeholder="123"></td>
|
||||
<td style="text-align:center">
|
||||
<input type="checkbox" name="ntp_prefer_{{$i}}" {{if $s.Prefer}}checked{{end}}>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row"
|
||||
title="Remove">
|
||||
{{template "icon-trash"}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cfg-card-footer">
|
||||
<button type="button" class="btn-add-row" data-table="ntp-tbody"
|
||||
data-template="ntp">+ Add server</button>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{/* ── DNS ─────────────────────────────────────────────────────────── */}}
|
||||
<form class="info-card info-grid-span-2"
|
||||
hx-put="/configure/system/dns"
|
||||
hx-swap="none">
|
||||
<div class="card-header">DNS</div>
|
||||
|
||||
<div class="section-subtitle">Search domains</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="dns-search-table">
|
||||
<thead>
|
||||
<tr><th>Domain</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody id="dns-search-tbody">
|
||||
{{range $i, $d := .DNS.Search}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_search_{{$i}}" value="{{$d}}"></td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
|
||||
{{template "icon-trash"}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="padding: 0 0.75rem;">
|
||||
<button type="button" class="btn-add-row" data-table="dns-search-tbody"
|
||||
data-template="dns-search">+ Add domain</button>
|
||||
</div>
|
||||
|
||||
<div class="section-subtitle" style="margin-top:0.75rem">Servers</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table cfg-table" id="dns-server-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Key</th>
|
||||
<th>Address</th>
|
||||
<th class="cfg-input-sm">Port</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dns-server-tbody">
|
||||
{{range $i, $s := .DNS.Servers}}
|
||||
<tr>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_name_{{$i}}" value="{{$s.Name}}" required></td>
|
||||
<td><input class="cfg-input" type="text"
|
||||
name="dns_addr_{{$i}}" value="{{$s.UDPAndTCP.Address}}"></td>
|
||||
<td><input class="cfg-input cfg-input-sm" type="number" min="1" max="65535"
|
||||
name="dns_port_{{$i}}" value="{{if $s.UDPAndTCP.Port}}{{$s.UDPAndTCP.Port}}{{end}}"
|
||||
placeholder="53"></td>
|
||||
<td>
|
||||
<button type="button" class="btn-icon btn-icon-danger cfg-delete-row" title="Remove">
|
||||
{{template "icon-trash"}}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="cfg-card-footer">
|
||||
<button type="button" class="btn-add-row" data-table="dns-server-tbody"
|
||||
data-template="dns-server">+ Add server</button>
|
||||
<button class="btn btn-primary" type="submit">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
<form id="cfg-clock-tz" hx-post="/configure/system/clock" hx-swap="none">
|
||||
<div class="cfg-card-footer">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
<span class="cfg-save-status"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{/* ── Preferences ─────────────────────────────────────────────────── */}}
|
||||
<form class="info-card info-grid-span-2"
|
||||
@@ -226,7 +152,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/infix-system:text-editor&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset text editor to its YANG default?">↺</button>
|
||||
hx-confirm="Reset text editor to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -239,7 +165,7 @@
|
||||
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
|
||||
hx-delete="/configure/leaf?path=/ietf-system:system/infix-system:motd-banner&redirect=/configure/system"
|
||||
hx-swap="none"
|
||||
hx-confirm="Reset login banner to its YANG default?">↺</button>
|
||||
hx-confirm="Reset login banner to its YANG default?">{{template "icon-reset"}}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -256,5 +182,3 @@
|
||||
|
||||
{{end}}
|
||||
|
||||
{{/* ── ⓘ tooltip helper ─────────────────────────────────────────────────── */}}
|
||||
{{define "field-info"}}{{if .}} <span class="field-info" data-tip="{{.}}">ⓘ</span>{{end}}{{end}}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{if .Location}}<tr><th>Location</th><td>{{.Location}}</td></tr>{{end}}
|
||||
{{if .OSName}}<tr><th>OS</th><td>{{.OSName}} {{.OSVersion}}</td></tr>{{end}}
|
||||
{{if .Machine}}<tr><th>Architecture</th><td>{{.Machine}}</td></tr>{{end}}
|
||||
{{if .Firmware}}<tr><th>Boot Partition</th><td>{{.Firmware}}</td></tr>{{end}}
|
||||
{{if .Software}}<tr><th>Boot Partition</th><td>{{.Software}}</td></tr>{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@
|
||||
{{range .Disks}}
|
||||
<div class="disk-item">
|
||||
<div class="disk-item-header">
|
||||
<span class="disk-mount">{{.Mount}}</span>
|
||||
<span class="disk-mount">{{.Mount}}{{if .ReadOnly}} <span class="disk-tag">read-only</span>{{end}}</span>
|
||||
<span class="num disk-pct">{{.Percent}}%</span>
|
||||
</div>
|
||||
<div class="health-bar-track">
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
{{define "firmware.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Message}}
|
||||
<div class="alert alert-info">{{.Message}}</div>
|
||||
{{end}}
|
||||
|
||||
{{$sseURL := "/firmware/progress"}}{{if .AutoReboot}}{{$sseURL = "/firmware/progress?auto-reboot=1"}}{{end}}
|
||||
|
||||
{{if .Installing}}
|
||||
<section id="fw-progress-card" class="info-card" data-sse-src="{{$sseURL}}">
|
||||
{{template "fw-progress-body" .}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div class="fw-install-grid">
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Software</div>
|
||||
{{if .BootOrder}}
|
||||
<div class="fw-boot-order-row">
|
||||
<span class="fw-boot-order-label">Boot order</span>
|
||||
<span id="fw-boot-slots" class="fw-boot-slots">
|
||||
{{range .BootOrder}}<span class="badge badge-neutral fw-boot-badge" draggable="true" data-slot="{{.}}">{{.}}</span>{{end}}
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
id="fw-boot-save-btn">Set</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm"
|
||||
id="fw-boot-reset-btn"
|
||||
title="Restore current device boot order">↺</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Slots}}
|
||||
<div class="fw-slot-list">
|
||||
{{range .Slots}}
|
||||
<div class="fw-slot-item">
|
||||
<div class="fw-slot-primary">
|
||||
<span class="fw-slot-name">{{.Name}}</span>
|
||||
{{if .Booted}}<span class="badge badge-accept">booted</span>
|
||||
{{else}}<span class="badge badge-neutral">{{.State}}</span>{{end}}
|
||||
</div>
|
||||
<div class="fw-slot-version">{{if .Version}}{{.Version}}{{else}}—{{end}}</div>
|
||||
{{if .InstallDate}}<div class="fw-slot-date">{{.InstallDate}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No software information available.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Install from URL</div>
|
||||
<div class="card-body">
|
||||
<p class="fw-help-text">
|
||||
Get the latest firmware from
|
||||
<a href="https://github.com/kernelkit/infix/releases/latest" target="_blank" rel="noopener">GitHub Releases</a>.
|
||||
Download the <code>.tar.gz</code> tarball, extract the <code>.pkg</code> file, and serve it over FTP/HTTP.
|
||||
</p>
|
||||
<details class="fw-hint">
|
||||
<summary>How to host the firmware from your PC</summary>
|
||||
<div class="fw-hint-body">
|
||||
<p>Extract and serve the package with Python (Linux/macOS/Windows):</p>
|
||||
<code class="fw-hint-code">tar xf infix-{{if .Machine}}{{.Machine}}{{else}}aarch64{{end}}-vVERSION.tar.gz
|
||||
python3 -m http.server 8080</code>
|
||||
<p>Then enter the URL below pointing at the <code>.pkg</code> file on your machine.</p>
|
||||
</div>
|
||||
</details>
|
||||
<form hx-post="/firmware/install" hx-swap="none" class="firmware-form">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<div class="form-group">
|
||||
<label for="fw-url">Firmware Package URL (.pkg)</label>
|
||||
<input type="url" id="fw-url" name="url"
|
||||
placeholder="http://192.168.1.10:8080/infix-{{if .Machine}}{{.Machine}}{{else}}aarch64{{end}}-vVERSION.pkg"
|
||||
list="fw-url-history" autocomplete="off" required>
|
||||
<datalist id="fw-url-history"></datalist>
|
||||
</div>
|
||||
<label class="fw-checkbox-row">
|
||||
<input type="checkbox" name="auto-reboot" value="1" id="fw-auto-reboot">
|
||||
Reboot after successful firmware upgrade
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary"
|
||||
hx-confirm="Install firmware from this URL?"
|
||||
{{if .Installer}}{{if .Installer.Active}}disabled{{end}}{{end}}>Install</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Upload & Install</div>
|
||||
<div class="card-body">
|
||||
<p class="fw-help-text">
|
||||
Select a <code>.pkg</code> firmware file from your computer to upload and install directly.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="fw-file">Firmware Package (.pkg)</label>
|
||||
<input type="file" id="fw-file" accept=".pkg" class="cfg-input" required>
|
||||
</div>
|
||||
<label class="fw-checkbox-row">
|
||||
<input type="checkbox" id="fw-upload-auto-reboot">
|
||||
Reboot after successful firmware upgrade
|
||||
</label>
|
||||
<div style="margin-top:0.75rem">
|
||||
<button type="button" class="btn btn-primary" id="fw-upload-btn"
|
||||
data-sse-url="{{$sseURL}}"
|
||||
{{if and .Installer .Installer.Active}}disabled{{end}}>
|
||||
Upload & Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "fw-progress-body"}}
|
||||
{{if and .Installer .Installer.Done}}
|
||||
{{if .Installer.Success}}
|
||||
<div class="card-header">Install Complete</div>
|
||||
<div class="card-body fw-result-body">
|
||||
<div class="fw-result fw-result-ok">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
<span>Firmware installed successfully.</span>
|
||||
</div>
|
||||
{{if not .AutoReboot}}
|
||||
<div class="fw-result-actions">
|
||||
<button class="btn btn-primary fw-reboot-btn"
|
||||
hx-post="/reboot"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML">Reboot to activate</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-header">Install Failed</div>
|
||||
<div class="card-body">
|
||||
<div class="fw-result fw-result-err">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>
|
||||
<span>{{.Installer.LastError}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="card-header">Install in Progress</div>
|
||||
<div class="card-body">
|
||||
<div class="progress-bar-wrap progress-bar-wrap--flush">
|
||||
<div class="progress-bar" data-progress="{{if .Installer}}{{.Installer.Percentage}}{{end}}"></div>
|
||||
</div>
|
||||
<p class="progress-text">{{if .Installer}}{{.Installer.Percentage}}% — {{.Installer.Message}}{{else}}Installing…{{end}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -17,10 +17,7 @@
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
{{template "icon-user-lg"}}
|
||||
<h1>Login</h1>
|
||||
</div>
|
||||
{{if .Error}}
|
||||
@@ -47,20 +44,9 @@
|
||||
</form>
|
||||
</div>
|
||||
<button id="login-theme-toggle" class="login-theme-btn" aria-label="Toggle theme">
|
||||
<svg id="lti-auto" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9"></circle>
|
||||
<path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor"></path>
|
||||
</svg>
|
||||
<svg id="lti-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg id="lti-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
{{template "icon-login-theme-auto"}}
|
||||
{{template "icon-login-theme-light"}}
|
||||
{{template "icon-login-theme-dark"}}
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
{{define "software.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
{{if .Error}}
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Message}}
|
||||
<div class="alert alert-info">{{.Message}}</div>
|
||||
{{end}}
|
||||
|
||||
{{$sseURL := "/software/progress"}}{{if .AutoReboot}}{{$sseURL = "/software/progress?auto-reboot=1"}}{{end}}
|
||||
|
||||
{{if .Installing}}
|
||||
<section id="sw-progress-card" class="info-card" data-sse-src="{{$sseURL}}">
|
||||
{{template "sw-progress-body" .}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div class="sw-install-grid">
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Software</div>
|
||||
{{if .BootOrder}}
|
||||
<div class="sw-boot-order-row">
|
||||
<span class="sw-boot-order-label">Boot order</span>
|
||||
<span id="sw-boot-slots" class="sw-boot-slots">
|
||||
{{range .BootOrder}}<span class="badge badge-neutral sw-boot-badge" draggable="true" data-slot="{{.}}">{{.}}</span>{{end}}
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline"
|
||||
id="sw-boot-save-btn">Set</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm"
|
||||
id="sw-boot-reset-btn"
|
||||
title="Restore current device boot order">{{template "icon-reset"}}</button>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Slots}}
|
||||
<div class="sw-slot-list">
|
||||
{{range .Slots}}
|
||||
<div class="sw-slot-item">
|
||||
<div class="sw-slot-primary">
|
||||
<span class="sw-slot-name">{{.Name}}</span>
|
||||
{{if .Booted}}<span class="badge badge-accept">booted</span>
|
||||
{{else}}<span class="badge badge-neutral">{{.State}}</span>{{end}}
|
||||
</div>
|
||||
<div class="sw-slot-version">{{if .Version}}{{.Version}}{{else}}—{{end}}</div>
|
||||
{{if .InstallDate}}<div class="sw-slot-date">{{.InstallDate}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No software information available.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Install from URL</div>
|
||||
<div class="card-body">
|
||||
<p class="sw-help-text">
|
||||
Fetch a <code>.pkg</code> software bundle from a URL and install directly.
|
||||
</p>
|
||||
<form hx-post="/software/install" hx-swap="none" class="software-form">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<div class="form-group">
|
||||
<label for="sw-url">Software Bundle URL (.pkg)</label>
|
||||
<input type="url" id="sw-url" name="url"
|
||||
list="sw-url-history" autocomplete="off" required>
|
||||
<datalist id="sw-url-history"></datalist>
|
||||
</div>
|
||||
<label class="sw-checkbox-row">
|
||||
<input type="checkbox" name="auto-reboot" value="1" id="sw-auto-reboot">
|
||||
Reboot after successful upgrade
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary"
|
||||
hx-confirm="Install software bundle from this URL?"
|
||||
{{if .Installer}}{{if .Installer.Active}}disabled{{end}}{{end}}>Install</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Upload & Install</div>
|
||||
<div class="card-body">
|
||||
<p class="sw-help-text">
|
||||
Select a <code>.pkg</code> software bundle from your computer to upload and install directly.
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label for="sw-file">Software Bundle (.pkg)</label>
|
||||
<input type="file" id="sw-file" accept=".pkg" class="cfg-input" required>
|
||||
</div>
|
||||
<label class="sw-checkbox-row">
|
||||
<input type="checkbox" id="sw-upload-auto-reboot">
|
||||
Reboot after successful upgrade
|
||||
</label>
|
||||
<div style="margin-top:0.75rem">
|
||||
<button type="button" class="btn btn-primary" id="sw-upload-btn"
|
||||
data-sse-url="{{$sseURL}}"
|
||||
{{if and .Installer .Installer.Active}}disabled{{end}}>
|
||||
Upload & Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "sw-progress-body"}}
|
||||
{{if and .Installer .Installer.Done}}
|
||||
{{if .Installer.Success}}
|
||||
<div class="card-header">Install Complete</div>
|
||||
<div class="card-body sw-result-body">
|
||||
<div class="sw-result sw-result-ok">
|
||||
{{template "icon-check-lg"}}
|
||||
<span>Software installed successfully.</span>
|
||||
</div>
|
||||
{{if not .AutoReboot}}
|
||||
<div class="sw-result-actions">
|
||||
<button class="btn btn-primary sw-reboot-btn"
|
||||
hx-post="/reboot"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML">Reboot to activate</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card-header">Install Failed</div>
|
||||
<div class="card-body">
|
||||
<div class="sw-result sw-result-err">
|
||||
{{template "icon-circle-x"}}
|
||||
<span>{{.Installer.LastError}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="card-header">Install in Progress</div>
|
||||
<div class="card-body">
|
||||
<div class="progress-bar-wrap progress-bar-wrap--flush">
|
||||
<div class="progress-bar" data-progress="{{if .Installer}}{{.Installer.Percentage}}{{end}}"></div>
|
||||
</div>
|
||||
<p class="progress-text">{{if .Installer}}{{.Installer.Percentage}}% — {{.Installer.Message}}{{else}}Installing…{{end}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -6,67 +6,43 @@
|
||||
<div class="info-grid">
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Reboot</div>
|
||||
<div class="card-body sc-action-body">
|
||||
<p class="sc-desc">Restart the device. The running configuration is preserved.
|
||||
Any unsaved changes (running config not yet copied to startup) will be lost on reboot.</p>
|
||||
<div>
|
||||
<div class="card-header">Power</div>
|
||||
<div class="sc-danger-body">
|
||||
|
||||
<div class="sc-danger-item">
|
||||
<div class="sc-danger-text">
|
||||
<strong>Reboot{{template "field-info" "Restart the device. The running configuration is preserved. Any unsaved changes (running config not yet copied to startup) will be lost on reboot."}}</strong>
|
||||
</div>
|
||||
<button class="btn btn-primary"
|
||||
hx-post="/maintenance/system/reboot"
|
||||
hx-confirm="Reboot the device?"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML">Reboot</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Shutdown</div>
|
||||
<div class="card-body sc-action-body">
|
||||
<p class="sc-desc">Power off the device. Physical access is required to bring it back online.</p>
|
||||
<div>
|
||||
<div class="sc-danger-sep"></div>
|
||||
|
||||
<div class="sc-danger-item">
|
||||
<div class="sc-danger-text">
|
||||
<strong>Shutdown{{template "field-info" "Power off the device. Physical access is required to bring it back online."}}</strong>
|
||||
</div>
|
||||
<button class="btn btn-secondary"
|
||||
hx-post="/maintenance/system/shutdown"
|
||||
hx-confirm="Shut down the device?"
|
||||
hx-target="#content"
|
||||
hx-swap="innerHTML">Shut down</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card">
|
||||
<div class="card-header">Date & Time</div>
|
||||
<div class="card-body sc-action-body">
|
||||
<p class="sc-desc">
|
||||
{{if .CurrentDatetime}}Device clock: <strong>{{.CurrentDatetime}}</strong>.{{else}}Device clock unavailable.{{end}}
|
||||
</p>
|
||||
<form hx-post="/maintenance/system/datetime"
|
||||
hx-target="#sc-dt-result"
|
||||
hx-swap="innerHTML">
|
||||
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
|
||||
<div class="sc-dt-row">
|
||||
<input type="datetime-local" id="sc-dt-input" name="datetime"
|
||||
class="cfg-input" required>
|
||||
<button type="button" id="sc-sync-time-btn"
|
||||
class="btn btn-outline btn-sm">Browser time</button>
|
||||
</div>
|
||||
<div id="sc-dt-result"></div>
|
||||
<div style="margin-top:0.75rem">
|
||||
<button type="submit" class="btn btn-primary">Set time</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="info-card info-grid-full sc-danger-card">
|
||||
<div class="card-header">Factory Reset</div>
|
||||
<section class="info-card sc-danger-card">
|
||||
<div class="card-header">Danger Zone</div>
|
||||
<div class="sc-danger-body">
|
||||
|
||||
<div class="sc-danger-item">
|
||||
<div class="sc-danger-text">
|
||||
<strong>Reset running config</strong>
|
||||
<p class="sc-desc">Replaces the running configuration with factory defaults.
|
||||
No reboot — takes effect immediately. Use the persistent notification to save to startup.</p>
|
||||
<strong>Reset running config{{template "field-info" "Replaces the running configuration with factory defaults. No reboot — takes effect immediately. Use the persistent notification to save to startup."}}</strong>
|
||||
<div id="sc-fd-result"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger-outline"
|
||||
@@ -80,9 +56,7 @@
|
||||
|
||||
<div class="sc-danger-item">
|
||||
<div class="sc-danger-text">
|
||||
<strong>Factory reset & reboot</strong>
|
||||
<p class="sc-desc">Wipes all configuration and non-volatile storage, then reboots.
|
||||
The device may become unreachable on the network. This cannot be undone.</p>
|
||||
<strong>Factory reset{{template "field-info" "Wipes all configuration and non-volatile storage, then reboots. The device may become unreachable on the network. This cannot be undone."}}</strong>
|
||||
</div>
|
||||
<button class="btn btn-danger-outline"
|
||||
hx-post="/maintenance/system/factory-reset"
|
||||
|
||||
@@ -17,15 +17,12 @@
|
||||
|
||||
{{/* ── Left pane: navigation tree ───────────────────────── */}}
|
||||
<div class="yang-tree-pane info-card">
|
||||
<div class="card-header">{{if .ReadOnly}}Status{{else}}Advanced{{end}}</div>
|
||||
<div class="card-header">{{if .ReadOnly}}View all{{else}}Edit all{{end}}</div>
|
||||
<ul class="yang-tree">
|
||||
<li>
|
||||
<details class="yt-node" open>
|
||||
<summary class="yt-label">
|
||||
<svg class="yt-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
{{template "icon-yt-chevron"}}
|
||||
<span class="yt-name yt-root">/</span>
|
||||
</summary>
|
||||
<ul class="yt-children">
|
||||
@@ -45,12 +42,7 @@
|
||||
{{end}}>
|
||||
{{if not .InitialPath}}
|
||||
<div class="yang-detail-empty">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||||
style="margin-bottom:.75rem;color:var(--fg-muted)">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
{{template "icon-search-empty"}}
|
||||
<p>Select a node to inspect.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||