From 47b8f0434abf6359c8215365db32d860fe77c74a Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Wed, 27 May 2026 17:01:09 +0900 Subject: [PATCH] feat(static): embed labdb/public/* and serve from BridgeDispatch Drops labdb's index.html / login.html / css / js into the binary via embed.FS so the single 24 MB fivenode_go binary now ships both the HTML/JS frontend AND the JSON API. No web server, no Apache, no asset bundler. hbrtl_ext/labdb_static/ public/ mirror of fivenode/labdb/public/ assets.go //go:embed public + two PRG-callable HB_FUNCs: LABDB_STATIC_FILE(cPath) returns the bytes, LABDB_STATIC_MIME(cPath) returns the Content-Type derived from the resolved (not raw) extension so "/" -> text/html, not application/octet-stream. app/bridge_server.prg BridgeDispatch now falls through to the static FS for any path that isn't /api/*. Missing assets get a 404 instead of being routed to the path-to-symbol dispatcher. Verified: GET / -> 200 text/html, full index.html body GET /login.html -> 200 text/html, 1850 bytes GET /css/app.css -> 200 text/css GET /api/admin-stats -> 200 JSON {devices:2,...} (still live PG) GET /nonexistent.html -> 404 text/plain Phase 1a complete: HTTP serves both the labdb frontend and the real-data labdb API from one binary, end to end. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/bridge_server.prg | 18 +- cmd/fnode/main.go | 1 + hbrtl_ext/labdb_static/assets.go | 86 +++++++ hbrtl_ext/labdb_static/public/css/app.css | 277 ++++++++++++++++++++++ hbrtl_ext/labdb_static/public/index.html | 257 ++++++++++++++++++++ hbrtl_ext/labdb_static/public/js/api.js | 43 ++++ hbrtl_ext/labdb_static/public/login.html | 55 +++++ 7 files changed, 736 insertions(+), 1 deletion(-) create mode 100644 hbrtl_ext/labdb_static/assets.go create mode 100644 hbrtl_ext/labdb_static/public/css/app.css create mode 100644 hbrtl_ext/labdb_static/public/index.html create mode 100644 hbrtl_ext/labdb_static/public/js/api.js create mode 100644 hbrtl_ext/labdb_static/public/login.html diff --git a/app/bridge_server.prg b/app/bridge_server.prg index 95d973d..5e56830 100644 --- a/app/bridge_server.prg +++ b/app/bridge_server.prg @@ -26,7 +26,23 @@ FUNCTION Main() RETURN NIL FUNCTION BridgeDispatch( hReq ) - LOCAL hCtx, cFunc, cErr + LOCAL hCtx, cFunc, cErr, cStatic, cMime + + // Static file fallback: anything that isn't /api/* is served from + // the embedded labdb/public/ filesystem. Lets index.html / login.html + // / css / js ship in the same binary as the JSON API. + IF SubStr( hReq[ "path" ], 1, 5 ) != "/api/" + cStatic := LABDB_STATIC_FILE( hReq[ "path" ] ) + IF ! Empty( cStatic ) + cMime := LABDB_STATIC_MIME( hReq[ "path" ] ) + RETURN { ; + "status" => 200, ; + "headers" => { "Content-Type" => cMime }, ; + "body" => cStatic ; + } + ENDIF + RETURN { "status" => 404, "headers" => { "Content-Type" => "text/plain" }, "body" => "404: " + hReq[ "path" ] } + ENDIF hCtx := { ; "method" => hReq[ "method" ], ; diff --git a/cmd/fnode/main.go b/cmd/fnode/main.go index e85e50f..ed7f8c3 100644 --- a/cmd/fnode/main.go +++ b/cmd/fnode/main.go @@ -40,6 +40,7 @@ var defaultRTL = []string{ "fivenode_go/hbrtl_ext/pgrtl", "fivenode_go/hbrtl_ext/dispatch", "fivenode_go/hbrtl_ext/labdb_state", + "fivenode_go/hbrtl_ext/labdb_static", } func main() { diff --git a/hbrtl_ext/labdb_static/assets.go b/hbrtl_ext/labdb_static/assets.go new file mode 100644 index 0000000..fdc9108 --- /dev/null +++ b/hbrtl_ext/labdb_static/assets.go @@ -0,0 +1,86 @@ +// Package labdb_static embeds labdb's HTML/CSS/JS into the binary so +// the fivenode_go process ships every byte the browser needs — +// no separate web server, no filesystem dependency. +// +// PRG surface +// +// cBody := LABDB_STATIC_FILE(cPath) -> file contents, or "" when missing +// cMime := LABDB_STATIC_MIME(cPath) -> Content-Type guessed from extension +// +// cPath is taken verbatim from hReq["path"], so "/" maps to index.html +// and a missing leading slash is fine. +package labdb_static + +import ( + "embed" + "path" + "strings" + + "five/hbrt" +) + +//go:embed public +var fs embed.FS + +func init() { + hbrt.HB_FUNC("LABDB_STATIC_FILE", staticFile) + hbrt.HB_FUNC("LABDB_STATIC_MIME", staticMime) +} + +// resolve turns "/" -> "public/index.html", +// "/css/app.css" -> "public/css/app.css", +// "css/app.css" -> "public/css/app.css". +func resolve(p string) string { + p = strings.TrimPrefix(p, "/") + if p == "" { + p = "index.html" + } + // Normalize any "..", "//" so an attacker can't escape the embed root. + cleaned := path.Clean("/" + p) + if cleaned == "/" { + return "public/index.html" + } + return "public" + cleaned +} + +func staticFile(ctx *hbrt.HBContext) { + if ctx.PCount() < 1 || !ctx.IsChar(1) { + ctx.RetC("") + return + } + data, err := fs.ReadFile(resolve(ctx.ParC(1))) + if err != nil { + ctx.RetC("") + return + } + ctx.RetC(string(data)) +} + +func staticMime(ctx *hbrt.HBContext) { + if ctx.PCount() < 1 || !ctx.IsChar(1) { + ctx.RetC("application/octet-stream") + return + } + // Use the resolved path so "/" -> .html, not "" -> octet-stream. + ext := strings.ToLower(path.Ext(resolve(ctx.ParC(1)))) + switch ext { + case ".html", ".htm": + ctx.RetC("text/html; charset=utf-8") + case ".css": + ctx.RetC("text/css; charset=utf-8") + case ".js", ".mjs": + ctx.RetC("application/javascript; charset=utf-8") + case ".json": + ctx.RetC("application/json; charset=utf-8") + case ".svg": + ctx.RetC("image/svg+xml") + case ".png": + ctx.RetC("image/png") + case ".jpg", ".jpeg": + ctx.RetC("image/jpeg") + case ".ico": + ctx.RetC("image/x-icon") + default: + ctx.RetC("application/octet-stream") + } +} diff --git a/hbrtl_ext/labdb_static/public/css/app.css b/hbrtl_ext/labdb_static/public/css/app.css new file mode 100644 index 0000000..f4aad13 --- /dev/null +++ b/hbrtl_ext/labdb_static/public/css/app.css @@ -0,0 +1,277 @@ +/* labdb — Lab Data API admin UI + * Quiet Luxury / Maison Dark theme + * Copyright (c) 2026 Charles KWON OhJun + */ +:root { + --bg-primary: #080808; + --bg-secondary: #121210; + --bg-tertiary: #1c1a18; + --bg-hover: rgba(255, 248, 240, 0.04); + --text-primary: #e8e4df; + --text-secondary: #a8a39d; + --text-tertiary: #6a6560; + --text-inverse: #080808; + --accent: #c9a96e; + --accent-hover: #d4b87a; + --accent-light: rgba(201, 169, 110, 0.08); + --border: rgba(255, 248, 240, 0.06); + --border-light: rgba(255, 248, 240, 0.04); + --status-active: #22c55e; + --status-pending: #eab308; + --status-error: #ef4444; + --status-muted: #71717a; + --r-sm: 8px; + --r-md: 12px; + --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; + --sp-5: 20px; --sp-6: 24px; --sp-8: 32px; --sp-10: 40px; +} + +* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } +html, body { margin: 0; padding: 0; } +body { + background: var(--bg-primary); + color: var(--text-primary); + font-family: -apple-system, "Segoe UI", "Helvetica Neue", "Noto Sans", sans-serif; + font-size: 14px; + -webkit-font-smoothing: antialiased; + min-height: 100dvh; +} +a { color: inherit; text-decoration: none; } +a:hover { color: var(--accent); } + +.hidden { display: none !important; } + +/* ── Header ─────────────────────────── */ +.header { + display: flex; align-items: center; gap: var(--sp-4); + padding: 0 var(--sp-6); + min-height: 56px; + background: rgba(8,8,8,0.85); + backdrop-filter: saturate(140%) blur(20px); + -webkit-backdrop-filter: saturate(140%) blur(20px); + border-bottom: 1px solid var(--border); + position: sticky; top: 0; z-index: 50; + padding-top: env(safe-area-inset-top); +} +.header h1 { + font-size: 16px; font-weight: 500; margin: 0; + letter-spacing: -0.01em; +} +.header .subtitle { + font-size: 11px; color: var(--text-tertiary); + margin-top: -2px; letter-spacing: 0.04em; text-transform: uppercase; +} +.header-spacer { flex: 1; } +.header-actions { display: flex; align-items: center; gap: var(--sp-2); } + +/* ── Layout ─────────────────────────── */ +.container { + max-width: 1400px; + margin: 0 auto; + padding: var(--sp-6); +} +.container-sm { + max-width: 480px; + margin: 80px auto; + padding: var(--sp-6); +} + +/* ── Cards ─────────────────────────── */ +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + overflow: hidden; +} +.card-padded { padding: var(--sp-6); } + +/* ── Stats grid ─────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--sp-4); + margin-bottom: var(--sp-6); +} +.stat-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: var(--sp-5); +} +.stat-label { + font-size: 10px; color: var(--accent); + text-transform: uppercase; letter-spacing: 0.12em; + margin-bottom: var(--sp-2); +} +.stat-value { + font-size: 28px; font-weight: 500; + letter-spacing: -0.02em; +} +@media (max-width: 768px) { + .stats-grid { grid-template-columns: repeat(2, 1fr); } + .container { padding: var(--sp-4); } +} + +/* ── Tabs ─────────────────────────── */ +.tabs { + display: flex; gap: var(--sp-2); + border-bottom: 1px solid var(--border); + margin-bottom: var(--sp-5); +} +.tab { + padding: var(--sp-3) var(--sp-4); + background: none; border: none; + color: var(--text-tertiary); + cursor: pointer; font-size: 13px; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} +.tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +/* ── Tables ─────────────────────────── */ +.table-wrap { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + overflow: hidden; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +thead th { + background: var(--bg-tertiary); + color: var(--accent); + font-weight: 500; + text-align: left; + padding: var(--sp-3) var(--sp-4); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + border-bottom: 1px solid var(--border); +} +tbody td { + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-light); +} +tbody tr:hover { background: var(--bg-hover); } +tbody tr:last-child td { border-bottom: none; } +.text-mono { font-family: "JetBrains Mono", "SF Mono", Menlo, monospace; font-size: 12px; } +.text-meta { color: var(--text-tertiary); font-size: 12px; } +.text-right { text-align: right; } + +/* ── Badges ─────────────────────────── */ +.badge { + display: inline-block; + padding: 3px 10px; + font-size: 10px; + font-weight: 500; + border-radius: 100px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.badge-active { background: rgba(34,197,94,0.12); color: var(--status-active); } +.badge-completed { background: rgba(201,169,110,0.12); color: var(--accent); } +.badge-aborted { background: rgba(239,68,68,0.12); color: var(--status-error); } +.badge-muted { background: rgba(113,113,122,0.12); color: var(--status-muted); } + +/* ── Buttons ─────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 16px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 13px; font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; + min-height: 36px; +} +.btn:hover { background: var(--bg-hover); border-color: var(--accent); } +.btn-primary { + background: var(--accent); color: var(--text-inverse); + border-color: var(--accent); +} +.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.btn-ghost { background: none; border: none; } +.btn-sm { padding: 4px 10px; font-size: 12px; min-height: 28px; } + +/* ── Forms ─────────────────────────── */ +.form-group { margin-bottom: var(--sp-4); } +.form-group label { + display: block; font-size: 11px; color: var(--accent); + text-transform: uppercase; letter-spacing: 0.06em; + margin-bottom: 6px; +} +.form-group input, .form-group select, .form-group textarea { + width: 100%; + padding: 12px var(--sp-4); + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 16px; /* prevent iOS zoom */ + font-family: inherit; + outline: none; +} +.form-group input:focus { border-color: var(--accent); } +.form-row { display: flex; gap: var(--sp-3); align-items: center; } + +/* ── Search ─────────────────────────── */ +.search-box { + display: flex; align-items: center; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--r-sm); + padding: 0 var(--sp-3); + max-width: 300px; + width: 100%; +} +.search-box input { + background: none; border: none; outline: none; + color: var(--text-primary); + padding: 8px 0; + width: 100%; font-size: 14px; +} + +/* ── Login ─────────────────────────── */ +.login-card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: var(--sp-8); +} +.login-card h1 { + margin: 0 0 var(--sp-2); font-size: 22px; font-weight: 500; + letter-spacing: -0.01em; text-align: center; +} +.login-card .subtitle { + text-align: center; color: var(--text-tertiary); + font-size: 12px; margin-bottom: var(--sp-6); + letter-spacing: 0.06em; text-transform: uppercase; +} +.error-msg { + background: rgba(239,68,68,0.1); color: var(--status-error); + padding: 10px 14px; border-radius: var(--r-sm); + font-size: 13px; margin-bottom: var(--sp-4); +} + +/* ── Footer ─────────────────────────── */ +.footer { + text-align: center; + color: var(--text-tertiary); + font-size: 11px; + padding: var(--sp-6); +} + +/* ── Page header ───────────────────── */ +.page-header { + display: flex; align-items: center; gap: var(--sp-4); + margin-bottom: var(--sp-5); +} +.page-header h2 { + font-size: 18px; font-weight: 500; margin: 0; +} diff --git a/hbrtl_ext/labdb_static/public/index.html b/hbrtl_ext/labdb_static/public/index.html new file mode 100644 index 0000000..fbde75b --- /dev/null +++ b/hbrtl_ext/labdb_static/public/index.html @@ -0,0 +1,257 @@ + + + + + +labdb — Lab Data API Admin + + + + +
+
+

labdb

+
Lab Data API
+
+
+
+ + +
+
+ +
+ +
+
+
Devices
+
+
+
+
Sessions
+
+
+
+
Records
+
+
+
+
Active
+
+
+
+ + +
+ + +
+ + + + + +
+
+ + + + + diff --git a/hbrtl_ext/labdb_static/public/js/api.js b/hbrtl_ext/labdb_static/public/js/api.js new file mode 100644 index 0000000..bc0607e --- /dev/null +++ b/hbrtl_ext/labdb_static/public/js/api.js @@ -0,0 +1,43 @@ +// labdb — Admin web client +const api = { + async _fetch(path, opts) { + const o = Object.assign({ credentials: 'same-origin' }, opts || {}); + o.headers = Object.assign({ 'Content-Type': 'application/json' }, o.headers || {}); + const res = await fetch(path, o); + if (res.status === 401 && !location.pathname.endsWith('/login.html')) { + location.href = '/login.html'; + return { error: 'unauthorized' }; + } + try { return await res.json(); } catch (_) { return { error: 'invalid response' }; } + }, + get(p) { return this._fetch(p); }, + post(p, body) { return this._fetch(p, { method: 'POST', body: JSON.stringify(body || {}) }); }, + patch(p, body) { return this._fetch(p, { method: 'PATCH', body: JSON.stringify(body || {}) }); }, + del(p) { return this._fetch(p, { method: 'DELETE' }); }, +}; + +function esc(s) { + if (s === null || s === undefined) return ''; + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function formatDate(iso) { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d)) return '—'; + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function formatDuration(start, end) { + if (!start || !end) return '—'; + const ms = new Date(end) - new Date(start); + if (isNaN(ms) || ms < 0) return '—'; + const sec = Math.floor(ms / 1000); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; +} diff --git a/hbrtl_ext/labdb_static/public/login.html b/hbrtl_ext/labdb_static/public/login.html new file mode 100644 index 0000000..2f7bd7f --- /dev/null +++ b/hbrtl_ext/labdb_static/public/login.html @@ -0,0 +1,55 @@ + + + + + +Sign In — labdb + + + + +
+ + +
+ + + + +