cli/pkg/lab/handler/templates/layout.html
Claude 50da0adcb7 feat: integrate lab dashboard as core lab serve
Port the standalone lab dashboard (lab.lthn.io) into the core CLI as
pkg/lab/ with collectors, handlers, and HTML templates. The dashboard
monitors machines, Docker containers, Forgejo, HuggingFace models,
training runs, and InfluxDB metrics with SSE live updates.

New command: core lab serve --bind :8080

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 05:53:52 +00:00

103 lines
5.5 KiB
HTML

{{define "head"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.}} - LEM.Lab</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer"/>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#0a0a0f;--surface:#12121a;--border:#1e1e2e;--text:#e0e0e8;--muted:#8888a0;--accent:#7c6ff0;--accent-dim:#5a4fd0;--green:#4ade80;--red:#f87171;--yellow:#fbbf24}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;line-height:1.6;font-size:.9375rem}
a{color:var(--accent);text-decoration:none;transition:color .2s}
a:hover{color:var(--green)}
nav{display:flex;align-items:center;gap:1.5rem;padding:.75rem 1.5rem;border-bottom:1px solid var(--border);background:var(--surface)}
nav .logo{font-size:1.25rem;font-weight:700;letter-spacing:-.02em}
nav .logo span{color:var(--accent)}
nav .links{display:flex;gap:.25rem}
nav .links a{padding:.375rem .75rem;border-radius:6px;font-size:.8125rem;color:var(--muted);transition:all .2s}
nav .links a:hover,nav .links a.active{color:var(--text);background:var(--bg)}
.container{max-width:1600px;margin:0 auto;padding:1.5rem}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1rem;margin-bottom:1.5rem}
.card{padding:1.25rem;border:1px solid var(--border);border-radius:8px;background:var(--surface)}
.card h3{font-size:.8125rem;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:.5rem}
.card .value{font-size:1.75rem;font-weight:700;line-height:1.2}
.card .sub{font-size:.8125rem;color:var(--muted);margin-top:.25rem}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:.375rem}
.status-ok .status-dot{background:var(--green)}
.status-warn .status-dot{background:var(--yellow)}
.status-err .status-dot{background:var(--red)}
.status-ok .label{color:var(--green)}
.status-warn .label{color:var(--yellow)}
.status-err .label{color:var(--red)}
.progress-bar{width:100%;height:8px;background:var(--border);border-radius:4px;overflow:hidden;margin:.5rem 0}
.progress-bar .fill{height:100%;background:var(--accent);border-radius:4px;transition:width .5s}
table{width:100%;border-collapse:collapse;font-size:.8125rem}
th{text-align:left;color:var(--muted);font-weight:600;padding:.5rem .75rem;border-bottom:1px solid var(--border);text-transform:uppercase;letter-spacing:.05em;font-size:.75rem}
td{padding:.5rem .75rem;border-bottom:1px solid var(--border)}
tr:last-child td{border-bottom:none}
code{font-family:"SF Mono",Consolas,monospace;font-size:.75rem;background:var(--bg);padding:.125rem .375rem;border-radius:4px;border:1px solid var(--border)}
.badge{display:inline-block;padding:.125rem .5rem;border-radius:4px;font-size:.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em}
.badge-ok{background:rgba(74,222,128,.15);color:var(--green)}
.badge-err{background:rgba(248,113,113,.15);color:var(--red)}
.badge-info{background:rgba(124,111,240,.15);color:var(--accent)}
.empty{text-align:center;padding:2rem;color:var(--muted)}
.section-title{font-size:1rem;font-weight:600;margin-bottom:1rem;color:var(--text)}
footer{text-align:center;padding:1rem;color:var(--muted);font-size:.75rem;border-top:1px solid var(--border);margin-top:2rem}
@media(max-width:640px){.grid{grid-template-columns:1fr}nav{flex-wrap:wrap;gap:.75rem}}
</style>
</head>
<body>{{end}}
{{define "nav"}}
<nav>
<div class="logo">LEM<span>.Lab</span></div>
<div class="links">
<a href="/"{{if eq . "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/models"{{if eq . "models"}} class="active"{{end}}>Models</a>
<a href="/training"{{if eq . "training"}} class="active"{{end}}>Training</a>
<a href="/dataset"{{if eq . "dataset"}} class="active"{{end}}>Dataset</a>
<a href="/agents"{{if eq . "agents"}} class="active"{{end}}>Agents</a>
<a href="/services"{{if eq . "services"}} class="active"{{end}}>Services</a>
</div>
</nav>
<div class="container">{{end}}
{{define "footer"}}
</div>
<footer>LEM.Lab &middot; live &middot; <a href="https://forge.lthn.io/agentic">forge.lthn.io</a></footer>
<script>
// SSE live update: fetches same-origin page on data change, swaps container content.
// Safe: only fetches from same origin (our own server), no user input involved.
(function(){
var es, timer;
function connect(){
es = new EventSource('/events');
es.onmessage = function(){
clearTimeout(timer);
timer = setTimeout(refresh, 500);
};
es.onerror = function(){
es.close();
setTimeout(connect, 5000);
};
}
function refresh(){
fetch(location.href).then(function(r){ return r.text(); }).then(function(html){
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = doc.querySelector('.container');
var current = document.querySelector('.container');
if(fresh && current){
// Save active tab before replacing DOM.
var activeTab = document.querySelector('.chart-panel.active');
var tabName = activeTab ? activeTab.getAttribute('data-tab') : null;
current.replaceWith(fresh);
// Restore active tab after DOM swap.
if(tabName && typeof showTab === 'function') showTab(tabName);
}
});
}
connect();
})();
</script>
</body></html>{{end}}