Skip to content

Commit 334c2e9

Browse files
sarg3ntclaude
andauthored
fix(console): #140 bug fixes + multi-session manager (tabs/dock/popout) (#141)
* Refactor console management and UI interactions - Removed the console column from the grid and replaced it with a tools column for better organization. - Updated the console opening logic to use box_id instead of id for consistency. - Enhanced the console.js file to support multi-session management with a new ConsoleSession class. - Improved the layout handling for console sessions, including dock and drawer modes. - Added preference management for font size and dock height. - Implemented a more robust session lifecycle management, including connection handling and error reporting. * address Copilot review findings on PR #141 - ARIA tab semantics: move role="tab" onto the label button so nested interactive elements (dot, close) are siblings of the tabbable element, not nested inside it. Wrapper is now role="presentation". - Per-tab status dot: add aria-label that reflects current state and reconnect action; aria-disabled when inert. - Toolbar buttons (clear, layout-drawer/dock/popout, close, +): add explicit aria-label; mark icon SVGs aria-hidden so screen readers don't try to announce path data. - Capabilities fetch failures and websocket errors now also write a red [console] line into the xterm buffer, so a failing session isn't a silent black void — matches the existing 'err' frame path. - Dock resize: switch from mouse events to Pointer Events with setPointerCapture/releasePointerCapture, plus blur and visibilitychange cleanups. Prevents stuck drag state when the mouse releases outside the window. - Fix CSS selector for .xterm height: actual DOM is .console-xterm-pad > #console-xterm > .console-session-host > .xterm — old selector skipped the #console-xterm wrapper, so the rule was inert and the terminal didn't always fill its container. - Popout templ doc comment was stale (mentioned overriding the close button); CSS now hides the entire header in popout mode, comment updated to match. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5a290f0 commit 334c2e9

7 files changed

Lines changed: 1338 additions & 306 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,11 @@ func main() {
677677
r.Get("/config/haproxy/{boxID}", h.HAProxyConfigPage)
678678
r.Get("/config/firewall/{boxID}", h.FirewallConfigPage)
679679

680+
// Chromeless console popout (#140). Opened via window.open()
681+
// from the in-page console manager. Auth + per-box opt-in
682+
// gating matches /api/console/{boxID}/capabilities.
683+
r.Get("/console/popout/{boxID}", h.ConsolePopoutPage)
684+
680685
// HTMX partial routes (return HTML fragments)
681686
r.Get("/htmx/sidebar-nav", h.SidebarNavPartialHandler)
682687
r.Get("/htmx/{boxID}/status-summary", h.StatusSummaryPartialHandler)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package handler
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/go-chi/chi/v5"
7+
8+
"github.com/sarg3nt/gearbox/internal/framework/models"
9+
"github.com/sarg3nt/gearbox/internal/framework/templates/pages"
10+
)
11+
12+
// ConsolePopoutPage renders a chromeless full-window console session for
13+
// the given box. Reached via the popout button in the in-page console
14+
// manager (window.open → /console/popout/{boxID}). Auth + per-box opt-in
15+
// match APIConsoleCapabilities so the popout can't outrun those gates.
16+
func (h *Handler) ConsolePopoutPage(w http.ResponseWriter, r *http.Request) {
17+
if _, err := h.authManager.GetUser(r); err != nil {
18+
http.Redirect(w, r, "/login", http.StatusSeeOther)
19+
return
20+
}
21+
if !h.authManager.HasPermission(r, models.ComponentBoxConsole, models.PermissionView) {
22+
http.Error(w, "Forbidden", http.StatusForbidden)
23+
return
24+
}
25+
26+
boxID := chi.URLParam(r, "boxID")
27+
if boxID == "" {
28+
http.Error(w, "Box ID is required", http.StatusBadRequest)
29+
return
30+
}
31+
server, err := h.db.GetBoxByBoxID(boxID)
32+
if err != nil || server == nil {
33+
http.NotFound(w, r)
34+
return
35+
}
36+
if !server.ConsoleEnabled {
37+
http.Error(w, "Console is not enabled for this box", http.StatusNotFound)
38+
return
39+
}
40+
41+
component := pages.ConsolePopoutPage(boxID, server.Name)
42+
if err := component.Render(r.Context(), w); err != nil {
43+
h.logger.Error("Failed to render console popout page", "error", err)
44+
http.Error(w, "Internal server error", http.StatusInternalServerError)
45+
}
46+
}

