Files
fivenode_go/hbrtl_ext/labdb_static/public/index.html
Charles KWON OhJun 47b8f0434a 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>
2026-05-27 17:01:09 +09:00

258 lines
9.0 KiB
HTML

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