Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions gearbox/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,11 @@ func main() {
r.Get("/config/haproxy/{boxID}", h.HAProxyConfigPage)
r.Get("/config/firewall/{boxID}", h.FirewallConfigPage)

// Chromeless console popout (#140). Opened via window.open()
// from the in-page console manager. Auth + per-box opt-in
// gating matches /api/console/{boxID}/capabilities.
r.Get("/console/popout/{boxID}", h.ConsolePopoutPage)

// HTMX partial routes (return HTML fragments)
r.Get("/htmx/sidebar-nav", h.SidebarNavPartialHandler)
r.Get("/htmx/{boxID}/status-summary", h.StatusSummaryPartialHandler)
Expand Down
46 changes: 46 additions & 0 deletions gearbox/internal/framework/handler/console_popout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package handler

import (
"net/http"

"github.com/go-chi/chi/v5"

"github.com/sarg3nt/gearbox/internal/framework/models"
"github.com/sarg3nt/gearbox/internal/framework/templates/pages"
)

// ConsolePopoutPage renders a chromeless full-window console session for
// the given box. Reached via the popout button in the in-page console
// manager (window.open → /console/popout/{boxID}). Auth + per-box opt-in
// match APIConsoleCapabilities so the popout can't outrun those gates.
func (h *Handler) ConsolePopoutPage(w http.ResponseWriter, r *http.Request) {
if _, err := h.authManager.GetUser(r); err != nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if !h.authManager.HasPermission(r, models.ComponentBoxConsole, models.PermissionView) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}

boxID := chi.URLParam(r, "boxID")
if boxID == "" {
http.Error(w, "Box ID is required", http.StatusBadRequest)
return
}
server, err := h.db.GetBoxByBoxID(boxID)
if err != nil || server == nil {
http.NotFound(w, r)
return
}
if !server.ConsoleEnabled {
http.Error(w, "Console is not enabled for this box", http.StatusNotFound)
return
}

component := pages.ConsolePopoutPage(boxID, server.Name)
if err := component.Render(r.Context(), w); err != nil {
h.logger.Error("Failed to render console popout page", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
156 changes: 116 additions & 40 deletions gearbox/internal/framework/templates/components/console.templ
Original file line number Diff line number Diff line change
@@ -1,60 +1,136 @@
package components

// ConsoleDrawer renders the markup for the remote-console drawer
// (one per layout, opened on demand by window.openConsole). The
// xterm.js terminal mounts into #console-xterm; the title bar shows
// which box the session targets so the user can't mix them up if
// they have multiple browser tabs open against different boxes.
// ConsoleDrawer renders the chrome for the multi-session remote console.
// One copy lives in the base layout (for the in-page drawer/dock) and is
// also rendered standalone by the popout page.
//
// Hidden by default. Opened by static/js/console/console.js. The
// fullscreen overlay sits at z-[200] so it lands above existing
// modals (z-[100]); a console session is the most foreground thing
// the UI can show.
// Sessions are managed by static/js/console/console.js (ConsoleManager).
// The xterm.js terminals mount into #console-xterm; the manager swaps the
// active session's <div> into/out of that host on tab switches.
//
// Layout state lives on the root #console-drawer element via the
// `console-layout-drawer` and `console-layout-dock` classes; CSS in
// components/console.css positions accordingly. Hidden by default.
templ ConsoleDrawer() {
<div id="console-drawer"
class="fixed inset-0 z-[200] hidden flex-col bg-slate-900 text-slate-100"
class="hidden flex-col bg-slate-900 text-slate-100 console-layout-drawer"
role="dialog"
aria-labelledby="console-drawer-title"
aria-label="Remote console"
aria-modal="true">
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700 bg-slate-800">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-emerald-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</svg>
<h2 id="console-drawer-title" class="text-sm font-medium truncate">
Console: <span id="console-drawer-box" class="font-mono"></span>
</h2>
<span id="console-drawer-status"
class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300 flex-shrink-0">
connecting…
</span>
<span id="console-drawer-mode"
class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-400 flex-shrink-0 font-mono"></span>
<span id="console-drawer-uid"
class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-400 flex-shrink-0 font-mono"></span>
// Dock-only drag handle. CSS hides it in drawer mode. Mouse-down
// captured by ConsoleManager._wireDockResize.
<div id="console-dock-handle"
class="console-dock-handle"
role="separator"
aria-orientation="horizontal"
aria-label="Resize console dock"
title="Drag to resize"></div>
// Header: title block on the left, status pills next, toolbar on
// the right, close button at the far end.
// Header now hosts only the toolbar — the box name lives in the
// active tab and the per-tab dot carries connection state, so the
// header has no left-side content to render. Vertical padding
// kept tight (py-1) so the bar stays thin.
<div class="console-header flex items-center justify-end px-3 py-1 border-b border-slate-700 bg-slate-800 gap-3">
<div class="flex items-center gap-2 flex-shrink-0">
// Font-size slider — matches the logs-viewer pattern. Value
// persisted via localStorage by ConsoleManager.
<div class="flex items-center gap-2 mr-2">
<label for="console-font-slider" class="text-xs text-slate-300">Font</label>
<input
type="range"
id="console-font-slider"
min="10"
max="24"
value="13"
class="w-20 h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer"
aria-label="Console font size"
/>
<span id="console-font-value" class="text-xs text-slate-400 w-10">13px</span>
</div>
<button type="button"
id="console-btn-clear"
class="console-tool-btn"
aria-label="Clear terminal buffer"
title="Clear (Ctrl-L sends to remote; this just clears the local buffer)">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</svg>
</button>
// Layout switchers — drawer/dock are mutually exclusive, so
// only the inactive one is visible at a time (JS toggles
// .hidden on each based on the active layout). Popout
// stays visible; the popout page hides all three.
<button type="button"
id="console-btn-layout-drawer"
class="console-tool-btn"
aria-label="Switch to fullscreen layout"
title="Fullscreen drawer">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</svg>
</button>
<button type="button"
id="console-btn-layout-dock"
class="console-tool-btn"
aria-label="Switch to docked layout"
title="Dock to bottom">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="16" rx="2" stroke-width="2"></rect>
<line x1="3" y1="14" x2="21" y2="14" stroke-width="2"></line>
</svg>
</button>
<button type="button"
id="console-btn-layout-popout"
class="console-tool-btn"
aria-label="Open console in new window"
title="Open in new window">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
</svg>
</button>
<button type="button"
id="console-drawer-close"
class="console-tool-btn"
aria-label="Close console"
title="Close console (Esc)">
<svg class="w-4 h-4" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
// Tab bar — tabs (populated by ConsoleManager) + "+ new tab" button.
// The bar is hidden by default and revealed by the manager once a
// session opens; we keep the "+" inline with the tabs so it's
// discoverable in the same place browsers/iTerm/etc. put it.
<div id="console-tab-bar" class="console-tab-bar hidden">
<div id="console-tabs" class="console-tabs" role="tablist"></div>
<button type="button"
id="console-drawer-close"
class="text-slate-400 hover:text-white p-1"
title="Close console (Esc)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
id="console-btn-newtab"
class="console-tab-new"
aria-label="New tab"
title="Open another tab to this box">
<svg class="w-3 h-3" aria-hidden="true" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
</button>
</div>
<div id="console-xterm" class="flex-1 overflow-hidden bg-black"></div>
// xterm host. Sessions attach their own xterm <div> into this.
// `console-xterm-pad` adds breathing room so cursor/text isn't
// flush against the panel border.
<div class="console-xterm-pad flex-1 overflow-hidden bg-black">
<div id="console-xterm" class="w-full h-full"></div>
</div>
</div>
}

// ConsoleAssets pulls in the xterm.js bundle, fit addon, and CSS.
// Place once near the bottom of the layout (after the existing
// vendor scripts) so window.Terminal exists before console.js runs.
//
// The actual wiring code is in static/js/console/console.js — kept
// in JS so the bundler-free workflow stays simple and so tests can
// stub window.openConsole without recompiling Go.
// ConsoleAssets pulls in the xterm.js bundle, fit addon, console CSS, and
// the console manager. Place once near the bottom of any layout that
// surfaces the console (the popout page uses this directly).
templ ConsoleAssets() {
<link rel="stylesheet" href="/static/css/vendor/xterm.min.css"/>
<link rel="stylesheet" href="/static/css/components/console.css"/>
<script src="/static/js/vendor/xterm.min.js" defer></script>
<script src="/static/js/vendor/xterm-addon-fit.min.js" defer></script>
<script src="/static/js/console/console.js" defer></script>
Expand Down
73 changes: 73 additions & 0 deletions gearbox/internal/framework/templates/pages/console_popout.templ
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package pages

import (
"github.com/sarg3nt/gearbox/internal/framework/middleware"
"github.com/sarg3nt/gearbox/internal/framework/templates/components"
)

// ConsolePopoutPage renders a chromeless full-window console session for a
// single box. Reached via window.open() from the in-page console manager
// (the popout button). No sidebar, no header chrome, no Tailwind app shell
// — just the ConsoleDrawer in fullscreen mode plus the assets needed to
// drive it.
//
// On load the inline boot script calls markPopout which:
// 1. Tags the drawer with .console-popout so CSS hides the entire
// header (incl. the close button) and the tab bar — the popout
// window is single-session, all chrome controls are meaningless.
// 2. Forces the layout to drawer (fullscreen) and disables Esc-to-close.
//
// The only way to exit a popout is closing the OS window (Cmd-W /
// red close button) — by design, since the chrome's gone anyway.
templ ConsolePopoutPage(boxID string, boxName string) {
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Console: { boxName } - Gearbox</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
if middleware.UseLocalAssets(ctx) {
<script src="/static/js/vendor/tailwind.js"></script>
} else {
<script src="https://cdn.tailwindcss.com"></script>
}
<style>
html, body { height: 100%; margin: 0; background: #000; }
/* Force the drawer to always be visible and fullscreen in a
* popout — the manager handles layout state for the in-page
* case, but here we want zero chrome and zero hidden state. */
#console-drawer { display: flex !important; }
#console-drawer.hidden { display: flex !important; }
</style>
</head>
<body class="h-full m-0 bg-black">
<div id="console-popout-data" data-box-id={ boxID } data-label={ boxName } class="hidden"></div>
@components.ConsoleDrawer()
@components.ConsoleAssets()
<script>
(function () {
function boot() {
const node = document.getElementById('console-popout-data');
if (!node) return;
const boxID = node.getAttribute('data-box-id') || '';
const label = node.getAttribute('data-label') || boxID;
if (!window.gearbox || !window.gearbox.console) {
setTimeout(boot, 50);
return;
}
// markPopout hides the header + tab bar; the only way to
// exit is closing the window (Cmd-W / OS chrome).
window.gearbox.console.markPopout();
window.gearbox.console.open({ kind: 'box', boxID: boxID, label: label });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
</script>
</body>
</html>
}
Loading
Loading