gearbox/internal/framework/templates/components/console.templ

Lines changed: 116 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,136 @@
11
package components
22

3-
// ConsoleDrawer renders the markup for the remote-console drawer
4-
// (one per layout, opened on demand by window.openConsole). The
5-
// xterm.js terminal mounts into #console-xterm; the title bar shows
6-
// which box the session targets so the user can't mix them up if
7-
// they have multiple browser tabs open against different boxes.
3+
// ConsoleDrawer renders the chrome for the multi-session remote console.
4+
// One copy lives in the base layout (for the in-page drawer/dock) and is
5+
// also rendered standalone by the popout page.
86
//
9-
// Hidden by default. Opened by static/js/console/console.js. The
10-
// fullscreen overlay sits at z-[200] so it lands above existing
11-
// modals (z-[100]); a console session is the most foreground thing
12-
// the UI can show.
7+
// Sessions are managed by static/js/console/console.js (ConsoleManager).
8+
// The xterm.js terminals mount into #console-xterm; the manager swaps the
9+
// active session's <div> into/out of that host on tab switches.
10+
//
11+
// Layout state lives on the root #console-drawer element via the
12+
// `console-layout-drawer` and `console-layout-dock` classes; CSS in
13+
// components/console.css positions accordingly. Hidden by default.
1314
templ ConsoleDrawer() {
1415
<div id="console-drawer"
15-
class="fixed inset-0 z-[200] hidden flex-col bg-slate-900 text-slate-100"
16+
class="hidden flex-col bg-slate-900 text-slate-100 console-layout-drawer"
1617
role="dialog"
17-
aria-labelledby="console-drawer-title"
18+
aria-label="Remote console"
1819
aria-modal="true">
19-
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700 bg-slate-800">
20-
<div class="flex items-center gap-3 min-w-0">
21-
<svg class="w-5 h-5 text-emerald-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
22-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
23-
</svg>
24-
<h2 id="console-drawer-title" class="text-sm font-medium truncate">
25-
Console: <span id="console-drawer-box" class="font-mono"></span>
26-
</h2>
27-
<span id="console-drawer-status"
28-
class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300 flex-shrink-0">
29-
connecting…
30-
</span>
31-
<span id="console-drawer-mode"
32-
class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-400 flex-shrink-0 font-mono"></span>
33-
<span id="console-drawer-uid"
34-
class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-400 flex-shrink-0 font-mono"></span>
20+
// Dock-only drag handle. CSS hides it in drawer mode. Mouse-down
21+
// captured by ConsoleManager._wireDockResize.
22+
<div id="console-dock-handle"
23+
class="console-dock-handle"
24+
role="separator"
25+
aria-orientation="horizontal"
26+
aria-label="Resize console dock"
27+
title="Drag to resize"></div>
28+
// Header: title block on the left, status pills next, toolbar on
29+
// the right, close button at the far end.
30+
// Header now hosts only the toolbar — the box name lives in the
31+
// active tab and the per-tab dot carries connection state, so the
32+
// header has no left-side content to render. Vertical padding
33+
// kept tight (py-1) so the bar stays thin.
34+
<div class="console-header flex items-center justify-end px-3 py-1 border-b border-slate-700 bg-slate-800 gap-3">
35+
<div class="flex items-center gap-2 flex-shrink-0">
36+
// Font-size slider — matches the logs-viewer pattern. Value
37+
// persisted via localStorage by ConsoleManager.
38+
<div class="flex items-center gap-2 mr-2">
39+
<label for="console-font-slider" class="text-xs text-slate-300">Font</label>
40+
<input
41+
type="range"
42+
id="console-font-slider"
43+
min="10"
44+
max="24"
45+
value="13"
46+
class="w-20 h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer"
47+
aria-label="Console font size"
48+
/>
49+
<span id="console-font-value" class="text-xs text-slate-400 w-10">13px</span>
50+
</div>
51+
<button type="button"
52+
id="console-btn-clear"
53+
class="console-tool-btn"
54+
aria-label="Clear terminal buffer"
55+
title="Clear (Ctrl-L sends to remote; this just clears the local buffer)">
56+
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
57+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"></path>
58+
</svg>
59+
</button>
60+
// Layout switchers — drawer/dock are mutually exclusive, so
61+
// only the inactive one is visible at a time (JS toggles
62+
// .hidden on each based on the active layout). Popout
63+
// stays visible; the popout page hides all three.
64+
<button type="button"
65+
id="console-btn-layout-drawer"
66+
class="console-tool-btn"
67+
aria-label="Switch to fullscreen layout"
68+
title="Fullscreen drawer">
69+
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
70+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"></path>
71+
</svg>
72+
</button>
73+
<button type="button"
74+
id="console-btn-layout-dock"
75+
class="console-tool-btn"
76+
aria-label="Switch to docked layout"
77+
title="Dock to bottom">
78+
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
79+
<rect x="3" y="4" width="18" height="16" rx="2" stroke-width="2"></rect>
80+
<line x1="3" y1="14" x2="21" y2="14" stroke-width="2"></line>
81+
</svg>
82+
</button>
83+
<button type="button"
84+
id="console-btn-layout-popout"
85+
class="console-tool-btn"
86+
aria-label="Open console in new window"
87+
title="Open in new window">
88+
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
89+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
90+
</svg>
91+
</button>
92+
<button type="button"
93+
id="console-drawer-close"
94+
class="console-tool-btn"
95+
aria-label="Close console"
96+
title="Close console (Esc)">
97+
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
98+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
99+
</svg>
100+
</button>
35101
</div>
102+
</div>
103+
// Tab bar — tabs (populated by ConsoleManager) + "+ new tab" button.
104+
// The bar is hidden by default and revealed by the manager once a
105+
// session opens; we keep the "+" inline with the tabs so it's
106+
// discoverable in the same place browsers/iTerm/etc. put it.
107+
<div id="console-tab-bar" class="console-tab-bar hidden">
108+
<div id="console-tabs" class="console-tabs" role="tablist"></div>
36109
<button type="button"
37-
id="console-drawer-close"
38-
class="text-slate-400 hover:text-white p-1"
39-
title="Close console (Esc)">
40-
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
41-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
110+
id="console-btn-newtab"
111+
class="console-tab-new"
112+
aria-label="New tab"
113+
title="Open another tab to this box">
114+
<svg class="w-3 h-3" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
115+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
42116
</svg>
43117
</button>
44118
</div>
45-
<div id="console-xterm" class="flex-1 overflow-hidden bg-black"></div>
119+
// xterm host. Sessions attach their own xterm <div> into this.
120+
// `console-xterm-pad` adds breathing room so cursor/text isn't
121+
// flush against the panel border.
122+
<div class="console-xterm-pad flex-1 overflow-hidden bg-black">
123+
<div id="console-xterm" class="w-full h-full"></div>
124+
</div>
46125
</div>
47126
}
48127

