Jaypore CI

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

commit c4d31fa72e0aeff6b6d6b32abe521d287451769a
parent 55d320c56b8ddf062df356d2907b45e31cf70af1
Author: Arjoonn@exe.dev <arjoonn+exe.dev@midpathsoftware.com>
Date:   Wed, 25 Feb 2026 04:25:24 +0000

Enhanced web UI with branch sidebar, push status, and CI indicators

- Added branch dropdown selector showing all local branches
- Show commits per branch with push status (local/pushed badges)
- Display CI status indicator for each commit (green/red/grey dots)
- Show branch-level CI status from latest commit with CI
- Added /api/branches JSON endpoint for frontend data
- Commits with CI results show embedded iframe with full CI details
- Commits without CI show helpful message to run git jci run

Co-authored-by: Shelley <shelley@exe.dev>

Diffstat:
Minternal/jci/web.go | 469+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
1 file changed, 425 insertions(+), 44 deletions(-)

diff --git a/internal/jci/web.go b/internal/jci/web.go @@ -1,6 +1,7 @@ package jci import ( + "encoding/json" "fmt" "net/http" "os" @@ -9,6 +10,23 @@ import ( "strings" ) +// BranchInfo holds branch data for the UI +type BranchInfo struct { + Name string `json:"name"` + IsRemote bool `json:"isRemote"` + Commits []CommitInfo `json:"commits"` +} + +// CommitInfo holds commit data for the UI +type CommitInfo struct { + Hash string `json:"hash"` + ShortHash string `json:"shortHash"` + Message string `json:"message"` + IsPushed bool `json:"isPushed"` + HasCI bool `json:"hasCI"` + CIStatus string `json:"ciStatus"` // "success", "failed", or "" +} + // Web starts a web server to view CI results func Web(args []string) error { port := "8000" @@ -34,9 +52,15 @@ func Web(args []string) error { func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { path := r.URL.Path - // Root: list all CI runs + // Root: show branch/commit view if path == "/" { - listRuns(w) + showMainPage(w, r) + return + } + + // API endpoint for branch data + if path == "/api/branches" { + serveBranchesAPI(w) return } @@ -55,69 +79,426 @@ func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { http.NotFound(w, r) } -func listRuns(w http.ResponseWriter) { - refs, err := ListJCIRefs() +// getLocalBranches returns local branch names +func getLocalBranches() ([]string, error) { + out, err := git("branch", "--format=%(refname:short)") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + return strings.Split(out, "\n"), nil +} + +// getRemoteCommits returns a set of commits that exist on remote +func getRemoteCommits(remote string) (map[string]bool, error) { + remoteCommits := make(map[string]bool) + + // Get all remote branch tips + out, err := git("branch", "-r", "--format=%(objectname)") + if err != nil { + return remoteCommits, nil // Not an error if no remotes + } + if out == "" { + return remoteCommits, nil + } + + for _, hash := range strings.Split(out, "\n") { + if hash != "" { + remoteCommits[hash] = true + } + } + return remoteCommits, nil +} + +// getBranchCommits returns recent commits for a branch +func getBranchCommits(branch string, limit int) ([]CommitInfo, error) { + // Get commit hash and message + out, err := git("log", branch, fmt.Sprintf("--max-count=%d", limit), "--format=%H|%s") + if err != nil { + return nil, err + } + if out == "" { + return nil, nil + } + + // Get remote commits for push status + remoteCommits, _ := getRemoteCommits("origin") + + // Get JCI refs for CI status + jciRefs, _ := ListJCIRefs() + jciSet := make(map[string]bool) + for _, ref := range jciRefs { + commit := strings.TrimPrefix(ref, "jci/") + jciSet[commit] = true + } + + var commits []CommitInfo + for _, line := range strings.Split(out, "\n") { + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + continue + } + hash := parts[0] + msg := parts[1] + + commit := CommitInfo{ + Hash: hash, + ShortHash: hash[:12], + Message: msg, + IsPushed: isCommitPushed(hash, remoteCommits), + HasCI: jciSet[hash], + } + + if commit.HasCI { + commit.CIStatus = getCIStatus(hash) + } + + commits = append(commits, commit) + } + + return commits, nil +} + +// isCommitPushed checks if a commit is reachable from any remote branch +func isCommitPushed(hash string, remoteCommits map[string]bool) bool { + // Quick check: is this exact commit a remote branch tip? + if remoteCommits[hash] { + return true + } + + // Check if commit is ancestor of any remote branch + // Use merge-base to check reachability + out, err := git("branch", "-r", "--contains", hash) + if err != nil { + return false + } + return strings.TrimSpace(out) != "" +} + +// getCIStatus returns "success" or "failed" based on CI results +func getCIStatus(commit string) string { + // Try to read the index.html and look for status + ref := "refs/jci/" + commit + cmd := exec.Command("git", "show", ref+":index.html") + out, err := cmd.Output() + if err != nil { + return "" + } + + content := string(out) + if strings.Contains(content, "class=\"status success\"") { + return "success" + } + if strings.Contains(content, "class=\"status failed\"") { + return "failed" + } + return "" +} + +// serveBranchesAPI returns branch/commit data as JSON +func serveBranchesAPI(w http.ResponseWriter) { + branches, err := getLocalBranches() if err != nil { http.Error(w, err.Error(), 500) return } + var branchInfos []BranchInfo + for _, branch := range branches { + commits, err := getBranchCommits(branch, 20) + if err != nil { + continue + } + branchInfos = append(branchInfos, BranchInfo{ + Name: branch, + IsRemote: false, + Commits: commits, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(branchInfos) +} + +func showMainPage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") fmt.Fprint(w, `<!DOCTYPE html> <html> <head> <meta charset="utf-8"> - <title>JCI - CI Runs</title> + <title>JCI - CI Dashboard</title> <style> + * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - max-width: 800px; - margin: 40px auto; - padding: 0 20px; + margin: 0; + padding: 0; background: #f5f5f5; + display: flex; + min-height: 100vh; + } + .sidebar { + width: 300px; + background: #24292e; + color: #fff; + padding: 20px; + overflow-y: auto; + } + .sidebar h1 { + font-size: 20px; + margin: 0 0 20px 0; + padding-bottom: 10px; + border-bottom: 1px solid #444; + } + .branch-selector { + width: 100%; + padding: 8px 12px; + font-size: 14px; + border: 1px solid #444; + border-radius: 6px; + background: #2d333b; + color: #fff; + margin-bottom: 16px; + cursor: pointer; + } + .branch-selector:focus { + outline: none; + border-color: #58a6ff; + } + .commit-list { + list-style: none; + padding: 0; + margin: 0; + } + .commit-item { + padding: 10px; + border-radius: 6px; + margin-bottom: 4px; + cursor: pointer; + transition: background 0.2s; + display: flex; + align-items: flex-start; + gap: 10px; + } + .commit-item:hover { + background: #2d333b; + } + .commit-status { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 4px; + } + .commit-status.success { background: #3fb950; } + .commit-status.failed { background: #f85149; } + .commit-status.none { background: #484f58; } + .commit-info { + flex: 1; + min-width: 0; + } + .commit-hash { + font-family: monospace; + font-size: 12px; + color: #58a6ff; + } + .commit-msg { + font-size: 13px; + color: #c9d1d9; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .commit-badges { + display: flex; + gap: 6px; + margin-top: 4px; + } + .badge { + font-size: 10px; + padding: 2px 6px; + border-radius: 10px; + font-weight: 500; + } + .badge.local { + background: #f0883e; + color: #000; + } + .badge.pushed { + background: #238636; + color: #fff; + } + .badge.ci { + background: #388bfd; + color: #fff; + } + .main-content { + flex: 1; + padding: 20px; + } + .main-content iframe { + width: 100%; + height: calc(100vh - 40px); + border: none; + border-radius: 8px; + background: #fff; } - .container { - background: white; + .no-selection { + display: flex; + align-items: center; + justify-content: center; + height: calc(100vh - 40px); + background: #fff; border-radius: 8px; - padding: 24px; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - } - h1 { color: #333; margin-top: 0; } - ul { list-style: none; padding: 0; } - li { - padding: 12px; - border-bottom: 1px solid #eee; - } - li:last-child { border-bottom: none; } - a { color: #0366d6; text-decoration: none; font-family: monospace; } - a:hover { text-decoration: underline; } - .empty { color: #666; font-style: italic; } + color: #666; + font-size: 16px; + } + .branch-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: #2d333b; + border-radius: 6px; + margin-bottom: 12px; + font-size: 13px; + } + .branch-ci-status { + width: 10px; + height: 10px; + border-radius: 50%; + } + .loading { + color: #8b949e; + font-style: italic; + padding: 20px; + } </style> </head> <body> - <div class="container"> - <h1>JCI - CI Runs</h1> - <ul> -`) - - if len(refs) == 0 { - fmt.Fprint(w, `<li class="empty">No CI runs yet. Run 'git jci run' to create one.</li>`) - } else { - for _, ref := range refs { - commit := strings.TrimPrefix(ref, "jci/") - shortCommit := commit - if len(commit) > 12 { - shortCommit = commit[:12] - } - // Get commit message - msg, _ := git("log", "-1", "--format=%s", commit) - fmt.Fprintf(w, `<li><a href="/jci/%s/">%s</a> - %s</li>`, commit, shortCommit, msg) - } - } - - fmt.Fprint(w, ` + <div class="sidebar"> + <h1>🔧 JCI Dashboard</h1> + <select class="branch-selector" id="branchSelect"> + <option value="">Loading branches...</option> + </select> + <div class="branch-status" id="branchStatus" style="display: none;"> + <div class="branch-ci-status" id="branchCIStatus"></div> + <span id="branchStatusText">Branch CI Status</span> + </div> + <ul class="commit-list" id="commitList"> + <li class="loading">Loading...</li> </ul> </div> + <div class="main-content"> + <div class="no-selection" id="noSelection"> + Select a commit to view CI results + </div> + <iframe id="ciFrame" style="display: none;"></iframe> + </div> + + <script> + let branches = []; + let currentBranch = ''; + + async function loadBranches() { + try { + const res = await fetch('/api/branches'); + branches = await res.json(); + + const select = document.getElementById('branchSelect'); + select.innerHTML = branches.map(b => + '"<option value="' + b.name + '">' + b.name + '</option>' + ).join(''); + + // Select main or first branch + const defaultBranch = branches.find(b => b.name === 'main') || branches[0]; + if (defaultBranch) { + select.value = defaultBranch.name; + showBranch(defaultBranch.name); + } + } catch (err) { + console.error('Failed to load branches:', err); + } + } + + function showBranch(branchName) { + currentBranch = branchName; + const branch = branches.find(b => b.name === branchName); + if (!branch) return; + + const list = document.getElementById('commitList'); + const branchStatus = document.getElementById('branchStatus'); + + // Determine branch CI status from first commit with CI + const firstCICommit = branch.commits.find(c => c.hasCI); + if (firstCICommit) { + branchStatus.style.display = 'flex'; + const statusEl = document.getElementById('branchCIStatus'); + const textEl = document.getElementById('branchStatusText'); + if (firstCICommit.ciStatus === 'success') { + statusEl.style.background = '#3fb950'; + textEl.textContent = 'Latest CI: Passing'; + } else if (firstCICommit.ciStatus === 'failed') { + statusEl.style.background = '#f85149'; + textEl.textContent = 'Latest CI: Failed'; + } else { + statusEl.style.background = '#484f58'; + textEl.textContent = 'Latest CI: Unknown'; + } + } else { + branchStatus.style.display = 'none'; + } + + list.innerHTML = branch.commits.map(c => { + let badges = ''; + if (c.isPushed) { + badges += '<span class="badge pushed">pushed</span>'; + } else { + badges += '<span class="badge local">local</span>'; + } + if (c.hasCI) { + badges += '<span class="badge ci">CI</span>'; + } + const statusClass = c.hasCI ? c.ciStatus : 'none'; + return '<li class="commit-item" onclick="showCommit(\'' + c.hash + '\', ' + c.hasCI + ')">' + + '<div class="commit-status ' + statusClass + '"></div>' + + '<div class="commit-info">' + + '<div class="commit-hash">' + c.shortHash + '</div>' + + '<div class="commit-msg">' + escapeHtml(c.message) + '</div>' + + '<div class="commit-badges">' + badges + '</div>' + + '</div></li>'; + }).join(''); + } + + function showCommit(hash, hasCI) { + const frame = document.getElementById('ciFrame'); + const noSel = document.getElementById('noSelection'); + + if (hasCI) { + frame.src = '/jci/' + hash + '/'; + frame.style.display = 'block'; + noSel.style.display = 'none'; + } else { + frame.style.display = 'none'; + noSel.style.display = 'flex'; + noSel.textContent = 'No CI results for this commit. Run: git jci run'; + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + document.getElementById('branchSelect').addEventListener('change', (e) => { + showBranch(e.target.value); + }); + + loadBranches(); + </script> </body> </html> `)