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:
| M | internal/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>
`)