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) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,23 @@ FUNCTION Main()
|
|||||||
RETURN NIL
|
RETURN NIL
|
||||||
|
|
||||||
FUNCTION BridgeDispatch( hReq )
|
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 := { ;
|
hCtx := { ;
|
||||||
"method" => hReq[ "method" ], ;
|
"method" => hReq[ "method" ], ;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ var defaultRTL = []string{
|
|||||||
"fivenode_go/hbrtl_ext/pgrtl",
|
"fivenode_go/hbrtl_ext/pgrtl",
|
||||||
"fivenode_go/hbrtl_ext/dispatch",
|
"fivenode_go/hbrtl_ext/dispatch",
|
||||||
"fivenode_go/hbrtl_ext/labdb_state",
|
"fivenode_go/hbrtl_ext/labdb_state",
|
||||||
|
"fivenode_go/hbrtl_ext/labdb_static",
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
86
hbrtl_ext/labdb_static/assets.go
Normal file
86
hbrtl_ext/labdb_static/assets.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
277
hbrtl_ext/labdb_static/public/css/app.css
Normal file
277
hbrtl_ext/labdb_static/public/css/app.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
257
hbrtl_ext/labdb_static/public/index.html
Normal file
257
hbrtl_ext/labdb_static/public/index.html
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>labdb — Lab Data API Admin</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css?v=__V__">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<div>
|
||||||
|
<h1>labdb</h1>
|
||||||
|
<div class="subtitle">Lab Data API</div>
|
||||||
|
</div>
|
||||||
|
<div class="header-spacer"></div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span id="userBadge" class="text-meta"></span>
|
||||||
|
<button class="btn btn-ghost btn-sm" onclick="doLogout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="stats-grid" id="statsGrid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Devices</div>
|
||||||
|
<div class="stat-value" id="statDevices">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Sessions</div>
|
||||||
|
<div class="stat-value" id="statSessions">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Records</div>
|
||||||
|
<div class="stat-value" id="statRecords">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Active</div>
|
||||||
|
<div class="stat-value" id="statActive">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="sessions" onclick="setTab('sessions')">Sessions</button>
|
||||||
|
<button class="tab" data-tab="devices" onclick="setTab('devices')">Devices</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Page header -->
|
||||||
|
<div class="page-header">
|
||||||
|
<h2 id="pageTitle">Sessions (newest first)</h2>
|
||||||
|
<button class="btn btn-sm" id="refreshBtn" onclick="refreshCurrent()" title="Refresh this tab only">
|
||||||
|
<span id="refreshIcon">↻</span> Refresh
|
||||||
|
</button>
|
||||||
|
<div class="header-spacer"></div>
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="searchInput" placeholder="Search session name..." oninput="onSearch()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="table-wrap" id="tableWrap"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js?v=__V__"></script>
|
||||||
|
<script>
|
||||||
|
let currentTab = 'sessions';
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const me = await api.get('/api/admin/me');
|
||||||
|
if (!me.ok) { location.href = '/login.html'; return; }
|
||||||
|
document.getElementById('userBadge').textContent = me.user.name + ' (' + me.user.role + ')';
|
||||||
|
loadStats();
|
||||||
|
loadCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
const s = await api.get('/api/admin/stats');
|
||||||
|
if (s && !s.error) {
|
||||||
|
document.getElementById('statDevices').textContent = s.devices ?? '—';
|
||||||
|
document.getElementById('statSessions').textContent = s.sessions ?? '—';
|
||||||
|
document.getElementById('statRecords').textContent = s.records ?? '—';
|
||||||
|
document.getElementById('statActive').textContent = s.active_sessions ?? '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||||
|
document.getElementById('pageTitle').textContent = tab === 'sessions' ? 'Sessions (newest first)' : 'Devices';
|
||||||
|
document.getElementById('searchInput').placeholder = tab === 'sessions' ? 'Search session name...' : 'Search device...';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
loadCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(loadCurrent, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCurrent() {
|
||||||
|
const q = document.getElementById('searchInput').value;
|
||||||
|
if (currentTab === 'sessions') await loadSessions(q);
|
||||||
|
else await loadDevices(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCurrent() {
|
||||||
|
// Animate the refresh icon
|
||||||
|
const icon = document.getElementById('refreshIcon');
|
||||||
|
const btn = document.getElementById('refreshBtn');
|
||||||
|
if (icon) {
|
||||||
|
icon.style.display = 'inline-block';
|
||||||
|
icon.style.transition = 'transform 0.6s ease';
|
||||||
|
icon.style.transform = 'rotate(360deg)';
|
||||||
|
setTimeout(() => { icon.style.transition = 'none'; icon.style.transform = 'rotate(0deg)'; }, 700);
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
// Refresh ONLY current tab (not stats)
|
||||||
|
await loadCurrent();
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessions(q) {
|
||||||
|
const url = '/api/admin/sessions?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
|
||||||
|
const r = await api.get(url);
|
||||||
|
if (!r || r.error) {
|
||||||
|
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta">Failed to load.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.sessions || r.sessions.length === 0) {
|
||||||
|
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta" style="text-align:center; padding:60px;">No sessions yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Start</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th class="text-right">Records</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
for (const s of r.sessions) {
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div>${esc(s.session_name || s.session_id)}</div>
|
||||||
|
<div class="text-meta text-mono">${esc(s.session_id)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${esc(s.device_name || '—')}</td>
|
||||||
|
<td class="text-mono">${formatDate(s.start_time)}</td>
|
||||||
|
<td class="text-mono">${formatDuration(s.start_time, s.end_time)}</td>
|
||||||
|
<td class="text-right text-mono">${s.record_count || 0}</td>
|
||||||
|
<td><span class="badge badge-${esc(s.status || 'muted')}">${esc(s.status || '—')}</span></td>
|
||||||
|
<td class="text-meta">${esc(s.note || '')}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('tableWrap').innerHTML = html;
|
||||||
|
document.getElementById('pageTitle').textContent = `Sessions (${r.total} total, newest first)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDevices(q) {
|
||||||
|
const r = await api.get('/api/admin/devices');
|
||||||
|
if (!r || r.error) {
|
||||||
|
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta">Failed to load.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const devices = r.devices || [];
|
||||||
|
const filtered = q ? devices.filter(d => (d.device_name || '').toLowerCase().includes(q.toLowerCase()) || (d.device_id || '').toLowerCase().includes(q.toLowerCase())) : devices;
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
document.getElementById('tableWrap').innerHTML = '<div class="card-padded text-meta" style="text-align:center; padding:60px;">No devices registered.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>App</th>
|
||||||
|
<th>MAC</th>
|
||||||
|
<th>Registered</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
for (const d of filtered) {
|
||||||
|
const did = esc(d.device_id);
|
||||||
|
let actions;
|
||||||
|
if (d.is_active) {
|
||||||
|
actions = `
|
||||||
|
<button class="btn btn-sm" onclick="deviceAction('${did}', 'revoke')">Revoke</button>
|
||||||
|
<button class="btn btn-sm" onclick="deviceAction('${did}', 'delete')" style="color:var(--status-error)">Delete</button>`;
|
||||||
|
} else {
|
||||||
|
actions = `
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="deviceAction('${did}', 'approve')">Approve</button>
|
||||||
|
<button class="btn btn-sm" onclick="deviceAction('${did}', 'delete')" style="color:var(--status-error)">Delete</button>`;
|
||||||
|
}
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div>${esc(d.device_name || '—')}</div>
|
||||||
|
<div class="text-meta text-mono">${esc(d.device_id)}</div>
|
||||||
|
</td>
|
||||||
|
<td>${esc(d.app_name || '—')}</td>
|
||||||
|
<td class="text-mono">${esc(d.device_address || '—')}</td>
|
||||||
|
<td class="text-mono">${formatDate(d.created_at)}</td>
|
||||||
|
<td><span class="badge ${d.is_active ? 'badge-active' : 'badge-completed'}">${d.is_active ? 'active' : 'pending'}</span></td>
|
||||||
|
<td>${actions}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
html += '</tbody></table>';
|
||||||
|
document.getElementById('tableWrap').innerHTML = html;
|
||||||
|
document.getElementById('pageTitle').textContent = `Devices (${filtered.length})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deviceAction(deviceId, action) {
|
||||||
|
const messages = {
|
||||||
|
approve: 'Approve this device? It will be able to call the API.',
|
||||||
|
revoke: 'Revoke this device? It will lose API access.',
|
||||||
|
delete: 'PERMANENTLY DELETE this device and ALL its sessions/records?'
|
||||||
|
};
|
||||||
|
if (!confirm(messages[action])) return;
|
||||||
|
let result;
|
||||||
|
if (action === 'delete') {
|
||||||
|
result = await api.del('/api/admin/devices/' + encodeURIComponent(deviceId));
|
||||||
|
} else {
|
||||||
|
result = await api.post('/api/admin/devices/' + encodeURIComponent(deviceId) + '/' + action);
|
||||||
|
}
|
||||||
|
if (result && result.ok) {
|
||||||
|
loadStats();
|
||||||
|
loadDevices();
|
||||||
|
} else {
|
||||||
|
alert('Failed: ' + ((result && result.error && result.error.message) || 'unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
await api.post('/api/admin/logout');
|
||||||
|
location.href = '/login.html';
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
43
hbrtl_ext/labdb_static/public/js/api.js
Normal file
43
hbrtl_ext/labdb_static/public/js/api.js
Normal file
@@ -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, '"').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')}`;
|
||||||
|
}
|
||||||
55
hbrtl_ext/labdb_static/public/login.html
Normal file
55
hbrtl_ext/labdb_static/public/login.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>Sign In — labdb</title>
|
||||||
|
<link rel="stylesheet" href="/css/app.css?v=__V__">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container-sm">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>labdb</h1>
|
||||||
|
<div class="subtitle">Lab Data API · Admin</div>
|
||||||
|
|
||||||
|
<div id="errorBox" class="error-msg hidden"></div>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Username</label>
|
||||||
|
<input type="text" id="username" autocomplete="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" id="password" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%; justify-content:center;">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
© 2026 MEDiThings Inc. · labdb v1.0
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/api.js?v=__V__"></script>
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const errBox = document.getElementById('errorBox');
|
||||||
|
errBox.classList.add('hidden');
|
||||||
|
const username = document.getElementById('username').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const result = await api.post('/api/admin/login', { username, password });
|
||||||
|
if (result.ok) {
|
||||||
|
location.href = '/';
|
||||||
|
} else {
|
||||||
|
errBox.textContent = (result.error && result.error.message) || result.error || 'Login failed';
|
||||||
|
errBox.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// auto-redirect if already logged in
|
||||||
|
api.get('/api/admin/me').then(r => { if (r.ok) location.href = '/'; });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user