Bootstrap reanimator chart viewer

This commit is contained in:
Mikhail Chusavitin
2026-03-15 17:28:19 +03:00
commit df91e24fea
22 changed files with 1231 additions and 0 deletions

57
web/embed.go Normal file
View File

@@ -0,0 +1,57 @@
package web
import (
"embed"
"html/template"
"io/fs"
"net/http"
"strings"
)
//go:embed templates/* static/*
var content embed.FS
var pageTemplate = template.Must(template.New("view.html").Funcs(template.FuncMap{
"statusClass": statusClass,
"joinLines": joinLines,
}).ParseFS(content, "templates/view.html"))
func Render(data any) ([]byte, error) {
var out strings.Builder
if err := pageTemplate.ExecuteTemplate(&out, "view.html", data); err != nil {
return nil, err
}
return []byte(out.String()), nil
}
func Static() http.Handler {
sub, err := fs.Sub(content, "static")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(sub))
}
func statusClass(value string) string {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "OK":
return "status-ok"
case "WARNING":
return "status-warning"
case "CRITICAL":
return "status-critical"
case "UNKNOWN":
return "status-unknown"
case "EMPTY":
return "status-empty"
default:
return ""
}
}
func joinLines(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
return strings.Split(value, "\n")
}

214
web/static/view.css Normal file
View File

@@ -0,0 +1,214 @@
:root {
--bg: #f4f1ea;
--panel: #fffdf9;
--border: #d8d0c3;
--ink: #26231e;
--muted: #6a6258;
--accent: #8f3b2e;
--ok-bg: #d8f1df;
--ok-fg: #1f6a32;
--warn-bg: #fff0bf;
--warn-fg: #8a6200;
--crit-bg: #ffd7d2;
--crit-fg: #9a2419;
--unknown-bg: #e3e5e8;
--unknown-fg: #4f5965;
--empty-bg: #ece7de;
--empty-fg: #72675b;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, #efe1cf 0, transparent 30%),
linear-gradient(180deg, #f8f4ed 0%, var(--bg) 100%);
color: var(--ink);
font: 14px/1.45 "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
}
.page-header {
padding: 24px 28px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.page-header h1 {
margin: 0;
font-size: 32px;
line-height: 1.1;
}
.page-header p {
margin: 6px 0 0;
color: var(--muted);
}
.page-main {
width: min(1500px, calc(100vw - 32px));
margin: 0 auto;
padding: 20px 0 32px;
}
.input-panel,
.meta-panel,
.section-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(78, 54, 26, 0.05);
padding: 18px;
margin-bottom: 16px;
}
.input-label,
.section-card h2,
.meta-panel h2 {
display: block;
margin: 0 0 12px;
font-size: 18px;
}
textarea {
width: 100%;
min-height: 220px;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
background: #fcfaf6;
color: var(--ink);
font: 13px/1.5 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
}
.input-actions {
margin-top: 12px;
}
button {
border: 0;
border-radius: 999px;
background: var(--accent);
color: #fff;
padding: 10px 16px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.error-box {
margin-top: 12px;
border: 1px solid var(--crit-fg);
border-radius: 10px;
padding: 12px;
background: var(--crit-bg);
color: var(--crit-fg);
}
.section-nav {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 16px;
}
.section-nav a {
text-decoration: none;
color: var(--accent);
background: rgba(143, 59, 46, 0.08);
padding: 7px 11px;
border-radius: 999px;
}
.kv-table,
.data-table {
width: 100%;
border-collapse: collapse;
}
.kv-table th,
.kv-table td,
.data-table th,
.data-table td {
vertical-align: top;
text-align: left;
border-top: 1px solid var(--border);
padding: 10px 8px;
}
.kv-table tr:first-child th,
.kv-table tr:first-child td,
.data-table tr:first-child th,
.data-table tr:first-child td {
border-top: 0;
}
.kv-table th,
.data-table th {
color: var(--muted);
font-weight: 700;
}
.table-wrap {
overflow-x: auto;
}
.data-table thead th {
position: sticky;
top: 0;
background: #f7f1e7;
}
.status-badge {
display: inline-block;
border-radius: 999px;
padding: 3px 9px;
font-weight: 700;
white-space: nowrap;
}
.status-ok {
background: var(--ok-bg);
color: var(--ok-fg);
}
.status-warning {
background: var(--warn-bg);
color: var(--warn-fg);
}
.status-critical {
background: var(--crit-bg);
color: var(--crit-fg);
}
.status-unknown {
background: var(--unknown-bg);
color: var(--unknown-fg);
}
.status-empty {
background: var(--empty-bg);
color: var(--empty-fg);
}
@media (max-width: 720px) {
.page-main {
width: min(100vw - 20px, 1500px);
}
.page-header {
padding: 18px 14px 14px;
}
.page-header h1 {
font-size: 26px;
}
.input-panel,
.meta-panel,
.section-card {
padding: 14px;
}
}

111
web/templates/view.html Normal file
View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }}</title>
<link rel="stylesheet" href="/static/view.css">
</head>
<body>
<header class="page-header">
<div>
<h1>{{ .Title }}</h1>
<p>Read-only viewer for Reanimator JSON snapshots</p>
</div>
</header>
<main class="page-main">
<section class="input-panel">
<form method="post" action="/render">
<label for="snapshot" class="input-label">Snapshot JSON</label>
<textarea id="snapshot" name="snapshot" spellcheck="false" placeholder='{"target_host":"...","hardware":{...}}'>{{ .InputJSON }}</textarea>
<div class="input-actions">
<button type="submit">Render Snapshot</button>
</div>
</form>
{{ if .Error }}
<div class="error-box">{{ .Error }}</div>
{{ end }}
</section>
{{ if .HasSnapshot }}
<section class="meta-panel">
<h2>Snapshot Metadata</h2>
<table class="kv-table">
<tbody>
{{ range .Meta }}
<tr>
<th>{{ .Key }}</th>
<td>{{ .Value }}</td>
</tr>
{{ end }}
</tbody>
</table>
</section>
<nav class="section-nav">
{{ range .Sections }}
<a href="#{{ .ID }}">{{ .Title }}</a>
{{ end }}
</nav>
{{ range .Sections }}
<section class="section-card" id="{{ .ID }}">
<h2>{{ .Title }}</h2>
{{ if eq .Kind "object" }}
<table class="kv-table">
<tbody>
{{ range .Rows }}
<tr>
<th>{{ .Key }}</th>
<td>
{{ range joinLines .Value }}
<div>{{ . }}</div>
{{ end }}
</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
{{ if eq .Kind "table" }}
{{ $section := . }}
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
{{ range .Columns }}
<th>{{ . }}</th>
{{ end }}
</tr>
</thead>
<tbody>
{{ range .Items }}
<tr>
{{ $row := . }}
{{ range $section.Columns }}
<td>
{{ $value := index $row.Cells . }}
{{ if eq . "status" }}
<span class="status-badge {{ statusClass $value }}">{{ $value }}</span>
{{ else }}
{{ range joinLines $value }}
<div>{{ . }}</div>
{{ end }}
{{ end }}
</td>
{{ end }}
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
</section>
{{ end }}
{{ end }}
</main>
</body>
</html>