Compare commits

...

6 Commits
v2.2 ... v2.6

Author SHA1 Message Date
b4371e291e fix(build): resolve ISO version from plain v* tags (e.g. v2.6)
resolve_iso_version only matched iso/v* pattern; GUI release tags
(v2, v2.1 ... v2.6) were ignored, falling back to the old v1.0.20
annotated tag via resolve_audit_version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:11:33 +03:00
c22b53a406 feat(boot): set 1920x1080 resolution for framebuffer and GRUB
- Add video=1920x1080 to kernel cmdline (sets fbdev to Full HD)
- Update GRUB gfxmode to 1920x1080 (fallback to 1280x1024,auto)
- Add Xorg Monitor section with 1920x1080 Modeline and preferred mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:10:18 +03:00
ff0acc3698 feat(webui): server-side SVG charts + reanimator-chart viewer
Metrics:
- Replace canvas JS charts with server-side SVG via go-analyze/charts
- Add ring buffers (120 samples) for CPU temp and power
- /api/metrics/chart/{name}.svg endpoint serves live SVG, polled every 2s

Dashboard:
- Replace custom renderViewerPage with viewer.RenderHTML() from reanimator/chart submodule
- Mount chart static assets at /chart/static/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:07:47 +03:00
d50760e7c6 fix(webui): remove emojis from nav, fix metrics chart sizing
- Remove all emojis from sidebar nav and logo (broken on server console fonts)
- Fix canvas chart: use parentElement.getBoundingClientRect() for width,
  set explicit H=120px — fixes empty charts when offsetWidth/Height is 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:49:09 +03:00
ed4f8be019 fix(webui): services table — show state badge, full status on click
Replace raw systemctl output in table cell with:
- state badge (active/failed/inactive) — click to expand
- full systemctl status in collapsible pre block (max 200px scroll)
Fixes layout explosion from multi-line status text in table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:47:59 +03:00
883592d029 feat(desktop): switch to LightDM for X startup (matches Ubuntu LiveCD)
startx from user shell has /dev/fb0 permission issues and is fragile.
LightDM starts Xorg as root — standard LiveCD approach that works
on server hardware / IPMI KVM with nomodeset + fbdev/vesa.

- Add lightdm package, configure autologin as bee/openbox session
- Add /usr/share/xsessions/openbox.desktop
- Remove startx from .profile (LightDM manages X lifecycle)
- Remove Xwrapper.config needs_root_rights workaround (no longer needed)
- Enable lightdm.service in setup hook

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 22:17:59 +03:00
16 changed files with 255 additions and 123 deletions

View File

@@ -1,3 +1,17 @@
module bee/audit module bee/audit
go 1.24.0 go 1.24.0
replace reanimator/chart => ../internal/chart
require (
github.com/go-analyze/charts v0.5.26
reanimator/chart v0.0.0-00010101000000-000000000000
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-analyze/bulk v0.1.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
golang.org/x/image v0.24.0 // indirect
)

18
audit/go.sum Normal file
View File

@@ -0,0 +1,18 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-analyze/bulk v0.1.3 h1:pzRdBqzHDAT9PyROt0SlWE0YqPtdmTcEpIJY0C3vF0c=
github.com/go-analyze/bulk v0.1.3/go.mod h1:afon/KtFJYnekIyN20H/+XUvcLFjE8sKR1CfpqfClgM=
github.com/go-analyze/charts v0.5.26 h1:rSwZikLQuFX6cJzwI8OAgaWZneG1kDYxD857ms00ZxY=
github.com/go-analyze/charts v0.5.26/go.mod h1:s1YvQhjiSwtLx1f2dOKfiV9x2TT49nVSL6v2rlRpTbY=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -57,6 +57,7 @@ type networkManager interface {
type serviceManager interface { type serviceManager interface {
ListBeeServices() ([]string, error) ListBeeServices() ([]string, error)
ServiceState(name string) string
ServiceStatus(name string) (string, error) ServiceStatus(name string) (string, error)
ServiceDo(name string, action platform.ServiceAction) (string, error) ServiceDo(name string, action platform.ServiceAction) (string, error)
} }
@@ -356,6 +357,10 @@ func (a *App) ListBeeServices() ([]string, error) {
return a.services.ListBeeServices() return a.services.ListBeeServices()
} }
func (a *App) ServiceState(name string) string {
return a.services.ServiceState(name)
}
func (a *App) ServiceStatus(name string) (string, error) { func (a *App) ServiceStatus(name string) (string, error) {
return a.services.ServiceStatus(name) return a.services.ServiceStatus(name)
} }

