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
+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">&larr; 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}} &middot; {{len .Rows}} session(s)</p>
190+<p class="meta">Job ID: {{.JobID}} &middot; {{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