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:
Joachim Wiberg
2026-06-15 10:45:48 +02:00
parent 7a714de3ed
commit eae7116cba
10 changed files with 212 additions and 78 deletions
+8
View File
@@ -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 {
+83
View File
@@ -1878,6 +1878,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;
+14 -2
View File
@@ -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 &rarr;</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 &rarr;</a>
</div>
<p class="empty-message">No containers configured.</p>
</section>
{{end}}
+7 -1
View File
@@ -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 &rarr;</a>
</div>
<table class="info-table">
<tr>
<th>Firewall</th>
+14 -2
View File
@@ -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 &rarr;</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 &rarr;</a>
</div>
<p class="empty-message">No interfaces found.</p>
</section>
{{end}}
+59 -68
View File
@@ -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 &rarr;</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 &rarr;</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>&nbsp;{{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>&nbsp;{{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 -3
View File
@@ -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 &rarr;</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 &rarr;</a>
+12 -1
View File
@@ -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 &rarr;</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 &rarr;</a>
</div>
<p class="empty-message">No WireGuard tunnels configured.</p>
</section>
{{end}}
+5
View File
@@ -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 &rarr;</a>
</div>
<div class="card-body" style="padding:0">
<table class="info-table">
+1 -1
View File
@@ -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">