commit a922fbb10f7e22c34d8a8a5d33e78b6a51652da2
parent 5fd3252890f9f6d9433fe3cf728d4da919fd61c7
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date: Wed, 25 Feb 2026 07:53:29 +0000
Add run navigation to web UI for multi-run commits
- Add dropdown selector to switch between runs for the same commit
- Add prev/next navigation buttons
- Update URL scheme to include run ID: /jci/<commit>/<runid>/<file>
- Show run timestamp in selector for easy identification
- Each run is stored independently in refs/jci-runs/<commit>/<runid>
Co-authored-by: Shelley <shelley@exe.dev>
Diffstat:
| M | internal/jci/web.go | | | 241 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------- |
1 file changed, 199 insertions(+), 42 deletions(-)
diff --git a/internal/jci/web.go b/internal/jci/web.go
@@ -74,12 +74,19 @@ func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) {
}
// /jci/<commit>/<file>/raw - serve raw file
+ // Also handles /jci/<commit>/<runid>/<file>/raw
if strings.HasPrefix(path, "/jci/") && strings.HasSuffix(path, "/raw") {
trimmed := strings.TrimPrefix(path, "/jci/")
trimmed = strings.TrimSuffix(trimmed, "/raw")
- parts := strings.SplitN(trimmed, "/", 2)
+ // trimmed is now: <commit>/<file> or <commit>/<runid>/<file>
+ parts := strings.SplitN(trimmed, "/", 3)
if len(parts) == 2 && parts[1] != "" {
- serveFromRef(w, parts[0], parts[1])
+ // <commit>/<file>
+ serveFromRef(w, parts[0], "", parts[1])
+ return
+ } else if len(parts) == 3 && parts[2] != "" {
+ // <commit>/<runid>/<file>
+ serveFromRef(w, parts[0], parts[1], parts[2])
return
}
}
@@ -229,12 +236,14 @@ func getCIStatusFromRef(ref string) string {
// CommitDetail holds detailed commit info for the API
type CommitDetail struct {
- Hash string `json:"hash"`
- Author string `json:"author"`
- Date string `json:"date"`
- Status string `json:"status"`
- Files []string `json:"files"`
- Ref string `json:"ref"` // The actual ref used
+ Hash string `json:"hash"`
+ Author string `json:"author"`
+ Date string `json:"date"`
+ Status string `json:"status"`
+ Files []string `json:"files"`
+ Ref string `json:"ref"` // The actual ref used
+ RunID string `json:"runId"` // Current run ID
+ Runs []RunInfo `json:"runs"` // All runs for this commit
}
// serveCommitAPI returns commit details and file list
@@ -242,23 +251,28 @@ type CommitDetail struct {
func serveCommitAPI(w http.ResponseWriter, commit string) {
var ref string
var actualCommit string
+ var currentRunID string
// Check if this is a run-specific request: <commit>/<runid>
if strings.Contains(commit, "/") {
parts := strings.SplitN(commit, "/", 2)
actualCommit = parts[0]
- runID := parts[1]
- ref = "refs/jci-runs/" + actualCommit + "/" + runID
+ currentRunID = parts[1]
+ ref = "refs/jci-runs/" + actualCommit + "/" + currentRunID
} else {
actualCommit = commit
ref = "refs/jci/" + commit
// Check if single-run ref exists, otherwise try to find latest run
if !RefExists(ref) {
- // Look for runs
+ // Look for runs - get the latest one
runRefs, _ := ListJCIRunRefs()
for _, r := range runRefs {
if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
ref = r // Use the last one (they're sorted)
+ parts := strings.Split(r, "/")
+ if len(parts) >= 4 {
+ currentRunID = parts[3]
+ }
}
}
}
@@ -269,6 +283,24 @@ func serveCommitAPI(w http.ResponseWriter, commit string) {
return
}
+ // Get all runs for this commit
+ var runs []RunInfo
+ runRefs, _ := ListJCIRunRefs()
+ for _, r := range runRefs {
+ if strings.HasPrefix(r, "refs/jci-runs/"+actualCommit+"/") {
+ parts := strings.Split(r, "/")
+ if len(parts) >= 4 {
+ runID := parts[3]
+ status := getCIStatusFromRef(r)
+ runs = append(runs, RunInfo{
+ RunID: runID,
+ Status: status,
+ Ref: r,
+ })
+ }
+ }
+ }
+
// Get commit info
author, _ := git("log", "-1", "--format=%an", actualCommit)
date, _ := git("log", "-1", "--format=%cr", actualCommit)
@@ -292,6 +324,8 @@ func serveCommitAPI(w http.ResponseWriter, commit string) {
Status: status,
Files: files,
Ref: ref,
+ RunID: currentRunID,
+ Runs: runs,
}
w.Header().Set("Content-Type", "application/json")
@@ -445,6 +479,12 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
.commit-info .status-icon.running { color: #9a6700; }
.commit-info .hash { color: #0969da; font-family: monospace; }
.commit-info .meta { color: #656d76; margin-top: 4px; font-size: 11px; }
+ .run-selector { margin-top: 8px; }
+ .run-selector select { width: 100%; padding: 4px 6px; font-size: 11px; border: 1px solid #d0d7de; border-radius: 4px; background: #fff; }
+ .run-nav { display: flex; gap: 4px; margin-top: 6px; }
+ .run-nav button { flex: 1; padding: 4px 8px; font-size: 10px; border: 1px solid #d0d7de; border-radius: 4px; background: #f6f8fa; cursor: pointer; }
+ .run-nav button:hover { background: #eaeef2; }
+ .run-nav button:disabled { opacity: 0.5; cursor: not-allowed; }
.file-list {
list-style: none;
overflow-y: auto;
@@ -549,7 +589,7 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
</div>
<script>
- let branches = [], currentCommit = null, currentFiles = [], currentFile = null;
+ let branches = [], currentCommit = null, currentFiles = [], currentFile = null, currentRuns = [], currentRunId = null;
async function loadBranches() {
const res = await fetch('/api/branches');
@@ -643,11 +683,60 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; }
else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; }
+ // Store runs info
+ currentRuns = info.runs || [];
+ currentRunId = info.runId || null;
+
+ // Build run selector if multiple runs
+ let runSelectorHtml = '';
+ if (currentRuns.length > 1) {
+ const runIdx = currentRuns.findIndex(r => r.runId === currentRunId);
+ runSelectorHtml = '<div class="run-selector">' +
+ '<select id="runSelect">' +
+ currentRuns.map((r, i) => {
+ const ts = parseInt(r.runId.split('-')[0]) * 1000;
+ const date = new Date(ts).toLocaleString();
+ const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?';
+ const selected = r.runId === currentRunId ? ' selected' : '';
+ return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>';
+ }).join('') +
+ '</select></div>' +
+ '<div class="run-nav">' +
+ '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' +
+ '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' +
+ '</div>';
+ } else if (currentRuns.length === 1) {
+ const ts = parseInt(currentRuns[0].runId.split('-')[0]) * 1000;
+ const date = new Date(ts).toLocaleString();
+ runSelectorHtml = '<div class="meta">Run: ' + date + '</div>';
+ }
+
document.getElementById('commitInfo').innerHTML =
'<div class="status-line">' +
'<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' +
'<span class="hash">' + hash.slice(0,7) + '</span></div>' +
- '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>';
+ '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' +
+ runSelectorHtml;
+
+ // Set up run selector events
+ const runSelect = document.getElementById('runSelect');
+ if (runSelect) {
+ runSelect.onchange = (e) => selectRun(hash, e.target.value);
+ }
+ const prevBtn = document.getElementById('prevRun');
+ const nextBtn = document.getElementById('nextRun');
+ if (prevBtn) {
+ prevBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx > 0) selectRun(hash, currentRuns[idx-1].runId);
+ };
+ }
+ if (nextBtn) {
+ nextBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId);
+ };
+ }
currentFiles = info.files || [];
const fileList = document.getElementById('fileList');
@@ -679,13 +768,15 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
const contentHeader = document.getElementById('contentHeader');
const contentBody = document.getElementById('contentBody');
- const rawUrl = '/jci/' + currentCommit + '/' + name + '/raw';
+ // Include runId in URL if available
+ const commitPath = currentRunId ? currentCommit + '/' + currentRunId : currentCommit;
+ const rawUrl = '/jci/' + commitPath + '/' + name + '/raw';
contentHeader.innerHTML = '<span class="filename">' + escapeHtml(name) + '</span>' +
'<a class="download-btn" href="' + rawUrl + '" target="_blank">↓ Download</a>';
if (!skipHistory) {
- history.pushState(null, '', '/jci/' + currentCommit + '/' + name);
+ history.pushState(null, '', '/jci/' + commitPath + '/' + name);
}
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
@@ -705,6 +796,87 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
}
}
+ async function selectRun(hash, runId) {
+ currentRunId = runId;
+ currentFile = null;
+ // Fetch the specific run
+ const infoRes = await fetch('/api/commit/' + hash + '/' + runId);
+ const info = await infoRes.json();
+
+ let statusIcon = '?';
+ let statusClass = '';
+ if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; }
+ else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; }
+ else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; }
+
+ // Update runs from response
+ currentRuns = info.runs || [];
+
+ // Build run selector
+ let runSelectorHtml = '';
+ if (currentRuns.length > 1) {
+ const runIdx = currentRuns.findIndex(r => r.runId === currentRunId);
+ runSelectorHtml = '<div class="run-selector">' +
+ '<select id="runSelect">' +
+ currentRuns.map((r, i) => {
+ const ts = parseInt(r.runId.split('-')[0]) * 1000;
+ const date = new Date(ts).toLocaleString();
+ const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?';
+ const selected = r.runId === currentRunId ? ' selected' : '';
+ return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>';
+ }).join('') +
+ '</select></div>' +
+ '<div class="run-nav">' +
+ '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' +
+ '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' +
+ '</div>';
+ }
+
+ document.getElementById('commitInfo').innerHTML =
+ '<div class="status-line">' +
+ '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' +
+ '<span class="hash">' + hash.slice(0,7) + '</span></div>' +
+ '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' +
+ runSelectorHtml;
+
+ // Re-attach event listeners
+ const runSelect = document.getElementById('runSelect');
+ if (runSelect) {
+ runSelect.onchange = (e) => selectRun(hash, e.target.value);
+ }
+ const prevBtn = document.getElementById('prevRun');
+ const nextBtn = document.getElementById('nextRun');
+ if (prevBtn) {
+ prevBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx > 0) selectRun(hash, currentRuns[idx-1].runId);
+ };
+ }
+ if (nextBtn) {
+ nextBtn.onclick = () => {
+ const idx = currentRuns.findIndex(r => r.runId === currentRunId);
+ if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId);
+ };
+ }
+
+ // Update files
+ currentFiles = info.files || [];
+ const fileList = document.getElementById('fileList');
+ fileList.innerHTML = currentFiles.map(f =>
+ '<li class="file-item" data-file="' + f + '">' + f + '</li>'
+ ).join('');
+ fileList.querySelectorAll('.file-item').forEach(el => {
+ el.onclick = () => loadFile(el.dataset.file);
+ });
+
+ // Update URL
+ history.pushState(null, '', '/jci/' + hash + '/' + runId);
+
+ // Load default file
+ const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
+ if (defaultFile) loadFile(defaultFile, true);
+ }
+
function escapeHtml(t) {
if (!t) return '';
return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
@@ -743,36 +915,21 @@ func showMainPage(w http.ResponseWriter, r *http.Request) {
`)
}
-func serveFromRef(w http.ResponseWriter, commit string, filePath string) {
+func serveFromRef(w http.ResponseWriter, commit string, runID string, filePath string) {
var ref string
- // Check if this is a run-specific request: <commit>/<runid>/<file>
- // The commit param might be "abc123/1234567890-abcd"
- if strings.Count(commit, "/") >= 1 {
- // Could be <commit>/<runid> or just <commit>
- parts := strings.SplitN(commit, "/", 2)
- if len(parts) == 2 && len(parts[1]) > 0 {
- // Check if parts[1] looks like a runID (timestamp-random)
- if strings.Contains(parts[1], "-") && len(parts[1]) >= 10 {
- ref = "refs/jci-runs/" + parts[0] + "/" + parts[1]
- } else {
- // It's probably <commit>/<file>, treat commit as just the hash
- ref = "refs/jci/" + parts[0]
- filePath = parts[1] + "/" + filePath
- }
- }
- }
-
- if ref == "" {
+ // Build ref based on whether we have a runID
+ if runID != "" {
+ ref = "refs/jci-runs/" + commit + "/" + runID
+ } else {
ref = "refs/jci/" + commit
- }
-
- if !RefExists(ref) {
- // Try to find a run ref
- runRefs, _ := ListJCIRunRefs()
- for _, r := range runRefs {
- if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
- ref = r
+ // If single-run ref doesn't exist, try to find latest run
+ if !RefExists(ref) {
+ runRefs, _ := ListJCIRunRefs()
+ for _, r := range runRefs {
+ if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") {
+ ref = r // Use last matching (sorted by timestamp)
+ }
}
}
}