Commit 3b5b3c0
Eric Bower
·
2026-05-08 14:56:27 -0400 EDT
parent 8316a06
feat: tmpl embedfs
4 files changed,
+201,
-139
M
main.go
+31,
-139
1@@ -5,11 +5,13 @@ import (
2 "bufio"
3 "context"
4 "crypto/sha256"
5+ "embed"
6 "encoding/json"
7 "flag"
8 "fmt"
9 "html/template"
10 "io"
11+ "io/fs"
12 "log/slog"
13 "os"
14 "os/exec"
15@@ -21,6 +23,9 @@ import (
16 "time"
17 )
18
19+//go:embed tmpl/*
20+var tmplFS embed.FS
21+
22 type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace
23
24 func defaultWorkspaceFactory(cfg *Cfg, logger *slog.Logger, source string) Workspace {
25@@ -1037,6 +1042,12 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
26 if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexTXT, ".txt"); err != nil {
27 log.Error("stage index.txt", "err", err)
28 }
29+ // Stage shared CSS
30+ if styles, err := loadStyles(); err == nil {
31+ if err := stageArtifact(cfg.ArtifactDir, name, jobID, "styles", styles, ".css"); err != nil {
32+ log.Error("stage styles.css", "err", err)
33+ }
34+ }
35
36 // Load event to get artifact destination
37 eventData, _ := loadEvent(cfg.ArtifactDir, name, jobID)
38@@ -1091,6 +1102,14 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
39 if err := os.WriteFile(sentinel, publishedJSON, 0644); err != nil {
40 log.Error("write published sentinel", "err", err)
41 }
42+ // Regenerate index.html now that published.json exists, so the artifact list includes it.
43+ indexHTML, indexTXT := generateJobIndex(cfg.ArtifactDir, name, jobID, group)
44+ if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexHTML, ".html"); err != nil {
45+ log.Error("stage index.html", "err", err)
46+ }
47+ if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexTXT, ".txt"); err != nil {
48+ log.Error("stage index.txt", "err", err)
49+ }
50 if err := syncJobArtifacts(cfg, name, jobID, log); err != nil {
51 log.Error("sync artifacts", "err", err)
52 }
53@@ -1325,78 +1344,14 @@ func parseZMXList(output string) []SessionInfo {
54 return sessions
55 }
56
57-// sessionHTMLTemplate wraps session history fragments in a full HTML document.
58-const sessionHTMLTemplate = `<!DOCTYPE html>
59-<html lang="en">
60-<head>
61-<meta charset="UTF-8">
62-<meta name="viewport" content="width=device-width, initial-scale=1.0">
63-<title>{{.SessionName}} — CI Session</title>
64-<style>
65- * { box-sizing: border-box; }
66- body {
67- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
68- margin: 0; padding: 1.5rem;
69- background: #1e1e2e;
70- color: #cdd6f4;
71- font-size: 14px;
72- line-height: 1.5;
73- }
74- h1 {
75- font-size: 1rem;
76- font-weight: 600;
77- margin: 0 0 1rem 0;
78- color: #89b4fa;
79- display: flex;
80- align-items: center;
81- gap: 0.75rem;
82- flex-wrap: wrap;
83- }
84- .meta {
85- color: #6c7086;
86- font-size: 0.8rem;
87- margin-bottom: 1rem;
88- display: flex;
89- gap: 1.5rem;
90- flex-wrap: wrap;
91- }
92- .meta span { display: flex; align-items: center; gap: 0.3rem; }
93- .status { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
94- .status-running { background: #89b4fa33; color: #89b4fa; }
95- .status-success { background: #a6e3a133; color: #a6e3a1; }
96- .status-failed { background: #f38ba833; color: #f38ba8; }
97- pre {
98- background: #11111b;
99- border-radius: 8px;
100- padding: 1rem;
101- overflow-x: auto;
102- margin: 0;
103- border: 1px solid #313244;
104- }
105- pre code { font-size: 13px; }
106- .zmx-output { white-space: pre-wrap; word-break: break-word; }
107- a { color: #89b4fa; }
108- a:hover { text-decoration: underline; }
109- .links { margin-top: 1rem; color: #6c7086; font-size: 0.85rem; }
110-</style>
111-</head>
112-<body>
113-<h1>
114- {{.SessionShort}}
115- <span class="status status-{{.SessionStatus}}">{{.SessionStatus}}</span>
116-</h1>
117-<div class="meta">
118- <span>Job: {{.JobName}}</span>
119- <span>ID: {{.JobID}}</span>
120- <span>Duration: {{.Duration}}</span>
121- {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}}
122-</div>
123-<div class="zmx-output">{{.Content}}</div>
124-<div class="links">
125- <a href="index.html">← Back to job</a>
126-</div>
127-</body>
128-</html>`
129+// loadStyles reads the shared CSS from the embedded template filesystem.
130+func loadStyles() (string, error) {
131+ data, err := fs.ReadFile(tmplFS, "tmpl/styles.css")
132+ if err != nil {
133+ return "", fmt.Errorf("read styles: %w", err)
134+ }
135+ return string(data), nil
136+}
137
138 // SessionArtifactData holds the data for rendering a session HTML artifact.
139 type SessionArtifactData struct {
140@@ -1433,13 +1388,13 @@ func fetchHistoryHTML(sessionName, jobName, jobID, status, duration, exitCode st
141 Content: template.HTML(string(output)),
142 }
143
144- tmpl, err := template.New("session").Parse(sessionHTMLTemplate)
145+ tmpl, err := template.ParseFS(tmplFS, "tmpl/session.html")
146 if err != nil {
147 return "", fmt.Errorf("parse template: %w", err)
148 }
149
150 var buf strings.Builder
151- if err := tmpl.Execute(&buf, data); err != nil {
152+ if err := tmpl.ExecuteTemplate(&buf, "session.html", data); err != nil {
153 return "", fmt.Errorf("execute template: %w", err)
154 }
155
156@@ -1863,10 +1818,10 @@ func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) (
157 // HTML index
158 tmpl, err := template.New("index").Funcs(template.FuncMap{
159 "formatTimestamp": formatTimestamp,
160- }).Parse(indexHTMLTemplate)
161+ }).ParseFS(tmplFS, "tmpl/index.html")
162 if err == nil {
163 var buf strings.Builder
164- if err := tmpl.Execute(&buf, struct {
165+ if err := tmpl.ExecuteTemplate(&buf, "index.html", struct {
166 Name string
167 JobID string
168 JobStatus string
169@@ -1895,69 +1850,6 @@ func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) (
170 return htmlContent, txtContent
171 }
172
173-const indexHTMLTemplate = `<!DOCTYPE html>
174-<html lang="en">
175-<head>
176-<meta charset="UTF-8">
177-<meta name="viewport" content="width=device-width, initial-scale=1.0">
178-<title>CI Job: {{.Name}} ({{.JobID}})</title>
179-<style>
180- body { font-family: system-ui, -apple-system, sans-serif; margin: 2rem; background: #fafafa; color: #222; }
181- h1 { font-size: 1.4rem; margin-bottom: 0.25rem; display: flex; align-items: center; gap: 0.75rem; }
182- .meta { color: #666; margin-bottom: 1.5rem; font-size: 0.9rem; }
183- table { border-collapse: collapse; width: 100%; max-width: 800px; }
184- th { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 2px solid #ccc; font-size: 0.8rem; text-transform: uppercase; color: #555; }
185- td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; }
186- a { color: #0066cc; text-decoration: none; }
187- a:hover { text-decoration: underline; }
188- .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; }
189- .pill-success { background: #d4edda; color: #155724; }
190- .pill-failed { background: #f8d7da; color: #721c24; }
191- .pill-running { background: #cce5ff; color: #004085; }
192-</style>
193-</head>
194-<body>
195-<h1>{{.Name}} <span class="pill pill-{{.JobStatus}}">{{.JobStatus}}</span></h1>
196-<p class="meta">Job ID: {{.JobID}} · {{len .Rows}} task(s)</p>
197-<table>
198-<thead>
199-<tr><th>Task</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Started</th><th>Ended</th><th>Artifacts</th></tr>
200-</thead>
201-<tbody>
202-{{range .Rows}}
203-<tr>
204- <td><strong>{{.Short}}</strong></td>
205- <td><span class="pill pill-{{.Status}}">{{.Status}}</span></td>
206- <td>{{.ExitCode}}</td>
207- <td>{{.Duration}}</td>
208- <td>{{.Started | formatTimestamp}}</td>
209- <td>{{.Ended | formatTimestamp}}</td>
210- <td><a href="{{.Short}}.html">html</a> / <a href="{{.Short}}.txt">txt</a></td>
211-</tr>
212-{{end}}
213-</tbody>
214-</table>
215-
216-{{if .Artifacts}}
217-<h2 style="margin-top: 2rem; font-size: 1rem; color: #555;">Other Artifacts</h2>
218-<table>
219-<thead>
220-<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
221-</thead>
222-<tbody>
223-{{range .Artifacts}}
224-<tr>
225- <td><a href="{{.Name}}">{{.Name}}</a></td>
226- <td>{{.Size}}</td>
227- <td>{{.ModTime}}</td>
228-</tr>
229-{{end}}
230-</tbody>
231-</table>
232-{{end}}
233-</body>
234-</html>`
235-
236 // fmtDuration formats the duration between two unix timestamp strings.
237 // Returns human-readable strings like "2m34s", "1.5s", or "—" if invalid.
238 func fmtDuration(created, ended string) string {
+50,
-0
1@@ -0,0 +1,50 @@
2+<!DOCTYPE html>
3+<html lang="en">
4+<head>
5+<meta charset="UTF-8">
6+<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+<title>CI Job: {{.Name}} ({{.JobID}})</title>
8+<link rel="stylesheet" href="styles.css">
9+</head>
10+<body>
11+<div class="links"><a href="..">{{.Name}}</a></div>
12+<h1>{{.Name}} <span class="pill pill-{{.JobStatus}}">{{.JobStatus}}</span></h1>
13+<p class="meta">Job ID: {{.JobID}} · {{len .Rows}} task(s)</p>
14+<table>
15+<thead>
16+<tr><th>Task</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Started</th><th>Ended</th><th>Raw</th></tr>
17+</thead>
18+<tbody>
19+{{range .Rows}}
20+<tr>
21+ <td><a href="{{.Short}}.html"><strong>{{.Short}}</strong></a></td>
22+ <td><span class="pill pill-{{.Status}}">{{.Status}}</span></td>
23+ <td>{{.ExitCode}}</td>
24+ <td>{{.Duration}}</td>
25+ <td>{{.Started | formatTimestamp}}</td>
26+ <td>{{.Ended | formatTimestamp}}</td>
27+ <td><a href="{{.Short}}.txt">raw</a></td>
28+</tr>
29+{{end}}
30+</tbody>
31+</table>
32+
33+{{if .Artifacts}}
34+<h2>Other Artifacts</h2>
35+<table>
36+<thead>
37+<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
38+</thead>
39+<tbody>
40+{{range .Artifacts}}
41+<tr>
42+ <td><a href="{{.Name}}">{{.Name}}</a></td>
43+ <td>{{.Size}}</td>
44+ <td>{{.ModTime}}</td>
45+</tr>
46+{{end}}
47+</tbody>
48+</table>
49+{{end}}
50+</body>
51+</html>
+25,
-0
1@@ -0,0 +1,25 @@
2+<!DOCTYPE html>
3+<html lang="en">
4+<head>
5+<meta charset="UTF-8">
6+<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+<title>{{.SessionShort}} — CI Session</title>
8+<link rel="stylesheet" href="styles.css">
9+</head>
10+<body>
11+<h1>
12+ {{.SessionShort}}
13+ <span class="status status-{{.SessionStatus}}">{{.SessionStatus}}</span>
14+</h1>
15+<div class="meta">
16+ <span>Job: {{.JobName}}</span>
17+ <span>ID: {{.JobID}}</span>
18+ <span>Duration: {{.Duration}}</span>
19+ {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}}
20+</div>
21+<div class="zmx-output">{{.Content}}</div>
22+<div class="links">
23+ <a href="index.html">← Back to job</a>
24+</div>
25+</body>
26+</html>
+95,
-0
1@@ -0,0 +1,95 @@
2+/* pici monitor — shared styles (Catppuccin Mocha palette) */
3+* { box-sizing: border-box; }
4+
5+body {
6+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
7+ margin: 0;
8+ padding: 1.5rem;
9+ background: #1e1e2e;
10+ color: #cdd6f4;
11+ font-size: 14px;
12+ line-height: 1.5;
13+}
14+
15+h1 {
16+ font-size: 1rem;
17+ font-weight: 600;
18+ margin: 0 0 1rem 0;
19+ color: #89b4fa;
20+ display: flex;
21+ align-items: center;
22+ gap: 0.75rem;
23+ flex-wrap: wrap;
24+}
25+
26+h2 {
27+ font-size: 0.9rem;
28+ font-weight: 600;
29+ margin: 2rem 0 0.75rem 0;
30+ color: #89b4fa;
31+}
32+
33+.meta {
34+ color: #6c7086;
35+ font-size: 0.8rem;
36+ margin-bottom: 1rem;
37+ display: flex;
38+ gap: 1.5rem;
39+ flex-wrap: wrap;
40+}
41+
42+.meta span { display: flex; align-items: center; gap: 0.3rem; }
43+
44+/* Status badges */
45+.status, .pill {
46+ display: inline-block;
47+ padding: 0.15rem 0.5rem;
48+ border-radius: 4px;
49+ font-size: 0.75rem;
50+ font-weight: 600;
51+}
52+
53+.status-running, .pill-running { background: #89b4fa33; color: #89b4fa; }
54+.status-success, .pill-success { background: #a6e3a133; color: #a6e3a1; }
55+.status-failed, .pill-failed { background: #f38ba833; color: #f38ba8; }
56+
57+/* Tables */
58+table {
59+ border-collapse: collapse;
60+ width: 100%;
61+ max-width: 800px;
62+}
63+
64+th {
65+ text-align: left;
66+ padding: 0.5rem 0.75rem;
67+ border-bottom: 2px solid #313244;
68+ font-size: 0.8rem;
69+ text-transform: uppercase;
70+ color: #6c7086;
71+}
72+
73+td {
74+ padding: 0.5rem 0.75rem;
75+ border-bottom: 1px solid #313244;
76+}
77+
78+/* Pre / code output */
79+pre {
80+ background: #11111b;
81+ border-radius: 8px;
82+ padding: 1rem;
83+ overflow-x: auto;
84+ margin: 0;
85+ border: 1px solid #313244;
86+}
87+
88+pre code { font-size: 13px; }
89+
90+.zmx-output { white-space: pre-wrap; word-break: break-word; }
91+
92+/* Links */
93+a { color: #89b4fa; text-decoration: none; }
94+a:hover { text-decoration: underline; }
95+
96+.links { margin-top: 1rem; color: #6c7086; font-size: 0.85rem; }