Bootstrap reanimator chart viewer
This commit is contained in:
57
web/embed.go
Normal file
57
web/embed.go
Normal 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
214
web/static/view.css
Normal 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
111
web/templates/view.html
Normal 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>
|
||||
Reference in New Issue
Block a user