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">&larr; 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}} &middot; {{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 {
A tmpl/index.html
+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}} &middot; {{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>
A tmpl/session.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">&larr; Back to job</a>
24+</div>
25+</body>
26+</html>
A tmpl/styles.css
+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; }