Commit 3b5b3c0

Eric Bower  ·  2026-05-08 14:56:27 -0400 EDT
parent 8316a06
feat: tmpl embedfs
4 files changed,  +201, -139
+31, -139
......@@ -5,11 +5,13 @@ import (
55 "bufio"
66 "context"
77 "crypto/sha256"
8+ "embed"
89 "encoding/json"
910 "flag"
1011 "fmt"
1112 "html/template"
1213 "io"
14+ "io/fs"
1315 "log/slog"
1416 "os"
1517 "os/exec"
......@@ -21,6 +23,9 @@ import (
2123 "time"
2224 )
2325
26+//go:embed tmpl/*
27+var tmplFS embed.FS
28+
2429 type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace
2530
2631 func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Workspace {
......@@ -1037,6 +1042,12 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
10371042 if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexTXT, ".txt"); err != nil {
10381043 log.Error("stage index.txt", "err", err)
10391044 }
1045+ // Stage shared CSS
1046+ if styles, err := loadStyles(); err == nil {
1047+ if err := stageArtifact(cfg.ArtifactDir, name, jobID, "styles", styles, ".css"); err != nil {
1048+ log.Error("stage styles.css", "err", err)
1049+ }
1050+ }
10401051
10411052 // Load event to get artifact destination
10421053 eventData, _ := loadEvent(cfg.ArtifactDir, name, jobID)
......@@ -1091,6 +1102,14 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
10911102 if err := os.WriteFile(sentinel, publishedJSON, 0644); err != nil {
10921103 log.Error("write published sentinel", "err", err)
10931104 }
1105+ // Regenerate index.html now that published.json exists, so the artifact list includes it.
1106+ indexHTML, indexTXT := generateJobIndex(cfg.ArtifactDir, name, jobID, group)
1107+ if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexHTML, ".html"); err != nil {
1108+ log.Error("stage index.html", "err", err)
1109+ }
1110+ if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexTXT, ".txt"); err != nil {
1111+ log.Error("stage index.txt", "err", err)
1112+ }
10941113 if err := syncJobArtifacts(cfg, name, jobID, log); err != nil {
10951114 log.Error("sync artifacts", "err", err)
10961115 }
......@@ -1325,78 +1344,14 @@ func parseZMXList(output string) []SessionInfo {
13251344 return sessions
13261345 }
13271346
1328-// sessionHTMLTemplate wraps session history fragments in a full HTML document.
1329-const sessionHTMLTemplate = `<!DOCTYPE html>
1330-<html lang="en">
1331-<head>
1332-<meta charset="UTF-8">
1333-<meta name="viewport" content="width=device-width, initial-scale=1.0">
1334-<title>{{.SessionName}} — CI Session</title>
1335-<style>
1336- * { box-sizing: border-box; }
1337- body {
1338- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
1339- margin: 0; padding: 1.5rem;
1340- background: #1e1e2e;
1341- color: #cdd6f4;
1342- font-size: 14px;
1343- line-height: 1.5;
1344- }
1345- h1 {
1346- font-size: 1rem;
1347- font-weight: 600;
1348- margin: 0 0 1rem 0;
1349- color: #89b4fa;
1350- display: flex;
1351- align-items: center;
1352- gap: 0.75rem;
1353- flex-wrap: wrap;
1354- }
1355- .meta {
1356- color: #6c7086;
1357- font-size: 0.8rem;
1358- margin-bottom: 1rem;
1359- display: flex;
1360- gap: 1.5rem;
1361- flex-wrap: wrap;
1362- }
1363- .meta span { display: flex; align-items: center; gap: 0.3rem; }
1364- .status { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
1365- .status-running { background: #89b4fa33; color: #89b4fa; }
1366- .status-success { background: #a6e3a133; color: #a6e3a1; }
1367- .status-failed { background: #f38ba833; color: #f38ba8; }
1368- pre {
1369- background: #11111b;
1370- border-radius: 8px;
1371- padding: 1rem;
1372- overflow-x: auto;
1373- margin: 0;
1374- border: 1px solid #313244;
1375- }
1376- pre code { font-size: 13px; }
1377- .zmx-output { white-space: pre-wrap; word-break: break-word; }
1378- a { color: #89b4fa; }
1379- a:hover { text-decoration: underline; }
1380- .links { margin-top: 1rem; color: #6c7086; font-size: 0.85rem; }
1381-</style>
1382-</head>
1383-<body>
1384-<h1>
1385- {{.SessionShort}}
1386- <span class="status status-{{.SessionStatus}}">{{.SessionStatus}}</span>
1387-</h1>
1388-<div class="meta">
1389- <span>Job: {{.JobName}}</span>
1390- <span>ID: {{.JobID}}</span>
1391- <span>Duration: {{.Duration}}</span>
1392- {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}}
1393-</div>
1394-<div class="zmx-output">{{.Content}}</div>
1395-<div class="links">
1396- <a href="index.html">&larr; Back to job</a>
1397-</div>
1398-</body>
1399-</html>`
1347+// loadStyles reads the shared CSS from the embedded template filesystem.
1348+func loadStyles() (string, error) {
1349+ data, err := fs.ReadFile(tmplFS, "tmpl/styles.css")
1350+ if err != nil {
1351+ return "", fmt.Errorf("read styles: %w", err)
1352+ }
1353+ return string(data), nil
1354+}
14001355
14011356 // SessionArtifactData holds the data for rendering a session HTML artifact.
14021357 type SessionArtifactData struct {
......@@ -1433,13 +1388,13 @@ func fetchHistoryHTML(sessionName, jobName, jobID, status, duration, exitCode st
14331388 Content: template.HTML(string(output)),
14341389 }
14351390
1436- tmpl, err := template.New("session").Parse(sessionHTMLTemplate)
1391+ tmpl, err := template.ParseFS(tmplFS, "tmpl/session.html")
14371392 if err != nil {
14381393 return "", fmt.Errorf("parse template: %w", err)
14391394 }
14401395
14411396 var buf strings.Builder
1442- if err := tmpl.Execute(&buf, data); err != nil {
1397+ if err := tmpl.ExecuteTemplate(&buf, "session.html", data); err != nil {
14431398 return "", fmt.Errorf("execute template: %w", err)
14441399 }
14451400
......@@ -1863,10 +1818,10 @@ func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) (
18631818 // HTML index
18641819 tmpl, err := template.New("index").Funcs(template.FuncMap{
18651820 "formatTimestamp": formatTimestamp,
1866- }).Parse(indexHTMLTemplate)
1821+ }).ParseFS(tmplFS, "tmpl/index.html")
18671822 if err == nil {
18681823 var buf strings.Builder
1869- if err := tmpl.Execute(&buf, struct {
1824+ if err := tmpl.ExecuteTemplate(&buf, "index.html", struct {
18701825 Name string
18711826 JobID string
18721827 JobStatus string
......@@ -1895,69 +1850,6 @@ func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) (
18951850 return htmlContent, txtContent
18961851 }
18971852
1898-const indexHTMLTemplate = `<!DOCTYPE html>
1899-<html lang="en">
1900-<head>
1901-<meta charset="UTF-8">
1902-<meta name="viewport" content="width=device-width, initial-scale=1.0">
1903-<title>CI Job: {{.Name}} ({{.JobID}})</title>
1904-<style>
1905- body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; background: #fafafa; color: #222; }
1906- h1 { font-size: 1.4rem; margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.75rem; }
1907- .meta { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; }
1908- table { border-collapse: collapse; width: 100%; max-width: 800px; }
1909- th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #ccc; font-size: 0.8rem; text-transform: uppercase; color: #555; }
1910- td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; }
1911- a { color: #0066cc; text-decoration: none; }
1912- a:hover { text-decoration: underline; }
1913- .pill { display: inline-block; padding: 0.2rem 0.65rem; border-radius: 999px; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; }
1914- .pill-success { background: #d4edda; color: #155724; }
1915- .pill-failed { background: #f8d7da; color: #721c24; }
1916- .pill-running { background: #cce5ff; color: #004085; }
1917-</style>
1918-</head>
1919-<body>
1920-<h1>{{.Name}} <span class="pill pill-{{.JobStatus}}">{{.JobStatus}}</span></h1>
1921-<p class="meta">Job ID: {{.JobID}} &middot; {{len .Rows}} task(s)</p>
1922-<table>
1923-<thead>
1924-<tr><th>Task</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Started</th><th>Ended</th><th>Artifacts</th></tr>
1925-</thead>
1926-<tbody>
1927-{{range .Rows}}
1928-<tr>
1929- <td><strong>{{.Short}}</strong></td>
1930- <td><span class="pill pill-{{.Status}}">{{.Status}}</span></td>
1931- <td>{{.ExitCode}}</td>
1932- <td>{{.Duration}}</td>
1933- <td>{{.Started | formatTimestamp}}</td>
1934- <td>{{.Ended | formatTimestamp}}</td>
1935- <td><a href="{{.Short}}.html">html</a> / <a href="{{.Short}}.txt">txt</a></td>
1936-</tr>
1937-{{end}}
1938-</tbody>
1939-</table>
1940-
1941-{{if .Artifacts}}
1942-<h2 style="margin-top: 2rem; font-size: 1rem; color: #555;">Other Artifacts</h2>
1943-<table>
1944-<thead>
1945-<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
1946-</thead>
1947-<tbody>
1948-{{range .Artifacts}}
1949-<tr>
1950- <td><a href="{{.Name}}">{{.Name}}</a></td>
1951- <td>{{.Size}}</td>
1952- <td>{{.ModTime}}</td>
1953-</tr>
1954-{{end}}
1955-</tbody>
1956-</table>
1957-{{end}}
1958-</body>
1959-</html>`
1960-
19611853 // fmtDuration formats the duration between two unix timestamp strings.
19621854 // Returns human-readable strings like "2m34s", "1.5s", or "—" if invalid.
19631855 func fmtDuration(created, ended string) string {
+50, -0
......@@ -0,0 +1,50 @@
1+<!DOCTYPE html>
2+<html lang="en">
3+<head>
4+<meta charset="UTF-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+<title>CI Job: {{.Name}} ({{.JobID}})</title>
7+<link rel="stylesheet" href="styles.css">
8+</head>
9+<body>
10+<div class="links"><a href="..">{{.Name}}</a></div>
11+<h1>{{.Name}} <span class="pill pill-{{.JobStatus}}">{{.JobStatus}}</span></h1>
12+<p class="meta">Job ID: {{.JobID}} &middot; {{len .Rows}} task(s)</p>
13+<table>
14+<thead>
15+<tr><th>Task</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Started</th><th>Ended</th><th>Raw</th></tr>
16+</thead>
17+<tbody>
18+{{range .Rows}}
19+<tr>
20+ <td><a href="{{.Short}}.html"><strong>{{.Short}}</strong></a></td>
21+ <td><span class="pill pill-{{.Status}}">{{.Status}}</span></td>
22+ <td>{{.ExitCode}}</td>
23+ <td>{{.Duration}}</td>
24+ <td>{{.Started | formatTimestamp}}</td>
25+ <td>{{.Ended | formatTimestamp}}</td>
26+ <td><a href="{{.Short}}.txt">raw</a></td>
27+</tr>
28+{{end}}
29+</tbody>
30+</table>
31+
32+{{if .Artifacts}}
33+<h2>Other Artifacts</h2>
34+<table>
35+<thead>
36+<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
37+</thead>
38+<tbody>
39+{{range .Artifacts}}
40+<tr>
41+ <td><a href="{{.Name}}">{{.Name}}</a></td>
42+ <td>{{.Size}}</td>
43+ <td>{{.ModTime}}</td>
44+</tr>
45+{{end}}
46+</tbody>
47+</table>
48+{{end}}
49+</body>
50+</html>
+25, -0
......@@ -0,0 +1,25 @@
1+<!DOCTYPE html>
2+<html lang="en">
3+<head>
4+<meta charset="UTF-8">
5+<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+<title>{{.SessionShort}} — CI Session</title>
7+<link rel="stylesheet" href="styles.css">
8+</head>
9+<body>
10+<h1>
11+ {{.SessionShort}}
12+ <span class="status status-{{.SessionStatus}}">{{.SessionStatus}}</span>
13+</h1>
14+<div class="meta">
15+ <span>Job: {{.JobName}}</span>
16+ <span>ID: {{.JobID}}</span>
17+ <span>Duration: {{.Duration}}</span>
18+ {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}}
19+</div>
20+<div class="zmx-output">{{.Content}}</div>
21+<div class="links">
22+ <a href="index.html">&larr; Back to job</a>
23+</div>
24+</body>
25+</html>
+95, -0
......@@ -0,0 +1,95 @@
1+/* pici monitor — shared styles (Catppuccin Mocha palette) */
2+* { box-sizing: border-box; }
3+
4+body {
5+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
6+ margin: 0;
7+ padding: 1.5rem;
8+ background: #1e1e2e;
9+ color: #cdd6f4;
10+ font-size: 14px;
11+ line-height: 1.5;
12+}
13+
14+h1 {
15+ font-size: 1rem;
16+ font-weight: 600;
17+ margin: 0 0 1rem 0;
18+ color: #89b4fa;
19+ display: flex;
20+ align-items: center;
21+ gap: 0.75rem;
22+ flex-wrap: wrap;
23+}
24+
25+h2 {
26+ font-size: 0.9rem;
27+ font-weight: 600;
28+ margin: 2rem 0 0.75rem 0;
29+ color: #89b4fa;
30+}
31+
32+.meta {
33+ color: #6c7086;
34+ font-size: 0.8rem;
35+ margin-bottom: 1rem;
36+ display: flex;
37+ gap: 1.5rem;
38+ flex-wrap: wrap;
39+}
40+
41+.meta span { display: flex; align-items: center; gap: 0.3rem; }
42+
43+/* Status badges */
44+.status, .pill {
45+ display: inline-block;
46+ padding: 0.15rem 0.5rem;
47+ border-radius: 4px;
48+ font-size: 0.75rem;
49+ font-weight: 600;
50+}
51+
52+.status-running, .pill-running { background: #89b4fa33; color: #89b4fa; }
53+.status-success, .pill-success { background: #a6e3a133; color: #a6e3a1; }
54+.status-failed, .pill-failed { background: #f38ba833; color: #f38ba8; }
55+
56+/* Tables */
57+table {
58+ border-collapse: collapse;
59+ width: 100%;
60+ max-width: 800px;
61+}
62+
63+th {
64+ text-align: left;
65+ padding: 0.5rem 0.75rem;
66+ border-bottom: 2px solid #313244;
67+ font-size: 0.8rem;
68+ text-transform: uppercase;
69+ color: #6c7086;
70+}
71+
72+td {
73+ padding: 0.5rem 0.75rem;
74+ border-bottom: 1px solid #313244;
75+}
76+
77+/* Pre / code output */
78+pre {
79+ background: #11111b;
80+ border-radius: 8px;
81+ padding: 1rem;
82+ overflow-x: auto;
83+ margin: 0;
84+ border: 1px solid #313244;
85+}
86+
87+pre code { font-size: 13px; }
88+
89+.zmx-output { white-space: pre-wrap; word-break: break-word; }
90+
91+/* Links */
92+a { color: #89b4fa; text-decoration: none; }
93+a:hover { text-decoration: underline; }
94+
95+.links { margin-top: 1rem; color: #6c7086; font-size: 0.85rem; }