webui: add web console shortcut to topbar and nav

Surface the ttyd web console (port 7681) from the WebUI: a terminal icon
in the topbar and a Console entry under Maintenance, both opening the
console in a new tab.  The link target is built client-side from the
current hostname since the console runs on its own HTTPS port.

The console is a config-gated capability (infix-services web/console
enabled), so both entries are detected at login and rendered only when
it is enabled — a disabled console shows no entry rather than a dead
link.  Detection fails closed: a config read error hides the entry.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:24 +02:00
parent 629f0aa74a
commit 8b3ba1f5c5
9 changed files with 76 additions and 0 deletions
+3
View File
@@ -74,6 +74,9 @@ func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) {
// Probe optional features once at login and bake into the session.
caps := handlers.DetectCapabilities(ctx, h.RC)
// The web console (ttyd) is config-gated; fold it into the same feature
// map so templates gate on .Capabilities.Has "console".
caps.Features()["console"] = handlers.DetectConsole(ctx, h.RC)
// Trigger any post-login hooks (e.g. schema sync) with full credentials.
if h.OnLogin != nil {
@@ -70,6 +70,28 @@ type yangLibrary struct {
} `json:"ietf-yang-library:yang-library"`
}
// webConsoleConfig mirrors the slice of infix-services we need for the
// console gate.
type webConsoleConfig struct {
Web struct {
Console struct {
Enabled bool `json:"enabled"`
} `json:"console"`
} `json:"infix-services:web"`
}
// DetectConsole reports whether the web console (ttyd on :7681) is enabled
// in config, so the UI can show or hide the terminal entry. Fails closed:
// a read error hides the entry rather than offering a dead link.
func DetectConsole(ctx context.Context, rc restconf.Fetcher) bool {
var cfg webConsoleConfig
if err := rc.Get(ctx, "/data/infix-services:web", &cfg); err != nil {
log.Printf("web/console config: %v (console entry hidden)", err)
return false
}
return cfg.Web.Console.Enabled
}
func DetectCapabilities(ctx context.Context, rc restconf.Fetcher) *Capabilities {
var lib yangLibrary
if err := rc.Get(ctx, "/data/ietf-yang-library:yang-library", &lib); err != nil {
+16
View File
@@ -2133,6 +2133,22 @@ details[open] > .cfg-multi-summary {
border-color: var(--fg-muted);
}
/* Web console (terminal) shortcut in the topbar */
.topbar-console {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
border-radius: var(--radius);
color: var(--fg-muted);
text-decoration: none;
transition: color 0.15s, background 0.15s;
}
.topbar-console:hover {
color: var(--fg);
background: var(--border);
}
/* User menu button */
.user-menu {
position: relative;
+1
View File
@@ -14,6 +14,7 @@ untouched.
|-------------------|-----------------------|
| advanced.svg | folder-git-2 |
| backup.svg | archive-restore |
| console.svg | square-terminal |
| containers.svg | container |
| dashboard.svg | circle-gauge |
| dhcp.svg | network |
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 11 2-2-2-2"/><path d="M11 13h4"/><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/></svg>

After

Width:  |  Height:  |  Size: 289 B

+14
View File
@@ -2633,3 +2633,17 @@ function renderCfgLog() {
}
});
})();
// ─── Web console (ttyd) link ────────────────────────────────────────────────
// The console runs on its own HTTPS port (7681), so the link target can't be
// a relative path — build it from the current hostname at load time. CSP
// (script-src 'self') forbids an inline onclick, hence this lives here. The
// topbar/sidebar only render on full page loads, so DOMContentLoaded covers it.
(function () {
document.addEventListener('DOMContentLoaded', function () {
var links = document.querySelectorAll('[data-console-link]');
if (!links.length) return;
var url = 'https://' + window.location.hostname + ':7681/';
links.forEach(function (a) { a.href = url; });
});
})();
+8
View File
@@ -24,6 +24,14 @@
</button>
<img src="/assets/img/logo.png" alt="Infix" class="topbar-logo">
<div class="topbar-right">
{{if .Capabilities.Has "console"}}
{{/* Web console (ttyd) on :7681. href is set by JS from the current
hostname since the port differs and CSP forbids inline script. */}}
<a class="topbar-console" data-console-link href="#" target="_blank" rel="noopener"
title="Open web console (terminal) in a new tab" aria-label="Web console">
{{template "icon-terminal"}}
</a>
{{end}}
<div class="user-menu" id="user-menu">
<button class="user-menu-btn" id="user-menu-btn" aria-haspopup="true" aria-expanded="false">
{{template "icon-user"}}
+1
View File
@@ -10,6 +10,7 @@
{{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-terminal"}}<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true"><polyline points="4 17 10 11 4 5" /><line x1="12" x2="20" y1="19" y2="19" /></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}}
+10
View File
@@ -324,6 +324,16 @@
Diagnostics
</a>
</li>
{{if .Capabilities.Has "console"}}
{{/* External link to ttyd on :7681 (new tab); not an htmx nav.
href is filled in by JS from the current hostname. */}}
<li>
<a data-console-link href="#" target="_blank" rel="noopener" class="nav-link">
<span class="nav-icon" style="--icon: url('/assets/img/icons/console.svg')"></span>
Console
</a>
</li>
{{end}}
<li>
<a href="/maintenance/backup"
hx-get="/maintenance/backup"