49-
// ConsoleAssets pulls in the xterm.js bundle, fit addon, and CSS.
50-
// Place once near the bottom of the layout (after the existing
51-
// vendor scripts) so window.Terminal exists before console.js runs.
52-
//
53-
// The actual wiring code is in static/js/console/console.js — kept
54-
// in JS so the bundler-free workflow stays simple and so tests can
55-
// stub window.openConsole without recompiling Go.
128+
// ConsoleAssets pulls in the xterm.js bundle, fit addon, console CSS, and
129+
// the console manager. Place once near the bottom of any layout that
130+
// surfaces the console (the popout page uses this directly).
56131
templ ConsoleAssets() {
57132
<link rel="stylesheet" href="/static/css/vendor/xterm.min.css"/>
133+
<link rel="stylesheet" href="/static/css/components/console.css"/>
58134
<script src="/static/js/vendor/xterm.min.js" defer></script>
59135
<script src="/static/js/vendor/xterm-addon-fit.min.js" defer></script>
60136
<script src="/static/js/console/console.js" defer></script>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package pages
2+
3+
import (
4+
"github.com/sarg3nt/gearbox/internal/framework/middleware"
5+
"github.com/sarg3nt/gearbox/internal/framework/templates/components"
6+
)
7+
8+
// ConsolePopoutPage renders a chromeless full-window console session for a
9+
// single box. Reached via window.open() from the in-page console manager
10+
// (the popout button). No sidebar, no header chrome, no Tailwind app shell
11+
// — just the ConsoleDrawer in fullscreen mode plus the assets needed to
12+
// drive it.
13+
//
14+
// On load the inline boot script calls markPopout which:
15+
// 1. Tags the drawer with .console-popout so CSS hides the entire
16+
// header (incl. the close button) and the tab bar — the popout
17+
// window is single-session, all chrome controls are meaningless.
18+
// 2. Forces the layout to drawer (fullscreen) and disables Esc-to-close.
19+
//
20+
// The only way to exit a popout is closing the OS window (Cmd-W /
21+
// red close button) — by design, since the chrome's gone anyway.
22+
templ ConsolePopoutPage(boxID string, boxName string) {
23+
<!DOCTYPE html>
24+
<html lang="en" class="h-full">
25+
<head>
26+
<meta charset="UTF-8"/>
27+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
28+
<title>Console: { boxName } - Gearbox</title>
29+
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
30+
if middleware.UseLocalAssets(ctx) {
31+
<script src="/static/js/vendor/tailwind.js"></script>
32+
} else {
33+
<script src="https://cdn.tailwindcss.com"></script>
34+
}
35+
<style>
36+
html, body { height: 100%; margin: 0; background: #000; }
37+
/* Force the drawer to always be visible and fullscreen in a
38+
* popout — the manager handles layout state for the in-page
39+
* case, but here we want zero chrome and zero hidden state. */
40+
#console-drawer { display: flex !important; }
41+
#console-drawer.hidden { display: flex !important; }
42+
</style>
43+
</head>
44+
<body class="h-full m-0 bg-black">
45+
<div id="console-popout-data" data-box-id={ boxID } data-label={ boxName } class="hidden"></div>
46+
@components.ConsoleDrawer()
47+
@components.ConsoleAssets()
48+
<script>
49+
(function () {
50+
function boot() {
51+
const node = document.getElementById('console-popout-data');
52+
if (!node) return;
53+
const boxID = node.getAttribute('data-box-id') || '';
54+
const label = node.getAttribute('data-label') || boxID;
55+
if (!window.gearbox || !window.gearbox.console) {
56+
setTimeout(boot, 50);
57+
return;
58+
}
59+
// markPopout hides the header + tab bar; the only way to
60+
// exit is closing the window (Cmd-W / OS chrome).
61+
window.gearbox.console.markPopout();
62+
window.gearbox.console.open({ kind: 'box', boxID: boxID, label: label });
63+
}
64+
if (document.readyState === 'loading') {
65+
document.addEventListener('DOMContentLoaded', boot);
66+
} else {
67+
boot();
68+
}
69+
})();
70+
</script>
71+
</body>
72+
</html>
73+
}

0 commit comments

Comments
 (0)