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:
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