mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: final touches
- Add missing Configure -> shortcuts - For shortcuts into YANG tree, expand and mark the tree node - Revamp the /mdns status page to mimic /ntp => more vertical space Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -74,13 +76,37 @@ func (h *ConfigureHandler) Enter(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// applyError surfaces a failed datastore copy from Apply / Apply&Save / Save.
|
||||
// A RESTCONF error means the device answered and rejected the config — almost
|
||||
// always a validation failure (e.g. deleting an interface other nodes still
|
||||
// reference). The device is reachable, so report the reason inline via an
|
||||
// HX-Trigger and a 200, NOT a 502: the front-end's connection monitor reads
|
||||
// 502/503/504 as "device disconnected", which is exactly wrong here. Only a
|
||||
// transport failure (no RESTCONF response at all) is a real gateway error.
|
||||
func applyError(w http.ResponseWriter, op string, err error) {
|
||||
log.Printf("configure %s: %v", op, err)
|
||||
var re *restconf.Error
|
||||
if errors.As(err, &re) {
|
||||
// parseError always populates Message (server text, else HTTP status
|
||||
// text), so a single fallback covers the otherwise-unreachable empty.
|
||||
msg := re.Message
|
||||
if msg == "" {
|
||||
msg = "the device rejected the configuration"
|
||||
}
|
||||
b, _ := json.Marshal(msg)
|
||||
w.Header().Set("HX-Trigger", `{"cfgApplyError":`+string(b)+`}`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Could not reach the device: "+err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// Apply copies candidate → running, activating all staged changes atomically.
|
||||
// Sets the cfg-unsaved cookie so the persistent banner appears until startup is saved.
|
||||
// POST /configure/apply
|
||||
func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil {
|
||||
log.Printf("configure apply: %v", err)
|
||||
http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway)
|
||||
applyError(w, "apply", err)
|
||||
return
|
||||
}
|
||||
setCfgUnsaved(w)
|
||||
@@ -104,13 +130,11 @@ func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /configure/apply-and-save
|
||||
func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil {
|
||||
log.Printf("configure apply-and-save: %v", err)
|
||||
http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway)
|
||||
applyError(w, "apply-and-save", err)
|
||||
return
|
||||
}
|
||||
if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil {
|
||||
log.Printf("configure apply-and-save (save): %v", err)
|
||||
http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway)
|
||||
applyError(w, "apply-and-save (save)", err)
|
||||
return
|
||||
}
|
||||
clearCfgUnsaved(w)
|
||||
@@ -142,8 +166,7 @@ func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) {
|
||||
// POST /configure/save
|
||||
func (h *ConfigureHandler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil {
|
||||
log.Printf("configure save: %v", err)
|
||||
http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway)
|
||||
applyError(w, "save", err)
|
||||
return
|
||||
}
|
||||
clearCfgUnsaved(w)
|
||||
|
||||
@@ -141,6 +141,11 @@ type cfgIfaceRow struct {
|
||||
// foldout auto-expands to reveal the freshly-inferred options. False
|
||||
// on the normal page render, so foldouts stay collapsed there.
|
||||
JustSaved bool
|
||||
// SavedMsg carries a confirmation rendered inline in the block footer on
|
||||
// a post-save re-render. The block is swapped via outerHTML, so the
|
||||
// triggering form (and its status span) is gone by the time any JS event
|
||||
// fires — server-rendering the confirmation is the only reliable place.
|
||||
SavedMsg string
|
||||
}
|
||||
|
||||
// ifaceRadioMirror is the subset of wifi-radio fields we expose on the
|
||||
@@ -1130,6 +1135,15 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h
|
||||
renderSaveError(w, fmt.Errorf("loopback interface cannot be deleted"))
|
||||
return
|
||||
}
|
||||
// Clear other interfaces' references to this one first: detach enslaved
|
||||
// bridge/LAG members, and refuse outright if a VLAN is stacked on top.
|
||||
// Otherwise the dangling leafref fails the next Apply with an opaque
|
||||
// "Invalid input data" and no hint which node broke.
|
||||
if err := h.resolveDependents(r.Context(), name); err != nil {
|
||||
log.Printf("configure interfaces %s resolve dependents: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
if err := h.RC.Delete(r.Context(), ifacePath(name)); err != nil && !restconf.IsNotFound(err) {
|
||||
log.Printf("configure interfaces %s delete: %v", name, err)
|
||||
renderSaveError(w, err)
|
||||
@@ -1138,6 +1152,56 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h
|
||||
renderSavedRedirect(w, name+" deleted", "/configure/interfaces")
|
||||
}
|
||||
|
||||
// resolveDependents prepares the named interface for deletion by clearing every
|
||||
// other interface's reference to it, so the delete doesn't leave a dangling
|
||||
// leafref that fails the next Apply with an opaque "Invalid input data" and no
|
||||
// hint which node broke.
|
||||
//
|
||||
// - Enslaved bridge/LAG members are detached; they survive as standalone
|
||||
// interfaces, only the membership is dropped.
|
||||
// - VLANs stacked on the master via lower-layer-if can't survive without it,
|
||||
// so rather than silently deleting an interface the user didn't name, the
|
||||
// delete is refused and the VLANs named — removing them first is the user's
|
||||
// call.
|
||||
//
|
||||
// The VLAN check runs before any detach, so a refused delete leaves the
|
||||
// candidate untouched.
|
||||
func (h *ConfigureInterfacesHandler) resolveDependents(ctx context.Context, master string) error {
|
||||
ifaces, err := h.fetchAllInterfaces(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var vlans []string
|
||||
for _, iface := range ifaces {
|
||||
if iface.Vlan != nil && iface.Vlan.LowerLayerIf == master {
|
||||
vlans = append(vlans, iface.Name)
|
||||
}
|
||||
}
|
||||
if len(vlans) > 0 {
|
||||
noun, verb, pron := "VLAN", "is", "it"
|
||||
if len(vlans) > 1 {
|
||||
noun, verb, pron = "VLANs", "are", "them"
|
||||
}
|
||||
return fmt.Errorf("cannot delete %s: %s %s %s built on it — delete %s first",
|
||||
master, noun, strings.Join(vlans, ", "), verb, pron)
|
||||
}
|
||||
|
||||
for _, iface := range ifaces {
|
||||
switch {
|
||||
case iface.BridgePort != nil && iface.BridgePort.Bridge == master:
|
||||
if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:bridge-port"); err != nil {
|
||||
return fmt.Errorf("detach %s from bridge %s: %w", iface.Name, master, err)
|
||||
}
|
||||
case iface.LagPort != nil && iface.LagPort.LAG == master:
|
||||
if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:lag-port"); err != nil {
|
||||
return fmt.Errorf("detach %s from lag %s: %w", iface.Name, master, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1149,12 +1213,14 @@ func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http.
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
enabled := r.FormValue("enabled") != "false"
|
||||
// PATCH on a list element needs the entry wrapped in a single-element
|
||||
// array, not a bare object — a bare object fails YANG validation (LY_EVALID).
|
||||
body := map[string]any{
|
||||
"ietf-interfaces:interface": map[string]any{
|
||||
"ietf-interfaces:interface": []map[string]any{{
|
||||
"name": name,
|
||||
"enabled": enabled,
|
||||
"description": strings.TrimSpace(r.FormValue("description")),
|
||||
},
|
||||
}},
|
||||
}
|
||||
if err := h.RC.Patch(r.Context(), ifacePath(name), body); err != nil {
|
||||
log.Printf("configure interfaces %s general: %v", name, err)
|
||||
@@ -2153,7 +2219,7 @@ func (h *ConfigureInterfacesHandler) addAddr(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
// Re-render the IP block in place so the new address appears without
|
||||
// collapsing the interface foldout — ready to add another straight away.
|
||||
h.renderIPBlock(w, r, name, famCap, frag)
|
||||
h.renderIPBlock(w, r, name, famCap, frag, "Address added")
|
||||
}
|
||||
|
||||
func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.Request, family, famCap, frag string) {
|
||||
@@ -2165,7 +2231,7 @@ func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.R
|
||||
renderSaveError(w, err)
|
||||
return
|
||||
}
|
||||
h.renderIPBlock(w, r, name, famCap, frag)
|
||||
h.renderIPBlock(w, r, name, famCap, frag, "Address removed")
|
||||
}
|
||||
|
||||
// SaveIPv4Settings PATCHes the per-interface IPv4 group settings — forwarding
|
||||
@@ -2248,7 +2314,7 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht
|
||||
// without collapsing the page. fragName empty → fall back to a toast
|
||||
// (e.g. families whose block fragment isn't wired up yet).
|
||||
if fragName != "" {
|
||||
h.renderIPBlock(w, r, name, family, fragName)
|
||||
h.renderIPBlock(w, r, name, family, fragName, family+" settings saved")
|
||||
return
|
||||
}
|
||||
renderSaved(w, family+" settings saved")
|
||||
@@ -2258,10 +2324,10 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht
|
||||
// fresh candidate. Falls back to a plain saved-toast if the interface or
|
||||
// schema can't be read, so a save is never reported as failed just because
|
||||
// the in-place refresh couldn't be built.
|
||||
func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName string) {
|
||||
func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName, savedMsg string) {
|
||||
ifaces, err := h.fetchAllInterfaces(r.Context())
|
||||
if err != nil {
|
||||
renderSaved(w, family+" settings saved")
|
||||
renderSaved(w, savedMsg)
|
||||
return
|
||||
}
|
||||
rows := h.buildRows(ifaces, nil)
|
||||
@@ -2273,7 +2339,7 @@ func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *htt
|
||||
}
|
||||
}
|
||||
if row == nil {
|
||||
renderSaved(w, family+" settings saved")
|
||||
renderSaved(w, savedMsg)
|
||||
return
|
||||
}
|
||||
if mgr := h.Schema.Manager(); mgr != nil {
|
||||
@@ -2293,8 +2359,12 @@ func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *htt
|
||||
}
|
||||
}
|
||||
row.JustSaved = true // expand the DHCP foldout to reveal inferred options
|
||||
// Keep the cfgSaved toast behaviour even though we swap the block.
|
||||
w.Header().Set("HX-Trigger", `{"cfgSaved":"`+family+` settings saved"}`)
|
||||
row.SavedMsg = savedMsg
|
||||
// The block is swapped via outerHTML, so the confirmation is rendered into
|
||||
// the block footer (above) rather than chased by JS across the swap; the
|
||||
// log-only event just records it in the Configure activity panel.
|
||||
msgJSON, _ := json.Marshal(savedMsg)
|
||||
w.Header().Set("HX-Trigger", `{"cfgLogged":`+string(msgJSON)+`}`)
|
||||
if err := h.Template.ExecuteTemplate(w, fragName, row); err != nil {
|
||||
log.Printf("configure interfaces %s: render %s: %v", name, fragName, err)
|
||||
}
|
||||
|
||||
@@ -3020,6 +3020,14 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); }
|
||||
background: var(--border-subtle);
|
||||
color: var(--primary);
|
||||
}
|
||||
/* Currently-selected node: set on click and when arriving via a status-page
|
||||
"Configure →" deep link, so the left tree shows where the right pane is. */
|
||||
.yt-label.yt-active {
|
||||
background: var(--border-subtle);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
box-shadow: inset 2px 0 0 var(--primary);
|
||||
}
|
||||
|
||||
/* Chevron rotates on open */
|
||||
.yt-chevron {
|
||||
|
||||
+121
-3
@@ -1679,20 +1679,33 @@
|
||||
// ─── Confirm dialog ────────────────────────────────────────────────────────
|
||||
// openModal(message, onConfirm) shows the shared <dialog> and calls onConfirm
|
||||
// if the user clicks Confirm, or does nothing on Cancel.
|
||||
function openModal(message, onConfirm) {
|
||||
// openModal shows the shared confirm dialog. With opts.alert it becomes a
|
||||
// single-button alert (one "Dismiss", no Cancel) for errors the user can only
|
||||
// acknowledge — e.g. a rejected Apply. The Cancel button and OK label are
|
||||
// restored on close so the dialog returns to confirm() defaults.
|
||||
function openModal(message, onConfirm, opts) {
|
||||
opts = opts || {};
|
||||
var dlg = document.getElementById('confirm-dialog');
|
||||
if (!dlg) { // fallback if dialog missing
|
||||
if (opts.alert) window.alert(message);
|
||||
else if (onConfirm) onConfirm();
|
||||
return;
|
||||
}
|
||||
var msg = document.getElementById('dialog-message');
|
||||
var ok = document.getElementById('dialog-confirm');
|
||||
var no = document.getElementById('dialog-cancel');
|
||||
if (!dlg) { onConfirm(); return; } // fallback if dialog missing
|
||||
msg.textContent = message;
|
||||
|
||||
var okLabel = ok.textContent;
|
||||
if (opts.alert) { no.hidden = true; ok.textContent = 'Dismiss'; }
|
||||
|
||||
function cleanup() {
|
||||
ok.removeEventListener('click', handleOK);
|
||||
no.removeEventListener('click', handleNo);
|
||||
if (opts.alert) { no.hidden = false; ok.textContent = okLabel; }
|
||||
dlg.close();
|
||||
}
|
||||
function handleOK() { cleanup(); onConfirm(); }
|
||||
function handleOK() { cleanup(); if (onConfirm) onConfirm(); }
|
||||
function handleNo() { cleanup(); }
|
||||
|
||||
ok.addEventListener('click', handleOK);
|
||||
@@ -1878,6 +1891,89 @@ function openModal(message, onConfirm) {
|
||||
}, true);
|
||||
})();
|
||||
|
||||
// ─── YANG tree: selected-node highlight + deep-link reveal ──────────────────
|
||||
// Two related orientation fixes for the tree editor:
|
||||
// 1. Clicking a node loads it in the right pane but left no cue of where you
|
||||
// are — mark the clicked summary .yt-active.
|
||||
// 2. A status-page "Configure →" deep link (/configure/tree?path=…) only
|
||||
// filled the right pane, leaving the left tree collapsed and the user
|
||||
// disoriented. Walk the ancestor chain, opening each <details> in turn —
|
||||
// children load lazily over htmx on toggle, so wait for each level's swap
|
||||
// before descending — then highlight and scroll the target into view.
|
||||
(function() {
|
||||
function setActive(summary) {
|
||||
document.querySelectorAll('.yt-label.yt-active').forEach(function (s) {
|
||||
if (s !== summary) s.classList.remove('yt-active');
|
||||
});
|
||||
if (summary) summary.classList.add('yt-active');
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (e) {
|
||||
var summary = e.target.closest && e.target.closest('summary[data-yang-path]');
|
||||
if (summary) setActive(summary);
|
||||
});
|
||||
|
||||
// Load a node's children into its .yt-children with a direct htmx.ajax
|
||||
// request, resolving with the children <ul>. We deliberately do NOT route
|
||||
// through the node's lazy "toggle once" trigger: the toggle event from a
|
||||
// programmatic .open is async and, on a freshly htmx-swapped tree, did not
|
||||
// reliably reach htmx — the node opened but its body stayed empty until a
|
||||
// manual reload. htmx.ajax sidesteps all of that.
|
||||
function loadChildren(details) {
|
||||
var childUl = details.querySelector(':scope > .yt-children');
|
||||
if (!childUl || childUl.children.length) return Promise.resolve(childUl);
|
||||
var url = details.getAttribute('hx-get');
|
||||
if (!url || !window.htmx || !window.htmx.ajax) return Promise.resolve(childUl);
|
||||
return window.htmx.ajax('GET', url, { target: childUl, swap: 'innerHTML' })
|
||||
.then(function () { return childUl; });
|
||||
}
|
||||
|
||||
function step(ul, target) {
|
||||
var best = null;
|
||||
ul.querySelectorAll(':scope > li > details.yt-node > summary[data-yang-path]').forEach(function (s) {
|
||||
var p = s.getAttribute('data-yang-path');
|
||||
if (p === target || target.indexOf(p + '/') === 0) {
|
||||
if (!best || p.length > best.getAttribute('data-yang-path').length) best = s;
|
||||
}
|
||||
});
|
||||
if (!best) return;
|
||||
|
||||
var details = best.parentElement;
|
||||
var isTarget = best.getAttribute('data-yang-path') === target;
|
||||
details.open = true; // chevron + reveal the children area
|
||||
|
||||
if (isTarget) {
|
||||
// Highlight and scroll right away — neither depends on the child load —
|
||||
// then pull in the target's own children so its subtree shows.
|
||||
setActive(best);
|
||||
best.scrollIntoView({ block: 'center' });
|
||||
loadChildren(details);
|
||||
return;
|
||||
}
|
||||
// Ancestor: load its children, then descend toward the target.
|
||||
loadChildren(details).then(function (childUl) {
|
||||
if (childUl) step(childUl, target);
|
||||
});
|
||||
}
|
||||
|
||||
function initReveal() {
|
||||
var tree = document.querySelector('.yang-tree[data-initial-path]');
|
||||
if (!tree) return;
|
||||
var target = tree.getAttribute('data-initial-path');
|
||||
tree.removeAttribute('data-initial-path'); // process once
|
||||
var rootChildren = tree.querySelector('.yt-children');
|
||||
if (!target || !rootChildren) return;
|
||||
// Defer a tick: on an htmx navigation the tree was just swapped in and htmx
|
||||
// is still settling, so a child load triggered now is dropped (the node
|
||||
// then highlights only after a manual reload). On a full reload it's
|
||||
// already settled, so the tick is harmless.
|
||||
setTimeout(function () { step(rootChildren, target); }, 0);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initReveal);
|
||||
document.addEventListener('htmx:afterSwap', initReveal);
|
||||
})();
|
||||
|
||||
// ─── ⓘ field-info tooltip (position:fixed to escape overflow clipping) ───────
|
||||
(function() {
|
||||
var tip = null;
|
||||
@@ -2109,6 +2205,28 @@ function renderCfgLog() {
|
||||
});
|
||||
})();
|
||||
|
||||
// cfgLogged records a save in the activity panel only — used when the
|
||||
// handler swaps the whole block via outerHTML and renders its own inline
|
||||
// confirmation, so there's no span for the page JS to chase.
|
||||
document.addEventListener('cfgLogged', function(e) {
|
||||
cfgLog('ok', e.detail && e.detail.value ? e.detail.value : 'Saved');
|
||||
});
|
||||
|
||||
// cfgApplyError fires when Apply / Apply & Save reaches the device but the
|
||||
// candidate is rejected (e.g. deleting a still-referenced interface). The
|
||||
// backend returns 200 + this trigger instead of 502 so the connection
|
||||
// monitor stays quiet — the box is fine, the config isn't. The toolbar
|
||||
// optimistically logged success before issuing the request; drop that entry
|
||||
// and show the real reason.
|
||||
document.addEventListener('cfgApplyError', function(e) {
|
||||
var msg = (e.detail && e.detail.value) || 'The device rejected the configuration.';
|
||||
if (cfgLogEntries.length && cfgLogEntries[cfgLogEntries.length - 1].level === 'ok') {
|
||||
cfgLogEntries.pop();
|
||||
}
|
||||
cfgLog('error', msg);
|
||||
openModal('Configuration not applied: ' + msg, null, { alert: true });
|
||||
});
|
||||
|
||||
document.addEventListener('cfgSaved', function(e) {
|
||||
var msg = e.detail && e.detail.value ? e.detail.value : 'Saved';
|
||||
cfgLog('ok', msg);
|
||||
|
||||
@@ -1724,7 +1724,7 @@
|
||||
|
||||
<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>
|
||||
<span class="cfg-save-status{{if .SavedMsg}} saved{{end}}" data-cfg-status-for="{{$ipv4Form}}">{{if .SavedMsg}}✓ {{.SavedMsg}}{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1852,7 +1852,7 @@
|
||||
|
||||
<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>
|
||||
<span class="cfg-save-status{{if .SavedMsg}} saved{{end}}" data-cfg-status-for="{{$ipv6Form}}">{{if .SavedMsg}}✓ {{.SavedMsg}}{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -5,7 +5,13 @@
|
||||
|
||||
{{if .Containers}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Containers</div>
|
||||
<div class="card-header">Containers
|
||||
<a href="/configure/tree?path=/infix-containers:containers"
|
||||
hx-get="/configure/tree?path=/infix-containers:containers"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
@@ -45,7 +51,13 @@
|
||||
</section>
|
||||
{{else if not .Error}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Containers</div>
|
||||
<div class="card-header">Containers
|
||||
<a href="/configure/tree?path=/infix-containers:containers"
|
||||
hx-get="/configure/tree?path=/infix-containers:containers"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<p class="empty-message">No containers configured.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -10,7 +10,13 @@
|
||||
<section class="info-grid">
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-header">Firewall Status</div>
|
||||
<div class="card-header">Firewall Status
|
||||
<a href="/configure/firewall"
|
||||
hx-get="/configure/firewall"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Firewall</th>
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
|
||||
{{if .Interfaces}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Interfaces</div>
|
||||
<div class="card-header">Interfaces
|
||||
<a href="/configure/interfaces"
|
||||
hx-get="/configure/interfaces"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
@@ -43,7 +49,13 @@
|
||||
</section>
|
||||
{{else if not .Error}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">Interfaces</div>
|
||||
<div class="card-header">Interfaces
|
||||
<a href="/configure/interfaces"
|
||||
hx-get="/configure/interfaces"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<p class="empty-message">No interfaces found.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -7,77 +7,68 @@
|
||||
<div class="alert alert-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
<section class="info-grid">
|
||||
<section class="info-card">
|
||||
<div class="card-header">mDNS
|
||||
<a href="/configure/tree?path=/infix-services:mdns"
|
||||
hx-get="/configure/tree?path=/infix-services:mdns"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<td>
|
||||
<span class="iface-status {{if .Enabled}}iface-up{{else}}iface-down{{end}}"></span>
|
||||
{{.EnabledText}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Domain</th><td>{{.Domain}}</td></tr>
|
||||
{{if .Allow}}<tr><th>Allow</th><td>{{.Allow}}</td></tr>{{end}}
|
||||
{{if .Deny}}<tr><th>Deny</th><td>{{.Deny}}</td></tr>{{end}}
|
||||
{{if .Reflector}}
|
||||
<tr>
|
||||
<th>Reflector</th>
|
||||
<td><span class="iface-status iface-up"></span> Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-header">mDNS Status
|
||||
<a href="/configure/tree?path=/infix-services:mdns"
|
||||
hx-get="/configure/tree?path=/infix-services:mdns"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>mDNS</th>
|
||||
<td>
|
||||
<span class="iface-status {{if .Enabled}}iface-up{{else}}iface-down{{end}}"></span>
|
||||
{{.EnabledText}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr><th>Domain</th><td>{{.Domain}}</td></tr>
|
||||
{{if .Allow}}<tr><th>Allow</th><td>{{.Allow}}</td></tr>{{end}}
|
||||
{{if .Deny}}<tr><th>Deny</th><td>{{.Deny}}</td></tr>{{end}}
|
||||
{{if .Reflector}}
|
||||
<tr>
|
||||
<th>Reflector</th>
|
||||
<td><span class="iface-status iface-up"></span> Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<h3 class="section-subtitle">Neighbors</h3>
|
||||
{{if .Neighbors}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Address</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Neighbors}}
|
||||
{{$host := .Hostname}}
|
||||
<tr>
|
||||
<td class="mdns-hostname">{{if .ExtraAddrs}}<button class="mdns-addr-toggle" data-group="{{$host}}" aria-expanded="false" title="Show more addresses"><span class="mdns-addr-arrow">▶</span></button> {{end}}{{.Hostname}}</td>
|
||||
<td class="num">{{.PrimaryAddr}}</td>
|
||||
<td class="mdns-lastseen">{{.LastSeen}}</td>
|
||||
<td>{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}<a href="{{$svc.URL}}" target="_blank" rel="noopener" class="mdns-svc mdns-svc-link">{{$svc.Label}}</a>{{else}}<span class="mdns-svc">{{$svc.Label}}({{$svc.Port}})</span>{{end}}{{end}}</td>
|
||||
</tr>
|
||||
{{range .ExtraAddrs}}
|
||||
<tr class="mdns-extra-row" data-mdns-extra="{{$host}}">
|
||||
<td></td>
|
||||
<td class="num mdns-extra-addr">{{.}}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if .Neighbors}}
|
||||
<div class="info-card info-grid-span-2">
|
||||
<div class="card-header">Neighbors</div>
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<th>Address</th>
|
||||
<th>Last Seen</th>
|
||||
<th>Services</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Neighbors}}
|
||||
{{$host := .Hostname}}
|
||||
<tr>
|
||||
<td class="mdns-hostname">{{if .ExtraAddrs}}<button class="mdns-addr-toggle" data-group="{{$host}}" aria-expanded="false" title="Show more addresses"><span class="mdns-addr-arrow">▶</span></button> {{end}}{{.Hostname}}</td>
|
||||
<td class="num">{{.PrimaryAddr}}</td>
|
||||
<td class="mdns-lastseen">{{.LastSeen}}</td>
|
||||
<td>{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}<a href="{{$svc.URL}}" target="_blank" rel="noopener" class="mdns-svc mdns-svc-link">{{$svc.Label}}</a>{{else}}<span class="mdns-svc">{{$svc.Label}}({{$svc.Port}})</span>{{end}}{{end}}</td>
|
||||
</tr>
|
||||
{{range .ExtraAddrs}}
|
||||
<tr class="mdns-extra-row" data-mdns-extra="{{$host}}">
|
||||
<td></td>
|
||||
<td class="num mdns-extra-addr">{{.}}</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else if not .Error}}
|
||||
<div class="info-card info-grid-span-2">
|
||||
<div class="card-header">Neighbors</div>
|
||||
<p class="empty-message">No mDNS neighbors discovered.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No mDNS neighbors discovered.</p>
|
||||
{{end}}
|
||||
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
|
||||
{{if .NTP}}
|
||||
<section class="info-card">
|
||||
<h2>NTP</h2>
|
||||
<div class="card-header">NTP
|
||||
<a href="/configure/ntp"
|
||||
hx-get="/configure/ntp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<th>Sync Status</th>
|
||||
@@ -69,8 +75,8 @@
|
||||
{{else if not .Error}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">NTP
|
||||
<a href="/configure/tree?path=/ietf-system:system/ntp"
|
||||
hx-get="/configure/tree?path=/ietf-system:system/ntp"
|
||||
<a href="/configure/ntp"
|
||||
hx-get="/configure/ntp"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
<div class="card-header">
|
||||
<span class="iface-status {{if eq .OperStatus "up"}}iface-up{{else}}iface-down{{end}}"></span>
|
||||
{{.Name}}{{if .ListenPort}} <span class="text-muted">:{{.ListenPort}}</span>{{end}}
|
||||
<a href="/configure/interfaces"
|
||||
hx-get="/configure/interfaces"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
{{if .Addresses}}
|
||||
<div class="card-body">
|
||||
@@ -51,7 +56,13 @@
|
||||
{{end}}
|
||||
{{else if not .Error}}
|
||||
<section class="info-card">
|
||||
<div class="card-header">WireGuard</div>
|
||||
<div class="card-header">WireGuard
|
||||
<a href="/configure/interfaces"
|
||||
hx-get="/configure/interfaces"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<p class="empty-message">No WireGuard tunnels configured.</p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
<div class="wifi-radio-header">
|
||||
<span>{{.Name}}</span>
|
||||
</div>
|
||||
<a href="/configure/hardware"
|
||||
hx-get="/configure/hardware"
|
||||
hx-target="#content"
|
||||
hx-push-url="true"
|
||||
class="btn btn-sm btn-outline card-header-action">Configure →</a>
|
||||
</div>
|
||||
<div class="card-body" style="padding:0">
|
||||
<table class="info-table">
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{{/* ── Left pane: navigation tree ───────────────────────── */}}
|
||||
<div class="yang-tree-pane info-card">
|
||||
<div class="card-header">{{if .ReadOnly}}View all{{else}}Edit all{{end}}</div>
|
||||
<ul class="yang-tree">
|
||||
<ul class="yang-tree"{{if .InitialPath}} data-initial-path="{{.InitialPath}}"{{end}}>
|
||||
<li>
|
||||
<details class="yt-node" open>
|
||||
<summary class="yt-label">
|
||||
|
||||
Reference in New Issue
Block a user