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
| ... | ... | @@ -5,11 +5,13 @@ import ( | |
| 5 | 5 | "bufio" | |
| 6 | 6 | "context" | |
| 7 | 7 | "crypto/sha256" | |
| 8 | + | "embed" | |
| 8 | 9 | "encoding/json" | |
| 9 | 10 | "flag" | |
| 10 | 11 | "fmt" | |
| 11 | 12 | "html/template" | |
| 12 | 13 | "io" | |
| 14 | + | "io/fs" | |
| 13 | 15 | "log/slog" | |
| 14 | 16 | "os" | |
| 15 | 17 | "os/exec" |
| ... | ... | @@ -21,6 +23,9 @@ import ( | |
| 21 | 23 | "time" | |
| 22 | 24 | ) | |
| 23 | 25 | ||
| 26 | + | //go:embed tmpl/* | |
| 27 | + | var tmplFS embed.FS | |
| 28 | + | ||
| 24 | 29 | type WorkspaceFactory func(cfg *Cfg, logger *slog.Logger, source string) Workspace | |
| 25 | 30 | ||
| 26 | 31 | 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 | |
| 1037 | 1042 | if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexTXT, ".txt"); err != nil { | |
| 1038 | 1043 | log.Error("stage index.txt", "err", err) | |
| 1039 | 1044 | } | |
| 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 | + | } | |
| 1040 | 1051 | ||
| 1041 | 1052 | // Load event to get artifact destination | |
| 1042 | 1053 | eventData, _ := loadEvent(cfg.ArtifactDir, name, jobID) |
| ... | ... | @@ -1091,6 +1102,14 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str | |
| 1091 | 1102 | if err := os.WriteFile(sentinel, publishedJSON, 0644); err != nil { | |
| 1092 | 1103 | log.Error("write published sentinel", "err", err) | |
| 1093 | 1104 | } | |
| 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 | + | } | |
| 1094 | 1113 | if err := syncJobArtifacts(cfg, name, jobID, log); err != nil { | |
| 1095 | 1114 | log.Error("sync artifacts", "err", err) | |
| 1096 | 1115 | } |
| ... | ... | @@ -1325,78 +1344,14 @@ func parseZMXList(output string) []SessionInfo { | |
| 1325 | 1344 | return sessions | |
| 1326 | 1345 | } | |
| 1327 | 1346 | ||
| 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">← 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 | + | } | |
| 1400 | 1355 | ||
| 1401 | 1356 | // SessionArtifactData holds the data for rendering a session HTML artifact. | |
| 1402 | 1357 | type SessionArtifactData struct { |
| ... | ... | @@ -1433,13 +1388,13 @@ func fetchHistoryHTML(sessionName, jobName, jobID, status, duration, exitCode st | |
| 1433 | 1388 | Content: template.HTML(string(output)), | |
| 1434 | 1389 | } | |
| 1435 | 1390 | ||
| 1436 | - | tmpl, err := template.New("session").Parse(sessionHTMLTemplate) | |
| 1391 | + | tmpl, err := template.ParseFS(tmplFS, "tmpl/session.html") | |
| 1437 | 1392 | if err != nil { | |
| 1438 | 1393 | return "", fmt.Errorf("parse template: %w", err) | |
| 1439 | 1394 | } | |
| 1440 | 1395 | ||
| 1441 | 1396 | var buf strings.Builder | |
| 1442 | - | if err := tmpl.Execute(&buf, data); err != nil { | |
| 1397 | + | if err := tmpl.ExecuteTemplate(&buf, "session.html", data); err != nil { | |
| 1443 | 1398 | return "", fmt.Errorf("execute template: %w", err) | |
| 1444 | 1399 | } | |
| 1445 | 1400 |
| ... | ... | @@ -1863,10 +1818,10 @@ func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) ( | |
| 1863 | 1818 | // HTML index | |
| 1864 | 1819 | tmpl, err := template.New("index").Funcs(template.FuncMap{ | |
| 1865 | 1820 | "formatTimestamp": formatTimestamp, | |
| 1866 | - | }).Parse(indexHTMLTemplate) | |
| 1821 | + | }).ParseFS(tmplFS, "tmpl/index.html") | |
| 1867 | 1822 | if err == nil { | |
| 1868 | 1823 | var buf strings.Builder | |
| 1869 | - | if err := tmpl.Execute(&buf, struct { | |
| 1824 | + | if err := tmpl.ExecuteTemplate(&buf, "index.html", struct { | |
| 1870 | 1825 | Name string | |
| 1871 | 1826 | JobID string | |
| 1872 | 1827 | JobStatus string |
| ... | ... | @@ -1895,69 +1850,6 @@ func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) ( | |
| 1895 | 1850 | return htmlContent, txtContent | |
| 1896 | 1851 | } | |
| 1897 | 1852 | ||
| 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}} · {{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 | - | ||
| 1961 | 1853 | // fmtDuration formats the duration between two unix timestamp strings. | |
| 1962 | 1854 | // Returns human-readable strings like "2m34s", "1.5s", or "—" if invalid. | |
| 1963 | 1855 | 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}} · {{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">← 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; } |