Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README | LICENSE

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:
Minternal/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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); @@ -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) + } } } }