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
| ... | ... | @@ -1030,7 +1030,7 @@ func monitorTick(cfg *Cfg, log *slog.Logger, output io.Writer, jobStates map[str | |
| 1030 | 1030 | } | |
| 1031 | 1031 | ||
| 1032 | 1032 | // Generate and stage job index landing pages | |
| 1033 | - | indexHTML, indexTXT := generateJobIndex(name, jobID, group) | |
| 1033 | + | indexHTML, indexTXT := generateJobIndex(cfg.ArtifactDir, name, jobID, group) | |
| 1034 | 1034 | if err := stageArtifact(cfg.ArtifactDir, name, jobID, "index", indexHTML, ".html"); err != nil { | |
| 1035 | 1035 | log.Error("stage index.html", "err", err) | |
| 1036 | 1036 | } |
| ... | ... | @@ -1380,7 +1380,7 @@ const sessionHTMLTemplate = `<!DOCTYPE html> | |
| 1380 | 1380 | <span>Duration: {{.Duration}}</span> | |
| 1381 | 1381 | {{if .ExitCode}}<span>Exit: {{.ExitCode}}</span>{{end}} | |
| 1382 | 1382 | </div> | |
| 1383 | - | <pre><code>{{.Content}}</code></pre> | |
| 1383 | + | <div class="zmx-output">{{.Content}}</div> | |
| 1384 | 1384 | <div class="links"> | |
| 1385 | 1385 | <a href="index.html">← Back to job</a> | |
| 1386 | 1386 | </div> |
| ... | ... | @@ -1396,7 +1396,7 @@ type SessionArtifactData struct { | |
| 1396 | 1396 | JobID string | |
| 1397 | 1397 | Duration string | |
| 1398 | 1398 | ExitCode string | |
| 1399 | - | Content string | |
| 1399 | + | Content template.HTML | |
| 1400 | 1400 | } | |
| 1401 | 1401 | ||
| 1402 | 1402 | // fetchHistoryHTML fetches session history from zmx and wraps it in a full HTML document. |
| ... | ... | @@ -1419,7 +1419,7 @@ func fetchHistoryHTML(sessionName, jobName, jobID, status, duration, exitCode st | |
| 1419 | 1419 | JobID: jobID, | |
| 1420 | 1420 | Duration: duration, | |
| 1421 | 1421 | ExitCode: exitCode, | |
| 1422 | - | Content: string(output), | |
| 1422 | + | Content: template.HTML(string(output)), | |
| 1423 | 1423 | } | |
| 1424 | 1424 | ||
| 1425 | 1425 | tmpl, err := template.New("session").Parse(sessionHTMLTemplate) |
| ... | ... | @@ -1490,11 +1490,14 @@ func syncArtifacts(cfg *Cfg, log *slog.Logger) error { | |
| 1490 | 1490 | continue | |
| 1491 | 1491 | } | |
| 1492 | 1492 | log.Debug("syncing artifacts", "repo", entry.Name(), "job_id", repoEntry.Name(), "dest", event.ArtifactDest) | |
| 1493 | - | sshArgs := fmt.Sprintf( | |
| 1494 | - | "-F ~/.ssh/config -i %s -o IdentitiesOnly=yes -o CertificateFile %s", | |
| 1495 | - | cfg.KeyLocation, | |
| 1496 | - | cfg.CertificateLocation, | |
| 1497 | - | ) | |
| 1493 | + | var sshArgs string | |
| 1494 | + | if cfg.KeyLocation != "" { | |
| 1495 | + | certFile := "" | |
| 1496 | + | if cfg.CertificateLocation != "" { | |
| 1497 | + | certFile = fmt.Sprintf(" -o CertificateFile %s", cfg.CertificateLocation) | |
| 1498 | + | } | |
| 1499 | + | sshArgs = fmt.Sprintf("-F ~/.ssh/config -i %s%s", cfg.KeyLocation, certFile) | |
| 1500 | + | } | |
| 1498 | 1501 | srcDir := filepath.Join(cfg.ArtifactDir, entry.Name(), repoEntry.Name()) | |
| 1499 | 1502 | // Append "/" so rsync copies into the destination directory, | |
| 1500 | 1503 | // not as a subdirectory named after the source. |
| ... | ... | @@ -1502,9 +1505,18 @@ func syncArtifacts(cfg *Cfg, log *slog.Logger) error { | |
| 1502 | 1505 | if !strings.HasSuffix(dest, "/") { | |
| 1503 | 1506 | dest += "/" | |
| 1504 | 1507 | } | |
| 1505 | - | rsyncCmd := fmt.Sprintf("rsync -e '%s' -rv %s/ %s", sshArgs, srcDir, dest) | |
| 1508 | + | ||
| 1509 | + | // Build rsync command, omitting -e if no ssh args | |
| 1510 | + | var cmd *exec.Cmd | |
| 1511 | + | if sshArgs != "" { | |
| 1512 | + | cmd = exec.Command("rsync", "-e", sshArgs, "-rv", srcDir+"/", dest) | |
| 1513 | + | } else { | |
| 1514 | + | cmd = exec.Command("rsync", "-rv", srcDir+"/", dest) | |
| 1515 | + | } | |
| 1516 | + | rsyncCmd := fmt.Sprintf("rsync %s %s %s", | |
| 1517 | + | strings.TrimLeft(cmd.Args[1], "-"), | |
| 1518 | + | srcDir+"/", dest) | |
| 1506 | 1519 | log.Info("rsync", "cmd", rsyncCmd) | |
| 1507 | - | cmd := exec.Command("rsync", "-e", sshArgs, "-rv", srcDir+"/", dest) | |
| 1508 | 1520 | if err := runCmd(cmd, log); err != nil { | |
| 1509 | 1521 | log.Error("sync artifacts", "repo", entry.Name(), "job_id", repoEntry.Name(), "err", err) | |
| 1510 | 1522 | } |
| ... | ... | @@ -1715,18 +1727,55 @@ type sessionRow struct { | |
| 1715 | 1727 | Short string | |
| 1716 | 1728 | Status string | |
| 1717 | 1729 | ExitCode string | |
| 1730 | + | Started string | |
| 1718 | 1731 | Ended string | |
| 1719 | 1732 | Duration string | |
| 1720 | 1733 | } | |
| 1721 | 1734 | ||
| 1735 | + | // artifactRow holds display info for non-session artifacts (e.g., event.json, workspace.tar). | |
| 1736 | + | type artifactRow struct { | |
| 1737 | + | Name string | |
| 1738 | + | Size string | |
| 1739 | + | ModTime string | |
| 1740 | + | } | |
| 1741 | + | ||
| 1742 | + | // formatFileSize returns a human-readable file size string. | |
| 1743 | + | func formatFileSize(bytes int64) string { | |
| 1744 | + | const unit = 1024 | |
| 1745 | + | if bytes < unit { | |
| 1746 | + | return fmt.Sprintf("%d B", bytes) | |
| 1747 | + | } | |
| 1748 | + | div, exp := int64(unit), 0 | |
| 1749 | + | for n := bytes / unit; n >= unit; n /= unit { | |
| 1750 | + | div *= unit | |
| 1751 | + | exp++ | |
| 1752 | + | } | |
| 1753 | + | units := []string{"KB", "MB", "GB", "TB"} | |
| 1754 | + | return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp]) | |
| 1755 | + | } | |
| 1756 | + | ||
| 1757 | + | // formatTimestamp converts a unix timestamp to a human-readable format. | |
| 1758 | + | func formatTimestamp(ts string) string { | |
| 1759 | + | if ts == "" { | |
| 1760 | + | return "—" | |
| 1761 | + | } | |
| 1762 | + | var t int64 | |
| 1763 | + | if _, err := fmt.Sscanf(ts, "%d", &t); err != nil { | |
| 1764 | + | return "—" | |
| 1765 | + | } | |
| 1766 | + | return time.Unix(t, 0).UTC().Format("2006-01-02 15:04:05") | |
| 1767 | + | } | |
| 1768 | + | ||
| 1722 | 1769 | // generateJobIndex produces HTML and plain-text index pages listing all | |
| 1723 | 1770 | // sessions for a job with links and metadata (status, exit code, ended at). | |
| 1724 | - | func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent, txtContent string) { | |
| 1771 | + | // It also includes any other artifacts in the job directory (e.g., event.json, workspace.tar). | |
| 1772 | + | func generateJobIndex(artifactDir, name, jobID string, sessions []SessionInfo) (htmlContent, txtContent string) { | |
| 1725 | 1773 | rows := make([]sessionRow, 0, len(sessions)) | |
| 1726 | 1774 | for _, s := range sessions { | |
| 1727 | 1775 | row := sessionRow{ | |
| 1728 | - | Name: s.Name, | |
| 1729 | - | Short: s.Short, | |
| 1776 | + | Name: s.Name, | |
| 1777 | + | Short: s.Short, | |
| 1778 | + | Started: s.Created, | |
| 1730 | 1779 | } | |
| 1731 | 1780 | if s.Ended == "" { | |
| 1732 | 1781 | row.Status = "running" |
| ... | ... | @@ -1743,6 +1792,26 @@ func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent, | |
| 1743 | 1792 | rows = append(rows, row) | |
| 1744 | 1793 | } | |
| 1745 | 1794 | ||
| 1795 | + | // Gather other artifacts in the job directory | |
| 1796 | + | var artifacts []artifactRow | |
| 1797 | + | jobDir := filepath.Join(artifactDir, name, jobID) | |
| 1798 | + | if entries, err := os.ReadDir(jobDir); err == nil { | |
| 1799 | + | for _, e := range entries { | |
| 1800 | + | if e.IsDir() { | |
| 1801 | + | continue // skip subdirs (task dirs) | |
| 1802 | + | } | |
| 1803 | + | info, err := e.Info() | |
| 1804 | + | if err != nil { | |
| 1805 | + | continue | |
| 1806 | + | } | |
| 1807 | + | artifacts = append(artifacts, artifactRow{ | |
| 1808 | + | Name: e.Name(), | |
| 1809 | + | Size: formatFileSize(info.Size()), | |
| 1810 | + | ModTime: formatTimestamp(fmt.Sprintf("%d", info.ModTime().Unix())), | |
| 1811 | + | }) | |
| 1812 | + | } | |
| 1813 | + | } | |
| 1814 | + | ||
| 1746 | 1815 | // Resolve overall job status | |
| 1747 | 1816 | jobStatus := "success" | |
| 1748 | 1817 | hasRunning := false |
| ... | ... | @@ -1760,7 +1829,9 @@ func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent, | |
| 1760 | 1829 | } | |
| 1761 | 1830 | ||
| 1762 | 1831 | // HTML index | |
| 1763 | - | tmpl, err := template.New("index").Parse(indexHTMLTemplate) | |
| 1832 | + | tmpl, err := template.New("index").Funcs(template.FuncMap{ | |
| 1833 | + | "formatTimestamp": formatTimestamp, | |
| 1834 | + | }).Parse(indexHTMLTemplate) | |
| 1764 | 1835 | if err == nil { | |
| 1765 | 1836 | var buf strings.Builder | |
| 1766 | 1837 | if err := tmpl.Execute(&buf, struct { |
| ... | ... | @@ -1768,7 +1839,8 @@ func generateJobIndex(name, jobID string, sessions []SessionInfo) (htmlContent, | |
| 1768 | 1839 | JobID string | |
| 1769 | 1840 | JobStatus string | |
| 1770 | 1841 | Rows []sessionRow | |
| 1771 | - | }{Name: name, JobID: jobID, JobStatus: jobStatus, Rows: rows}); err == nil { | |
| 1842 | + | Artifacts []artifactRow | |
| 1843 | + | }{Name: name, JobID: jobID, JobStatus: jobStatus, Rows: rows, Artifacts: artifacts}); err == nil { | |
| 1772 | 1844 | htmlContent = buf.String() | |
| 1773 | 1845 | } | |
| 1774 | 1846 | } |
| ... | ... | @@ -1814,10 +1886,10 @@ const indexHTMLTemplate = `<!DOCTYPE html> | |
| 1814 | 1886 | </head> | |
| 1815 | 1887 | <body> | |
| 1816 | 1888 | <h1>{{.Name}} <span class="pill pill-{{.JobStatus}}">{{.JobStatus}}</span></h1> | |
| 1817 | - | <p class="meta">Job ID: {{.JobID}} · {{len .Rows}} session(s)</p> | |
| 1889 | + | <p class="meta">Job ID: {{.JobID}} · {{len .Rows}} task(s)</p> | |
| 1818 | 1890 | <table> | |
| 1819 | 1891 | <thead> | |
| 1820 | - | <tr><th>Session</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Ended</th><th>Artifacts</th></tr> | |
| 1892 | + | <tr><th>Task</th><th>Status</th><th>Exit Code</th><th>Duration</th><th>Started</th><th>Ended</th><th>Artifacts</th></tr> | |
| 1821 | 1893 | </thead> | |
| 1822 | 1894 | <tbody> | |
| 1823 | 1895 | {{range .Rows}} |
| ... | ... | @@ -1826,12 +1898,31 @@ const indexHTMLTemplate = `<!DOCTYPE html> | |
| 1826 | 1898 | <td><span class="pill pill-{{.Status}}">{{.Status}}</span></td> | |
| 1827 | 1899 | <td>{{.ExitCode}}</td> | |
| 1828 | 1900 | <td>{{.Duration}}</td> | |
| 1829 | - | <td>{{.Ended}}</td> | |
| 1901 | + | <td>{{.Started | formatTimestamp}}</td> | |
| 1902 | + | <td>{{.Ended | formatTimestamp}}</td> | |
| 1830 | 1903 | <td><a href="{{.Short}}.html">html</a> / <a href="{{.Short}}.txt">txt</a></td> | |
| 1831 | 1904 | </tr> | |
| 1832 | 1905 | {{end}} | |
| 1833 | 1906 | </tbody> | |
| 1834 | 1907 | </table> | |
| 1908 | + | ||
| 1909 | + | {{if .Artifacts}} | |
| 1910 | + | <h2 style="margin-top: 2rem; font-size: 1rem; color: #555;">Other Artifacts</h2> | |
| 1911 | + | <table> | |
| 1912 | + | <thead> | |
| 1913 | + | <tr><th>Name</th><th>Size</th><th>Modified</th></tr> | |
| 1914 | + | </thead> | |
| 1915 | + | <tbody> | |
| 1916 | + | {{range .Artifacts}} | |
| 1917 | + | <tr> | |
| 1918 | + | <td><a href="{{.Name}}">{{.Name}}</a></td> | |
| 1919 | + | <td>{{.Size}}</td> | |
| 1920 | + | <td>{{.ModTime}}</td> | |
| 1921 | + | </tr> | |
| 1922 | + | {{end}} | |
| 1923 | + | </tbody> | |
| 1924 | + | </table> | |
| 1925 | + | {{end}} | |
| 1835 | 1926 | </body> | |
| 1836 | 1927 | </html>` | |
| 1837 | 1928 |