View File

@@ -52,6 +52,10 @@ func (f fakeServices) ListBeeServices() ([]string, error) {
return nil, nil return nil, nil
} }
func (f fakeServices) ServiceState(name string) string {
return "active"
}
func (f fakeServices) ServiceStatus(name string) (string, error) { func (f fakeServices) ServiceStatus(name string) (string, error) {
return f.serviceStatusFn(name) return f.serviceStatusFn(name)
} }

View File

@@ -236,13 +236,15 @@ func (h *handler) handleAPIServicesList(w http.ResponseWriter, r *http.Request)
return return
} }
type serviceInfo struct { type serviceInfo struct {
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` State string `json:"state"`
Body string `json:"body"`
} }
result := make([]serviceInfo, 0, len(names)) result := make([]serviceInfo, 0, len(names))
for _, name := range names { for _, name := range names {
status, _ := h.opts.App.ServiceStatus(name) state := h.opts.App.ServiceState(name)
result = append(result, serviceInfo{Name: name, Status: status}) body, _ := h.opts.App.ServiceStatus(name)
result = append(result, serviceInfo{Name: name, State: state, Body: body})
} }
writeJSON(w, result) writeJSON(w, result)
} }
@@ -421,6 +423,16 @@ func (h *handler) handleAPIMetricsStream(w http.ResponseWriter, r *http.Request)
return return
case <-ticker.C: case <-ticker.C:
sample := platform.SampleLiveMetrics() sample := platform.SampleLiveMetrics()
// Feed ring buffers for server-side SVG charts
for _, t := range sample.Temps {
if t.Name == "CPU" {
h.ringCPUTemp.push(t.Celsius)
break
}
}
h.ringPower.push(sample.PowerW)
b, err := json.Marshal(sample) b, err := json.Marshal(sample)
if err != nil { if err != nil {
continue continue

View File

@@ -66,9 +66,6 @@ tr:hover td{background:#1a2030}
.form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px} .form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:5px}
.form-row input,.form-row select{width:100%;padding:8px 10px;background:#0f1117;border:1px solid #252d3d;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none} .form-row input,.form-row select{width:100%;padding:8px 10px;background:#0f1117;border:1px solid #252d3d;border-radius:6px;color:#e2e8f0;font-size:13px;outline:none}
.form-row input:focus,.form-row select:focus{border-color:#3b82f6} .form-row input:focus,.form-row select:focus{border-color:#3b82f6}
/* Metrics chart */
.chart-wrap{position:relative;height:140px;background:#0a0d14;border-radius:8px;overflow:hidden;margin-bottom:8px}
canvas.chart{width:100%;height:100%}
.chart-legend{font-size:11px;color:#64748b;padding:4px 0} .chart-legend{font-size:11px;color:#64748b;padding:4px 0}
/* Grid */ /* Grid */
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px} .grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@@ -88,18 +85,18 @@ canvas.chart{width:100%;height:100%}
func layoutNav(active string) string { func layoutNav(active string) string {
items := []struct{ id, icon, label string }{ items := []struct{ id, icon, label string }{
{"dashboard", "📊", "Dashboard"}, {"dashboard", "", "Dashboard"},
{"metrics", "📈", "Metrics"}, {"metrics", "", "Metrics"},
{"tests", "🧪", "Acceptance Tests"}, {"tests", "", "Acceptance Tests"},
{"burn-in", "🔥", "Burn-in"}, {"burn-in", "", "Burn-in"},
{"network", "🌐", "Network"}, {"network", "", "Network"},
{"services", "⚙️", "Services"}, {"services", "", "Services"},
{"export", "📦", "Export"}, {"export", "", "Export"},
{"tools", "🔧", "Tools"}, {"tools", "", "Tools"},
} }
var b strings.Builder var b strings.Builder
b.WriteString(`<aside class="sidebar">`) b.WriteString(`<aside class="sidebar">`)
b.WriteString(`<div class="sidebar-logo">🐝 bee<span>hardware audit</span></div>`) b.WriteString(`<div class="sidebar-logo">bee<span>hardware audit</span></div>`)
b.WriteString(`<nav class="nav">`) b.WriteString(`<nav class="nav">`)
for _, item := range items { for _, item := range items {
cls := "nav-item" cls := "nav-item"
@@ -110,8 +107,8 @@ func layoutNav(active string) string {
if item.id != "dashboard" { if item.id != "dashboard" {
href = "/" + item.id href = "/" + item.id
} }
b.WriteString(fmt.Sprintf(`<a class="%s" href="%s"><span class="nav-icon">%s</span>%s</a>`, b.WriteString(fmt.Sprintf(`<a class="%s" href="%s">%s</a>`,
cls, href, item.icon, item.label)) cls, href, item.label))
} }
b.WriteString(`</nav></aside>`) b.WriteString(`</nav></aside>`)
return b.String() return b.String()
@@ -245,92 +242,36 @@ func renderHealthCard(opts HandlerOptions) string {
// ── Metrics ─────────────────────────────────────────────────────────────────── // ── Metrics ───────────────────────────────────────────────────────────────────
func renderMetrics() string { func renderMetrics() string {
return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Live server metrics updated every second.</p> return `<p style="color:#64748b;font-size:13px;margin-bottom:16px">Live server metrics, charts updated every 2 seconds.</p>
<div class="grid2"> <div class="grid2">
<div class="card"> <div class="card">
<div class="card-head">GPU Metrics</div> <div class="card-head">System</div>
<div class="card-body"> <div class="card-body">
<div class="chart-wrap"><canvas id="chart-gpu-temp" class="chart"></canvas></div> <img id="chart-cpu-temp" src="/api/metrics/chart/cpu-temp.svg" style="width:100%;border-radius:6px" alt="CPU Temp">
<div class="chart-legend">Temperature °C</div> <img id="chart-power" src="/api/metrics/chart/power.svg" style="width:100%;border-radius:6px;margin-top:8px" alt="Power">
<div class="chart-wrap"><canvas id="chart-gpu-usage" class="chart"></canvas></div> <div id="sys-table" style="margin-top:8px"></div>
<div class="chart-legend">Usage %</div>
<div class="chart-wrap"><canvas id="chart-gpu-power" class="chart"></canvas></div>
<div class="chart-legend">Power W</div>
<div id="gpu-table"></div>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-head">System Metrics</div> <div class="card-head">GPU</div>
<div class="card-body"> <div class="card-body">
<div class="chart-wrap"><canvas id="chart-cpu-temp" class="chart"></canvas></div> <div id="gpu-table"><p style="color:#64748b;font-size:12px">Waiting for data...</p></div>
<div class="chart-legend">CPU Temperature °C</div>
<div class="chart-wrap"><canvas id="chart-fans" class="chart"></canvas></div>
<div class="chart-legend">Fan Speed RPM</div>
<div class="chart-wrap"><canvas id="chart-power" class="chart"></canvas></div>
<div class="chart-legend">System Power W</div>
<div id="sys-table"></div>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
const WINDOW = 120; function refreshCharts() {
const history = {gpuTemp:[],gpuUsage:[],gpuPower:[],cpuTemp:[],fans:[],power:[]}; const t = '?t=' + Date.now();
const colors = ['#60a5fa','#34d399','#f87171','#fbbf24','#a78bfa','#fb7185']; ['chart-cpu-temp','chart-power'].forEach(id => {
const el = document.getElementById(id);
function push(arr, val) { arr.push(val); if (arr.length > WINDOW) arr.shift(); } if (el) el.src = el.src.split('?')[0] + t;
function drawChart(canvasId, datasets, maxY) {
const c = document.getElementById(canvasId);
if (!c) return;
const W = c.offsetWidth, H = c.offsetHeight;
c.width = W; c.height = H;
const ctx = c.getContext('2d');
ctx.clearRect(0,0,W,H);
ctx.fillStyle = '#0a0d14';
ctx.fillRect(0,0,W,H);
// grid
ctx.strokeStyle = '#1e2535';
ctx.lineWidth = 1;
for (let y = 0; y <= 4; y++) {
const py = H * y / 4;
ctx.beginPath(); ctx.moveTo(0,py); ctx.lineTo(W,py); ctx.stroke();
}
const max = maxY || datasets.reduce((m,d) => Math.max(m,...d.map(v=>v||0)), 1) || 1;
datasets.forEach((data, i) => {
if (!data.length) return;
ctx.strokeStyle = colors[i % colors.length];
ctx.lineWidth = 1.5;
ctx.beginPath();
data.forEach((v, j) => {
const x = W * j / Math.max(data.length - 1, 1);
const y = H - H * (v || 0) / max;
j === 0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
});
ctx.stroke();
}); });
} }
setInterval(refreshCharts, 2000);
const es = new EventSource('/api/metrics/stream'); const es = new EventSource('/api/metrics/stream');
es.addEventListener('metrics', e => { es.addEventListener('metrics', e => {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
// GPU
const gpuTemps = (d.gpus||[]).map(g => g.temp_c||0);
const gpuUsages = (d.gpus||[]).map(g => g.usage_pct||0);
const gpuPowers = (d.gpus||[]).map(g => g.power_w||0);
if (!history.gpuTemp.length && gpuTemps.length) {
history.gpuTemp = gpuTemps.map(() => []);
history.gpuUsage = gpuUsages.map(() => []);
history.gpuPower = gpuPowers.map(() => []);
}
gpuTemps.forEach((v,i) => push(history.gpuTemp[i]||[], v));
gpuUsages.forEach((v,i) => push(history.gpuUsage[i]||[], v));
gpuPowers.forEach((v,i) => push(history.gpuPower[i]||[], v));
drawChart('chart-gpu-temp', history.gpuTemp.length ? history.gpuTemp : [history.cpuTemp]);
drawChart('chart-gpu-usage', history.gpuUsage, 100);
drawChart('chart-gpu-power', history.gpuPower);
// GPU table
const gpuRows = (d.gpus||[]).map(g => const gpuRows = (d.gpus||[]).map(g =>
'<tr><td>GPU '+g.index+'</td><td>'+g.temp_c+'°C</td><td>'+g.usage_pct+'%</td><td>'+g.power_w+'W</td><td>'+g.clock_mhz+'MHz</td></tr>' '<tr><td>GPU '+g.index+'</td><td>'+g.temp_c+'°C</td><td>'+g.usage_pct+'%</td><td>'+g.power_w+'W</td><td>'+g.clock_mhz+'MHz</td></tr>'
).join(''); ).join('');
@@ -338,27 +279,16 @@ es.addEventListener('metrics', e => {
'<table><tr><th>GPU</th><th>Temp</th><th>Usage</th><th>Power</th><th>Clock</th></tr>'+gpuRows+'</table>' : '<table><tr><th>GPU</th><th>Temp</th><th>Usage</th><th>Power</th><th>Clock</th></tr>'+gpuRows+'</table>' :
'<p style="color:#64748b;font-size:12px">No NVIDIA GPU detected</p>'; '<p style="color:#64748b;font-size:12px">No NVIDIA GPU detected</p>';
// System
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
push(history.cpuTemp, cpuTemp ? cpuTemp.celsius : 0);
const fanRPMs = (d.fans||[]).map(f => f.rpm||0);
if (!history.fans.length && fanRPMs.length) history.fans = fanRPMs.map(() => []);
fanRPMs.forEach((v,i) => push(history.fans[i]||[], v));
push(history.power, d.power_w||0);
drawChart('chart-cpu-temp', [history.cpuTemp]);
drawChart('chart-fans', history.fans.length ? history.fans : [[]]);
drawChart('chart-power', [history.power]);
// Sys table
let sysHTML = ''; let sysHTML = '';
const cpuTemp = (d.temps||[]).find(t => t.name==='CPU');
if (cpuTemp) sysHTML += '<tr><td>CPU Temp</td><td>'+cpuTemp.celsius.toFixed(1)+'°C</td></tr>'; if (cpuTemp) sysHTML += '<tr><td>CPU Temp</td><td>'+cpuTemp.celsius.toFixed(1)+'°C</td></tr>';
(d.fans||[]).forEach(f => sysHTML += '<tr><td>'+f.name+'</td><td>'+f.rpm+' RPM</td></tr>'); (d.fans||[]).forEach(f => sysHTML += '<tr><td>'+f.name+'</td><td>'+f.rpm+' RPM</td></tr>');
if (d.power_w) sysHTML += '<tr><td>System Power</td><td>'+d.power_w.toFixed(0)+'W</td></tr>'; if (d.power_w) sysHTML += '<tr><td>System Power</td><td>'+d.power_w.toFixed(0)+'W</td></tr>';
document.getElementById('sys-table').innerHTML = sysHTML ? document.getElementById('sys-table').innerHTML = sysHTML ?
'<table style="margin-top:8px">'+sysHTML+'</table>' : '<table>'+sysHTML+'</table>' :
'<p style="color:#64748b;font-size:12px">No sensor data (requires ipmitool/sensors)</p>'; '<p style="color:#64748b;font-size:12px">No sensor data (ipmitool/sensors required)</p>';
}); });
es.onerror = () => { /* reconnect automatically */ }; es.onerror = () => {};
</script>` </script>`
} }
@@ -515,9 +445,16 @@ func renderServices() string {
function loadServices() { function loadServices() {
fetch('/api/services').then(r=>r.json()).then(svcs => { fetch('/api/services').then(r=>r.json()).then(svcs => {
const rows = svcs.map(s => { const rows = svcs.map(s => {
const st = s.status||'unknown'; const st = s.state||'unknown';
const badge = st.includes('active') ? 'badge-ok' : st.includes('failed') ? 'badge-err' : 'badge-warn'; const badge = st==='active' ? 'badge-ok' : st==='failed' ? 'badge-err' : 'badge-warn';
return '<tr><td>'+s.name+'</td><td><span class="badge '+badge+'">'+st+'</span></td><td>' + const id = 'svc-body-'+s.name.replace(/[^a-z0-9]/g,'-');
const body = (s.body||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return '<tr>' +
'<td style="white-space:nowrap">'+s.name+'</td>' +
'<td style="white-space:nowrap"><span class="badge '+badge+'" style="cursor:pointer" onclick="toggleBody(\''+id+'\')">'+st+' ▾</span>' +
'<div id="'+id+'" style="display:none;margin-top:6px"><pre style="font-size:11px;white-space:pre-wrap;word-break:break-all;max-height:200px;overflow-y:auto;background:#0a0d14;padding:8px;border-radius:6px;color:#94a3b8">'+body+'</pre></div>' +
'</td>' +
'<td style="white-space:nowrap">' +
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'start\')">Start</button> ' + '<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'start\')">Start</button> ' +
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'stop\')">Stop</button> ' + '<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'stop\')">Stop</button> ' +
'<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'restart\')">Restart</button>' + '<button class="btn btn-sm btn-secondary" onclick="svcAction(\''+s.name+'\',\'restart\')">Restart</button>' +
@@ -527,6 +464,10 @@ function loadServices() {
'<table><tr><th>Service</th><th>Status</th><th>Actions</th></tr>'+rows+'</table>'; '<table><tr><th>Service</th><th>Status</th><th>Actions</th></tr>'+rows+'</table>';
}); });
} }
function toggleBody(id) {
const el = document.getElementById(id);
if (el) el.style.display = el.style.display==='none' ? 'block' : 'none';
}
function svcAction(name, action) { function svcAction(name, action) {
fetch('/api/services/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,action})}) fetch('/api/services/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,action})})
.then(r=>r.json()).then(d => { .then(r=>r.json()).then(d => {

View File

@@ -8,9 +8,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync"
"time"
"bee/audit/internal/app" "bee/audit/internal/app"
"bee/audit/internal/runtimeenv" "bee/audit/internal/runtimeenv"
gocharts "github.com/go-analyze/charts"
"reanimator/chart/viewer"
"reanimator/chart/web"
) )
const defaultTitle = "Bee Hardware Audit" const defaultTitle = "Bee Hardware Audit"
@@ -24,10 +29,49 @@ type HandlerOptions struct {
RuntimeMode runtimeenv.Mode RuntimeMode runtimeenv.Mode
} }
// metricsRing holds a rolling window of live metric samples.
type metricsRing struct {
mu sync.Mutex
vals []float64
labels []string
size int
}
func newMetricsRing(size int) *metricsRing {
return &metricsRing{size: size, vals: make([]float64, 0, size), labels: make([]string, 0, size)}
}
func (r *metricsRing) push(v float64) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.vals) >= r.size {
r.vals = r.vals[1:]
r.labels = r.labels[1:]
}
r.vals = append(r.vals, v)
r.labels = append(r.labels, time.Now().Format("15:04"))
}
func (r *metricsRing) snapshot() ([]float64, []string) {
r.mu.Lock()
defer r.mu.Unlock()
v := make([]float64, len(r.vals))
l := make([]string, len(r.labels))
copy(v, r.vals)
copy(l, r.labels)
return v, l
}
// handler is the HTTP handler for the web UI. // handler is the HTTP handler for the web UI.
type handler struct { type handler struct {
opts HandlerOptions opts HandlerOptions
mux *http.ServeMux mux *http.ServeMux
ringCPUTemp *metricsRing
ringPower *metricsRing
ringFans []*metricsRing
ringGPUTemp []*metricsRing
ringGPUUtil []*metricsRing
ringsMu sync.Mutex
} }
// NewHandler creates the HTTP mux with all routes. // NewHandler creates the HTTP mux with all routes.
@@ -42,7 +86,11 @@ func NewHandler(opts HandlerOptions) http.Handler {
opts.RuntimeMode = runtimeenv.ModeAuto opts.RuntimeMode = runtimeenv.ModeAuto
} }
h := &handler{opts: opts} h := &handler{
opts: opts,
ringCPUTemp: newMetricsRing(120),
ringPower: newMetricsRing(120),
}
mux := http.NewServeMux() mux := http.NewServeMux()
// ── Infrastructure ────────────────────────────────────────────────────── // ── Infrastructure ──────────────────────────────────────────────────────
@@ -87,8 +135,12 @@ func NewHandler(opts HandlerOptions) http.Handler {
// Preflight // Preflight
mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight) mux.HandleFunc("GET /api/preflight", h.handleAPIPreflight)
// Metrics — SSE stream of live sensor data // Metrics — SSE stream of live sensor data + server-side SVG charts
mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream) mux.HandleFunc("GET /api/metrics/stream", h.handleAPIMetricsStream)
mux.HandleFunc("GET /api/metrics/chart/", h.handleMetricsChartSVG)
// Reanimator chart static assets
mux.Handle("GET /chart/static/", http.StripPrefix("/chart/static/", web.Static()))
// ── Pages ──────────────────────────────────────────────────────────────── // ── Pages ────────────────────────────────────────────────────────────────
mux.HandleFunc("GET /", h.handlePage) mux.HandleFunc("GET /", h.handlePage)
@@ -181,10 +233,72 @@ func (h *handler) handleExportIndex(w http.ResponseWriter, r *http.Request) {
func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) { func (h *handler) handleViewer(w http.ResponseWriter, r *http.Request) {
snapshot, _ := loadSnapshot(h.opts.AuditPath) snapshot, _ := loadSnapshot(h.opts.AuditPath)
body := renderViewerPage(h.opts.Title, snapshot) body, err := viewer.RenderHTML(snapshot, h.opts.Title)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store") w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(body)) _, _ = w.Write(body)
}
func (h *handler) handleMetricsChartSVG(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/api/metrics/chart/")
name = strings.TrimSuffix(name, ".svg")
var ring *metricsRing
var title, unit string
switch name {
case "cpu-temp":
ring, title, unit = h.ringCPUTemp, "CPU Temperature", "°C"
case "power":
ring, title, unit = h.ringPower, "System Power", "W"
default:
http.NotFound(w, r)
return
}
vals, labels := ring.snapshot()
if len(vals) == 0 {
vals = []float64{0}
labels = []string{""}
}
// Sparse x-axis labels
sparse := make([]string, len(labels))
step := len(labels) / 6
if step < 1 {
step = 1
}
for i := range labels {
if i%step == 0 {
sparse[i] = labels[i]
}
}
opt := gocharts.NewLineChartOptionWithData([][]float64{vals})
opt.Title = gocharts.TitleOption{Text: title + " (" + unit + ")"}
opt.XAxis.Labels = sparse
opt.Legend = gocharts.LegendOption{Show: gocharts.Ptr(false)}
p := gocharts.NewPainter(gocharts.PainterOptions{
OutputFormat: gocharts.ChartOutputSVG,
Width: 600,
Height: 180,
}, gocharts.PainterThemeOption(gocharts.GetTheme("grafana")))
if err := p.LineChart(opt); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
buf, err := p.Bytes()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(buf)
} }
// ── Page handler ───────────────────────────────────────────────────────────── // ── Page handler ─────────────────────────────────────────────────────────────

View File

@@ -32,6 +32,6 @@ lb config noauto \
--memtest none \ --memtest none \
--iso-volume "EASY-BEE" \ --iso-volume "EASY-BEE" \
--iso-application "EASY-BEE" \ --iso-application "EASY-BEE" \
--bootappend-live "boot=live components quiet nomodeset console=tty0 console=ttyS0,115200n8 loglevel=3 username=bee user-fullname=Bee modprobe.blacklist=nouveau" \ --bootappend-live "boot=live components quiet nomodeset video=1920x1080 console=tty0 console=ttyS0,115200n8 loglevel=3 username=bee user-fullname=Bee modprobe.blacklist=nouveau" \
--apt-recommends false \ --apt-recommends false \
"${@}" "${@}"

View File

@@ -84,6 +84,15 @@ resolve_iso_version() {
;; ;;
esac esac
# Also accept plain v* tags (e.g. v2, v2.1 used for GUI releases)
tag="$(git -C "${REPO_ROOT}" describe --tags --match 'v*' --abbrev=7 --dirty 2>/dev/null || true)"
case "${tag}" in
v*)
echo "${tag#v}"
return 0
;;
esac
# Fall back to audit version so the name is still meaningful # Fall back to audit version so the name is still meaningful
resolve_audit_version resolve_audit_version
} }

View File

@@ -8,7 +8,7 @@ else
fi fi
if loadfont $font ; then if loadfont $font ; then
set gfxmode=800x600 set gfxmode=1920x1080,1280x1024,auto
set gfxpayload=keep set gfxpayload=keep
insmod efi_gop insmod efi_gop
insmod efi_uga insmod efi_uga

View File

@@ -29,6 +29,7 @@ systemctl enable bee-audit.service
systemctl enable bee-web.service systemctl enable bee-web.service
systemctl enable bee-sshsetup.service systemctl enable bee-sshsetup.service
systemctl enable ssh.service systemctl enable ssh.service
systemctl enable lightdm.service 2>/dev/null || true
systemctl enable qemu-guest-agent.service 2>/dev/null || true systemctl enable qemu-guest-agent.service 2>/dev/null || true
systemctl enable serial-getty@ttyS0.service 2>/dev/null || true systemctl enable serial-getty@ttyS0.service 2>/dev/null || true
systemctl enable serial-getty@ttyS1.service 2>/dev/null || true systemctl enable serial-getty@ttyS1.service 2>/dev/null || true

View File

@@ -52,11 +52,11 @@ qrencode
openbox openbox
tint2 tint2
xorg xorg
xinit
xterm xterm
chromium chromium
xserver-xorg-video-fbdev xserver-xorg-video-fbdev
xserver-xorg-video-vesa xserver-xorg-video-vesa
lightdm
# Firmware # Firmware
firmware-linux-free firmware-linux-free

View File

@@ -4,7 +4,19 @@ Section "Device"
Option "fbdev" "/dev/fb0" Option "fbdev" "/dev/fb0"
EndSection EndSection
Section "Monitor"
Identifier "monitor0"
Modeline "1920x1080" 148.50 1920 2008 2052 2200 1080 1084 1089 1125 +hsync +vsync
Option "PreferredMode" "1920x1080"
EndSection
Section "Screen" Section "Screen"
Identifier "screen0" Identifier "screen0"
Device "fbdev" Device "fbdev"
Monitor "monitor0"
DefaultDepth 24
SubSection "Display"
Depth 24
Modes "1920x1080" "1280x1024" "1024x768"
EndSubSection
EndSection EndSection

View File

@@ -0,0 +1,5 @@
[Seat:*]
autologin-user=bee
autologin-user-timeout=0
autologin-session=openbox
user-session=openbox

View File

@@ -1,10 +1 @@
export PATH="/usr/local/bin:$PATH" export PATH="/usr/local/bin:$PATH"
# On tty1 (IPMI KVM console): start X desktop directly on VT1
# so the KVM shows the graphical UI without switching VTs.
if [ "$(tty 2>/dev/null)" = "/dev/tty1" ] \
&& [ -z "${DISPLAY:-}" ]; then
startx /usr/local/bin/bee-openbox-session -- :0 vt1 -nolisten tcp
echo ""
echo "X session ended. Type 'startx' to retry or use the shell."
fi

View File

@@ -0,0 +1,6 @@
[Desktop Entry]
Name=Openbox
Comment=Bee Hardware Audit Desktop
Exec=/usr/local/bin/bee-openbox-session
TryExec=openbox
Type=Application