|
|
|
|
@@ -5,7 +5,9 @@ import (
|
|
|
|
|
"fmt"
|
|
|
|
|
"html"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
|
|
|
|
"sort"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"bee/audit/internal/app"
|
|
|
|
|
@@ -95,6 +97,17 @@ document.querySelectorAll('.terminal').forEach(function(t){
|
|
|
|
|
btn.onclick=function(){navigator.clipboard.writeText(t.textContent).then(function(){btn.textContent='Copied!';setTimeout(function(){btn.textContent='Copy';},1500);});};
|
|
|
|
|
w.appendChild(btn);
|
|
|
|
|
});
|
|
|
|
|
function openComponentDetail(type) {
|
|
|
|
|
var dlg = document.getElementById('component-detail-dialog');
|
|
|
|
|
var body = document.getElementById('component-detail-body');
|
|
|
|
|
body.innerHTML = '<div style="padding:20px;color:var(--muted)">Loading…</div>';
|
|
|
|
|
dlg.showModal();
|
|
|
|
|
fetch('/api/components/' + type).then(function(r){ return r.text(); }).then(function(html){
|
|
|
|
|
body.innerHTML = html;
|
|
|
|
|
}).catch(function(){
|
|
|
|
|
body.innerHTML = '<div style="padding:20px;color:var(--crit-fg)">Error loading details.</div>';
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
</script>` +
|
|
|
|
|
`</body></html>`
|
|
|
|
|
}
|
|
|
|
|
@@ -107,6 +120,14 @@ func renderDashboard(opts HandlerOptions) string {
|
|
|
|
|
b.WriteString(renderHardwareSummaryCard(opts))
|
|
|
|
|
b.WriteString(renderHealthCard(opts))
|
|
|
|
|
b.WriteString(renderMetrics())
|
|
|
|
|
b.WriteString(`<script>
|
|
|
|
|
setInterval(function(){
|
|
|
|
|
fetch('/api/hardware-summary').then(function(r){return r.text();}).then(function(html){
|
|
|
|
|
var el=document.getElementById('hw-summary-card');
|
|
|
|
|
if(el){el.outerHTML=html;}
|
|
|
|
|
}).catch(function(){});
|
|
|
|
|
},30000);
|
|
|
|
|
</script>`)
|
|
|
|
|
return b.String()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -185,14 +206,14 @@ func renderAudit() string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderHardwareSummaryCard(opts HandlerOptions) string {
|
|
|
|
|
const cardAttrs = ` hx-get="/api/hardware-summary" hx-trigger="every 30s" hx-swap="outerHTML"`
|
|
|
|
|
const cardID = ` id="hw-summary-card"`
|
|
|
|
|
data, err := loadSnapshot(opts.AuditPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return `<div class="card"` + cardAttrs + `><div class="card-head card-head-actions"><span>Hardware Summary</span><div class="card-head-buttons"><button class="btn btn-primary btn-sm" onclick="auditModalRun()">Run audit</button></div></div><div class="card-body"></div></div>`
|
|
|
|
|
return `<div class="card"` + cardID + `><div class="card-head card-head-actions"><span>Hardware Summary</span><div class="card-head-buttons"><button class="btn btn-primary btn-sm" onclick="auditModalRun()">Run audit</button></div></div><div class="card-body"></div></div>`
|
|
|
|
|
}
|
|
|
|
|
var ingest schema.HardwareIngestRequest
|
|
|
|
|
if err := json.Unmarshal(data, &ingest); err != nil {
|
|
|
|
|
return `<div class="card"` + cardAttrs + `><div class="card-head">Hardware Summary</div><div class="card-body"><span class="badge badge-err">Parse error</span></div></div>`
|
|
|
|
|
return `<div class="card"` + cardID + `><div class="card-head">Hardware Summary</div><div class="card-body"><span class="badge badge-err">Parse error</span></div></div>`
|
|
|
|
|
}
|
|
|
|
|
hw := ingest.Hardware
|
|
|
|
|
|
|
|
|
|
@@ -202,7 +223,7 @@ func renderHardwareSummaryCard(opts HandlerOptions) string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var b strings.Builder
|
|
|
|
|
b.WriteString(`<div class="card"` + cardAttrs + `><div class="card-head">Hardware Summary</div><div class="card-body">`)
|
|
|
|
|
b.WriteString(`<div class="card"` + cardID + `><div class="card-head">Hardware Summary</div><div class="card-body">`)
|
|
|
|
|
|
|
|
|
|
// Server identity block above the component table.
|
|
|
|
|
{
|
|
|
|
|
@@ -237,7 +258,7 @@ func renderHardwareSummaryCard(opts HandlerOptions) string {
|
|
|
|
|
var labelHTML string
|
|
|
|
|
if compType != "" {
|
|
|
|
|
labelHTML = fmt.Sprintf(
|
|
|
|
|
`<span style="cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px" hx-get="/api/components/%s" hx-target="#component-detail-body" hx-swap="innerHTML" onclick="document.getElementById('component-detail-dialog').showModal()">%s</span>`,
|
|
|
|
|
`<span style="cursor:pointer;text-decoration:underline dotted;text-underline-offset:3px" onclick="openComponentDetail('%s')">%s</span>`,
|
|
|
|
|
compType, html.EscapeString(label))
|
|
|
|
|
} else {
|
|
|
|
|
labelHTML = html.EscapeString(label)
|
|
|
|
|
@@ -1012,6 +1033,114 @@ func rowIssueHTML(issue string) string {
|
|
|
|
|
return html.EscapeString(issue)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var aerStatusRe = regexp.MustCompile(`aer_status:\s*0x([0-9a-fA-F]{1,8})`)
|
|
|
|
|
|
|
|
|
|
// decodeAERStatus parses an AER status hex value from a kernel error detail string
|
|
|
|
|
// and returns a human-readable list of set bit names with correctable/uncorrectable label,
|
|
|
|
|
// or "" if no AER status is found.
|
|
|
|
|
func decodeAERStatus(detail string) string {
|
|
|
|
|
m := aerStatusRe.FindStringSubmatch(detail)
|
|
|
|
|
if m == nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
v64, err := strconv.ParseUint(m[1], 16, 32)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
val := uint32(v64)
|
|
|
|
|
|
|
|
|
|
type bitDef struct {
|
|
|
|
|
bit uint32
|
|
|
|
|
name string
|
|
|
|
|
}
|
|
|
|
|
corrBits := []bitDef{
|
|
|
|
|
{0, "Receiver Error"}, {6, "Replay Timer Timeout"}, {7, "Advisory Non-Fatal"},
|
|
|
|
|
{8, "Corrected Internal Error"}, {9, "Header Log Overflow"},
|
|
|
|
|
{13, "Replay Num Rollover"}, {14, "Bad DLLP"}, {15, "Bad TLP"},
|
|
|
|
|
}
|
|
|
|
|
uncorrBits := []bitDef{
|
|
|
|
|
{4, "Data Link Protocol Error"}, {5, "Surprise Down Error"},
|
|
|
|
|
{12, "Poisoned TLP Received"}, {13, "Flow Control Protocol Error"},
|
|
|
|
|
{14, "Completion Timeout"}, {15, "Completer Abort"}, {16, "Unexpected Completion"},
|
|
|
|
|
{17, "Receiver Overflow"}, {18, "Malformed TLP"}, {19, "ECRC Error"},
|
|
|
|
|
{20, "Unsupported Request Error"}, {21, "ACS Violation"}, {22, "Uncorrectable Internal Error"},
|
|
|
|
|
}
|
|
|
|
|
var corrNames, uncorrNames []string
|
|
|
|
|
for _, b := range corrBits {
|
|
|
|
|
if val&(1<<b.bit) != 0 {
|
|
|
|
|
corrNames = append(corrNames, b.name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, b := range uncorrBits {
|
|
|
|
|
if val&(1<<b.bit) != 0 {
|
|
|
|
|
uncorrNames = append(uncorrNames, b.name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(corrNames) >= len(uncorrNames) && len(corrNames) > 0 {
|
|
|
|
|
return strings.Join(corrNames, ", ") + " (correctable)"
|
|
|
|
|
}
|
|
|
|
|
if len(uncorrNames) > 0 {
|
|
|
|
|
return strings.Join(uncorrNames, ", ") + " (uncorrectable)"
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("unknown bits: 0x%08x", val)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderSparkline returns a small inline SVG showing non-OK events over time.
|
|
|
|
|
// Events are positioned proportionally along the time axis; if all share the same
|
|
|
|
|
// timestamp they are spaced evenly. Width is always 100px.
|
|
|
|
|
func renderSparkline(history []app.ComponentStatusEntry) string {
|
|
|
|
|
const (
|
|
|
|
|
svgW = 100
|
|
|
|
|
svgH = 20
|
|
|
|
|
barW = 3
|
|
|
|
|
barH = 14
|
|
|
|
|
)
|
|
|
|
|
var events []app.ComponentStatusEntry
|
|
|
|
|
for _, e := range history {
|
|
|
|
|
if e.Status != "OK" {
|
|
|
|
|
events = append(events, e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(events) == 0 {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
n := len(events)
|
|
|
|
|
barColor := func(status string) string {
|
|
|
|
|
if status == "Critical" {
|
|
|
|
|
return "#c0392b"
|
|
|
|
|
}
|
|
|
|
|
return "#d97706"
|
|
|
|
|
}
|
|
|
|
|
yTop := (svgH - barH) / 2
|
|
|
|
|
|
|
|
|
|
var bars strings.Builder
|
|
|
|
|
if n == 1 {
|
|
|
|
|
x := (svgW - barW) / 2
|
|
|
|
|
fmt.Fprintf(&bars, `<rect x="%d" y="%d" width="%d" height="%d" fill="%s" rx="1"/>`,
|
|
|
|
|
x, yTop, barW, barH, barColor(events[0].Status))
|
|
|
|
|
} else {
|
|
|
|
|
minT := events[0].At
|
|
|
|
|
maxT := events[n-1].At
|
|
|
|
|
dur := maxT.Sub(minT).Seconds()
|
|
|
|
|
for i, e := range events {
|
|
|
|
|
var x int
|
|
|
|
|
if dur <= 0 {
|
|
|
|
|
step := svgW / n
|
|
|
|
|
x = i*step + (step-barW)/2
|
|
|
|
|
} else {
|
|
|
|
|
frac := e.At.Sub(minT).Seconds() / dur
|
|
|
|
|
x = int(frac * float64(svgW-barW))
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(&bars, `<rect x="%d" y="%d" width="%d" height="%d" fill="%s" rx="1"/>`,
|
|
|
|
|
x, yTop, barW, barH, barColor(e.Status))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
`<svg width="%d" height="%d" style="display:inline-block;vertical-align:middle;margin-left:6px;flex-shrink:0" xmlns="http://www.w3.org/2000/svg">`+
|
|
|
|
|
`<rect x="0" y="0" width="%d" height="%d" fill="var(--surface-alt,#ebebeb)" rx="3"/>%s</svg>`,
|
|
|
|
|
svgW, svgH, svgW, svgH, bars.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderComponentDetail renders a modal content fragment for one component type.
|
|
|
|
|
// Called by handleAPIComponentDetail and displayed inside #component-detail-dialog.
|
|
|
|
|
func renderComponentDetail(title string, records []app.ComponentStatusRecord) string {
|
|
|
|
|
@@ -1034,16 +1163,41 @@ func renderComponentDetail(title string, records []app.ComponentStatusRecord) st
|
|
|
|
|
|
|
|
|
|
for _, rec := range records {
|
|
|
|
|
letter, cls := chipLetterClass(rec.Status)
|
|
|
|
|
|
|
|
|
|
// Count non-OK events across the full history for the badge + sparkline.
|
|
|
|
|
warnCount := 0
|
|
|
|
|
for _, e := range rec.History {
|
|
|
|
|
if e.Status != "OK" {
|
|
|
|
|
warnCount++
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(&b, `<div style="margin-bottom:20px">`)
|
|
|
|
|
fmt.Fprintf(&b, `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">`)
|
|
|
|
|
fmt.Fprintf(&b, `<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap">`)
|
|
|
|
|
fmt.Fprintf(&b, `<span class="chip %s">%s</span>`, cls, letter)
|
|
|
|
|
fmt.Fprintf(&b, `<span style="font-weight:700;font-size:13px">%s</span>`, html.EscapeString(rec.ComponentKey))
|
|
|
|
|
if !rec.LastCheckedAt.IsZero() {
|
|
|
|
|
fmt.Fprintf(&b, `<span style="color:var(--muted);font-size:12px">checked %s</span>`, rec.LastCheckedAt.Format("2006-01-02 15:04:05"))
|
|
|
|
|
}
|
|
|
|
|
if warnCount > 0 {
|
|
|
|
|
noun := "events"
|
|
|
|
|
if warnCount == 1 {
|
|
|
|
|
noun = "event"
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintf(&b,
|
|
|
|
|
`<span style="font-size:11px;background:var(--warn-bg,#fffbeb);color:var(--warn-fg,#92400e);border:1px solid var(--warn-border,#fde68a);border-radius:10px;padding:1px 7px;white-space:nowrap">%d %s</span>`,
|
|
|
|
|
warnCount, noun)
|
|
|
|
|
b.WriteString(renderSparkline(rec.History))
|
|
|
|
|
}
|
|
|
|
|
b.WriteString(`</div>`)
|
|
|
|
|
|
|
|
|
|
if rec.ErrorSummary != "" {
|
|
|
|
|
fmt.Fprintf(&b, `<div style="font-size:12px;margin-bottom:8px;color:var(--muted)">%s</div>`, html.EscapeString(rec.ErrorSummary))
|
|
|
|
|
fmt.Fprintf(&b, `<div style="font-size:12px;margin-bottom:4px;color:var(--muted)">%s</div>`, html.EscapeString(rec.ErrorSummary))
|
|
|
|
|
if decoded := decodeAERStatus(rec.ErrorSummary); decoded != "" {
|
|
|
|
|
fmt.Fprintf(&b,
|
|
|
|
|
`<div style="font-size:12px;margin-bottom:8px;color:var(--muted)"><span style="background:var(--surface-alt,#f5f5f5);border-radius:4px;padding:1px 6px;font-family:monospace">AER: %s</span></div>`,
|
|
|
|
|
html.EscapeString(decoded))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// History table — newest first, cap at 20 entries.
|
|
|
|
|
|