Commit 15b8e8f
Eric Bower
·
2026-05-08 09:46:26 -0400 EDT
parent 595103a
chore: html artifact work
1 files changed,
+110,
-19
M
main.go
M
main.go
+110,
-19
1@@ -1030,7 +1030,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str
2 }
3
4 // Generate and stage job index landing pages
5- indexHTML, indexTXT := generateJobIndex(name, jobID, group)
6+ indexHTML, indexTXT := generateJobIndex(cfg.ArtifactDir, name, jobID, group)
7 if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexHTML, ".html"); err != nil {
8 log.Error("stage index.html", "err", err)
9 }
10@@ -1380,7 +1380,7 @@ const sessionHTMLTemplate = `<!DOCTYPE html>
11 <span>Duration: {{.Duration}}</span>
12 {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}}
13 </div>
14-<pre><code>{{.Content}}</code></pre>
15+<div class="zmx-output">{{.Content}}</div>
16 <div class="links">
17 <a href="index.html">← Back to job</a>
18 </div>
19@@ -1396,7 +1396,7 @@ type SessionArtifactData struct {
20 JobID string
21 Duration string
22 ExitCode string
23- Content string
24+ Content template.HTML
25 }
26
27 // fetchHistoryHTML fetches session history from zmx and wraps it in a full HTML document.
28@@ -1419,7 +1419,7 @@ func fetchHistoryHTML(sessionName, jobName, jobID, status, duration, exitCode st
29 JobID: jobID,
30 Duration: duration,
31 ExitCode: exitCode,
32- Content: string(output),
33+ Content: template.HTML(string(output)),
34 }
35
36 tmpl, err := template.New("session").Parse(sessionHTMLTemplate)
37@@ -1490,11 +1490,14 @@ func syncArtifacts(cfg *Cfg, log *slog.Logger) error {
38 continue
39 }
40 log.Debug("syncing artifacts", "repo", entry.Name(), "job_id", repoEntry.Name(), "dest", event.ArtifactDest)
41- sshArgs := fmt.Sprintf(
42- "-F ~/.ssh/config -i %s -o IdentitiesOnly=yes -o CertificateFile %s",
43- cfg.KeyLocation,
44- cfg.CertificateLocation,
45- )
46+ var sshArgs string
47+ if cfg.KeyLocation != "" {
48+ certFile := ""
49+ if cfg.CertificateLocation != "" {
50+ certFile = fmt.Sprintf(" -o CertificateFile %s", cfg.CertificateLocation)
51+ }
52+ sshArgs = fmt.Sprintf("-F ~/.ssh/config -i %s%s", cfg.KeyLocation, certFile)
53+ }
54 srcDir := filepath.Join(cfg.ArtifactDir, entry.Name(), repoEntry.Name())
55 // Append "/" so rsync copies into the destination directory,
56 // not as a subdirectory named after the source.
57@@ -1502,9 +1505,18 @@ func syncArtifacts(cfg *Cfg, log *slog.Logger) error {
58 if !strings.HasSuffix(dest, "/") {
59 dest += "/"
60 }
61- rsyncCmd := fmt.Sprintf("rsync -e '%s' -rv %s/ %s", sshArgs, srcDir, dest)
62+
63+ // Build rsync command, omitting -e if no ssh args
64+ var cmd *exec.Cmd
65+ if sshArgs != "" {
66+ cmd = exec.Command("rsync", "-e", sshArgs, "-rv", srcDir+"/", dest)
67+ } else {
68+ cmd = exec.Command("rsync", "-rv", srcDir+"/", dest)
69+ }
70+ rsyncCmd := fmt.Sprintf("rsync %s %s %s",
71+ strings.TrimLeft(cmd.Args[1], "-"),
72+ srcDir+"/", dest)
73 log.Info("rsync", "cmd", rsyncCmd)
74- cmd := exec.Command("rsync", "-e", sshArgs, "-rv", srcDir+"/", dest)
75 if err := runCmd(cmd, log); err != nil {
76 log.Error("sync artifacts", "repo", entry.Name(), "job_id", repoEntry.Name(), "err", err)
77 }
78@@ -1715,18 +1727,55 @@ type sessionRow struct {
79 Short string
80 Status string
81 ExitCode string
82+ Started string
83 Ended string
84 Duration string
85 }
86
87+// artifactRow holds display info for non-session artifacts (e.g., event.json, workspace.tar).
88+type artifactRow struct {
89+ Name string
90+ Size string
91+ ModTime string
92+}
93+
94+// formatFileSize returns a human-readable file size string.
95+func formatFileSize(bytes int64) string {
96+ const unit = 1024
97+ if bytes < unit {
98+ return fmt.Sprintf("%d B", bytes)
99+ }
100+ div, exp := int64(unit), 0
101+ for n := bytes / unit; n >= unit; n /= unit {
102+ div *= unit
103+ exp++
104+ }
105+ units := []string{"KB", "MB", "GB", "TB"}
106+ return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp])
107+}
108+
109+// formatTimestamp converts a unix timestamp to a human-readable format.
110+func formatTimestamp(ts string) string {
111+ if ts == "" {
112+ return "—"
113+ }
114+ var t int64
115+ if _, err := fmt.Sscanf(ts, "%d", &t); err != nil {
116+ return "—"
117+ }
118+ return time.Unix(t, 0).UTC().Format("2006-01-02 15:04:05")
119+}
120+
121 // generateJobIndex produces HTML and plain-text index pages listing all
122 // sessions for a job with links and metadata (status, exit code, ended at).
123-func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent, txtContent string) {
124+// It also includes any other artifacts in the job directory (e.g., event.json, workspace.tar).
125+func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) (htmlContent, txtContent string) {
126 rows := make([]sessionRow, 0, len(sessions))
127 for _, s := range sessions {
128 row := sessionRow{
129- Name: s.Name,
130- Short: s.Short,
131+ Name: s.Name,
132+ Short: s.Short,
133+ Started: s.Created,
134 }
135 if s.Ended == "" {
136 row.Status = "running"
137@@ -1743,6 +1792,26 @@ func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent,
138 rows = append(rows, row)
139 }
140
141+ // Gather other artifacts in the job directory
142+ var artifacts []artifactRow
143+ jobDir := filepath.Join(artifactDir, name, jobID)
144+ if entries, err := os.ReadDir(jobDir); err == nil {
145+ for _, e := range entries {
146+ if e.IsDir() {
147+ continue // skip subdirs (task dirs)
148+ }
149+ info, err := e.Info()
150+ if err != nil {
151+ continue
152+ }
153+ artifacts = append(artifacts, artifactRow{
154+ Name: e.Name(),
155+ Size: formatFileSize(info.Size()),
156+ ModTime: formatTimestamp(fmt.Sprintf("%d", info.ModTime().Unix())),
157+ })
158+ }
159+ }
160+
161 // Resolve overall job status
162 jobStatus := "success"
163 hasRunning := false
164@@ -1760,7 +1829,9 @@ func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent,
165 }
166
167 // HTML index
168- tmpl, err := template.New("index").Parse(indexHTMLTemplate)
169+ tmpl, err := template.New("index").Funcs(template.FuncMap{
170+ "formatTimestamp": formatTimestamp,
171+ }).Parse(indexHTMLTemplate)
172 if err == nil {
173 var buf strings.Builder
174 if err := tmpl.Execute(&buf, struct {
175@@ -1768,7 +1839,8 @@ func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent,
176 JobID string
177 JobStatus string
178 Rows []sessionRow
179- }{Name: name, JobID: jobID, JobStatus: jobStatus, Rows: rows}); err == nil {
180+ Artifacts []artifactRow
181+ }{Name: name, JobID: jobID, JobStatus: jobStatus, Rows: rows, Artifacts: artifacts}); err == nil {
182 htmlContent = buf.String()
183 }
184 }
185@@ -1814,10 +1886,10 @@ const indexHTMLTemplate = `<!DOCTYPE html>
186 </head>
187 <body>
188 <h1>{{.Name}} <span class="pill pill-{{.JobStatus}}">{{.JobStatus}}</span></h1>
189-<p class="meta">Job ID: {{.JobID}} · {{len .Rows}} session(s)</p>
190+<p class="meta">Job ID: {{.JobID}} · {{len .Rows}} task(s)</p>
191 <table>
192 <thead>
193-<tr><th>Session</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Ended</th><th>Artifacts</th></tr>
194+<tr><th>Task</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Started</th><th>Ended</th><th>Artifacts</th></tr>
195 </thead>
196 <tbody>
197 {{range .Rows}}
198@@ -1826,12 +1898,31 @@ const indexHTMLTemplate = `<!DOCTYPE html>
199 <td><span class="pill pill-{{.Status}}">{{.Status}}</span></td>
200 <td>{{.ExitCode}}</td>
201 <td>{{.Duration}}</td>
202- <td>{{.Ended}}</td>
203+ <td>{{.Started | formatTimestamp}}</td>
204+ <td>{{.Ended | formatTimestamp}}</td>
205 <td><a href="{{.Short}}.html">html</a> / <a href="{{.Short}}.txt">txt</a></td>
206 </tr>
207 {{end}}
208 </tbody>
209 </table>
210+
211+{{if .Artifacts}}
212+<h2 style="margin-top: 2rem; font-size: 1rem; color: #555;">Other Artifacts</h2>
213+<table>
214+<thead>
215+<tr><th>Name</th><th>Size</th><th>Modified</th></tr>
216+</thead>
217+<tbody>
218+{{range .Artifacts}}
219+<tr>
220+ <td><a href="{{.Name}}">{{.Name}}</a></td>
221+ <td>{{.Size}}</td>
222+ <td>{{.ModTime}}</td>
223+</tr>
224+{{end}}
225+</tbody>
226+</table>
227+{{end}}
228 </body>
229 </html>`
230