Jaypore CI

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

web.go (19882B)


      1 package jci
      2 
      3 import (
      4 	"encoding/json"
      5 	"fmt"
      6 	"net/http"
      7 	"os"
      8 	"os/exec"
      9 	"path/filepath"
     10 	"strings"
     11 )
     12 
     13 // BranchInfo holds branch data for the UI
     14 type BranchInfo struct {
     15 	Name     string       `json:"name"`
     16 	IsRemote bool         `json:"isRemote"`
     17 	Commits  []CommitInfo `json:"commits"`
     18 }
     19 
     20 // CommitInfo holds commit data for the UI
     21 type CommitInfo struct {
     22 	Hash      string `json:"hash"`
     23 	ShortHash string `json:"shortHash"`
     24 	Message   string `json:"message"`
     25 	HasCI     bool   `json:"hasCI"`
     26 	CIStatus  string `json:"ciStatus"` // "success", "failed", or ""
     27 	CIPushed  bool   `json:"ciPushed"` // whether CI ref is pushed to remote
     28 }
     29 
     30 // Web starts a web server to view CI results
     31 func Web(args []string) error {
     32 	port := "8000"
     33 	if len(args) > 0 {
     34 		port = args[0]
     35 	}
     36 
     37 	repoRoot, err := GetRepoRoot()
     38 	if err != nil {
     39 		return err
     40 	}
     41 
     42 	fmt.Printf("Starting JCI web server on http://localhost:%s\n", port)
     43 	fmt.Println("Press Ctrl+C to stop")
     44 
     45 	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
     46 		handleRequest(w, r, repoRoot)
     47 	})
     48 
     49 	return http.ListenAndServe(":"+port, nil)
     50 }
     51 
     52 func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) {
     53 	path := r.URL.Path
     54 
     55 	// Root or /jci/... without file: show main SPA
     56 	if path == "/" || (strings.HasPrefix(path, "/jci/") && !strings.Contains(strings.TrimPrefix(path, "/jci/"), ".")) {
     57 		showMainPage(w, r)
     58 		return
     59 	}
     60 
     61 	// API endpoint for branch data
     62 	if path == "/api/branches" {
     63 		serveBranchesAPI(w)
     64 		return
     65 	}
     66 
     67 	// API endpoint for commit info
     68 	if strings.HasPrefix(path, "/api/commit/") {
     69 		commit := strings.TrimPrefix(path, "/api/commit/")
     70 		serveCommitAPI(w, commit)
     71 		return
     72 	}
     73 
     74 	// /jci/<commit>/<file> - serve files from that commit's CI results
     75 	if strings.HasPrefix(path, "/jci/") {
     76 		parts := strings.SplitN(strings.TrimPrefix(path, "/jci/"), "/", 2)
     77 		commit := parts[0]
     78 		filePath := ""
     79 		if len(parts) > 1 {
     80 			filePath = parts[1]
     81 		}
     82 		if filePath == "" {
     83 			showMainPage(w, r)
     84 			return
     85 		}
     86 		serveFromRef(w, commit, filePath)
     87 		return
     88 	}
     89 
     90 	http.NotFound(w, r)
     91 }
     92 
     93 // getLocalBranches returns local branch names
     94 func getLocalBranches() ([]string, error) {
     95 	out, err := git("branch", "--format=%(refname:short)")
     96 	if err != nil {
     97 		return nil, err
     98 	}
     99 	if out == "" {
    100 		return nil, nil
    101 	}
    102 	return strings.Split(out, "\n"), nil
    103 }
    104 
    105 // getRemoteJCIRefs returns a set of commits that have CI refs pushed to remote
    106 func getRemoteJCIRefs(remote string) map[string]bool {
    107 	remoteCI := make(map[string]bool)
    108 
    109 	// Get remote JCI refs
    110 	out, err := git("ls-remote", "--refs", remote, "refs/jci/*")
    111 	if err != nil {
    112 		return remoteCI
    113 	}
    114 	if out == "" {
    115 		return remoteCI
    116 	}
    117 
    118 	for _, line := range strings.Split(out, "\n") {
    119 		parts := strings.Fields(line)
    120 		if len(parts) >= 2 {
    121 			// refs/jci/<commit> -> <commit>
    122 			ref := parts[1]
    123 			commit := strings.TrimPrefix(ref, "refs/jci/")
    124 			remoteCI[commit] = true
    125 		}
    126 	}
    127 	return remoteCI
    128 }
    129 
    130 // getBranchCommits returns recent commits for a branch
    131 func getBranchCommits(branch string, limit int) ([]CommitInfo, error) {
    132 	// Get commit hash and message
    133 	out, err := git("log", branch, fmt.Sprintf("--max-count=%d", limit), "--format=%H|%s")
    134 	if err != nil {
    135 		return nil, err
    136 	}
    137 	if out == "" {
    138 		return nil, nil
    139 	}
    140 
    141 	// Get local JCI refs
    142 	jciRefs, _ := ListJCIRefs()
    143 	jciSet := make(map[string]bool)
    144 	for _, ref := range jciRefs {
    145 		commit := strings.TrimPrefix(ref, "jci/")
    146 		jciSet[commit] = true
    147 	}
    148 
    149 	// Get remote JCI refs for CI push status
    150 	remoteCI := getRemoteJCIRefs("origin")
    151 
    152 	var commits []CommitInfo
    153 	for _, line := range strings.Split(out, "\n") {
    154 		parts := strings.SplitN(line, "|", 2)
    155 		if len(parts) != 2 {
    156 			continue
    157 		}
    158 		hash := parts[0]
    159 		msg := parts[1]
    160 
    161 		commit := CommitInfo{
    162 			Hash:      hash,
    163 			ShortHash: hash[:7],
    164 			Message:   msg,
    165 			HasCI:     jciSet[hash],
    166 			CIPushed:  remoteCI[hash],
    167 		}
    168 
    169 		if commit.HasCI {
    170 			commit.CIStatus = getCIStatus(hash)
    171 		}
    172 
    173 		commits = append(commits, commit)
    174 	}
    175 
    176 	return commits, nil
    177 }
    178 
    179 // getCIStatus returns "success" or "failed" based on CI results
    180 func getCIStatus(commit string) string {
    181 	// Try to read the index.html and look for status
    182 	ref := "refs/jci/" + commit
    183 	cmd := exec.Command("git", "show", ref+":index.html")
    184 	out, err := cmd.Output()
    185 	if err != nil {
    186 		return ""
    187 	}
    188 
    189 	content := string(out)
    190 	if strings.Contains(content, "class=\"status success\"") {
    191 		return "success"
    192 	}
    193 	if strings.Contains(content, "class=\"status failed\"") {
    194 		return "failed"
    195 	}
    196 	return ""
    197 }
    198 
    199 // CommitDetail holds detailed commit info for the API
    200 type CommitDetail struct {
    201 	Hash   string   `json:"hash"`
    202 	Author string   `json:"author"`
    203 	Date   string   `json:"date"`
    204 	Status string   `json:"status"`
    205 	Files  []string `json:"files"`
    206 }
    207 
    208 // serveCommitAPI returns commit details and file list
    209 func serveCommitAPI(w http.ResponseWriter, commit string) {
    210 	ref := "refs/jci/" + commit
    211 	if !RefExists(ref) {
    212 		http.Error(w, "not found", 404)
    213 		return
    214 	}
    215 
    216 	// Get commit info
    217 	author, _ := git("log", "-1", "--format=%an", commit)
    218 	date, _ := git("log", "-1", "--format=%cr", commit)
    219 	status := getCIStatus(commit)
    220 
    221 	// List files in the CI ref
    222 	filesOut, err := git("ls-tree", "--name-only", ref)
    223 	var files []string
    224 	if err == nil && filesOut != "" {
    225 		for _, f := range strings.Split(filesOut, "\n") {
    226 			if f != "" && f != "index.html" {
    227 				files = append(files, f)
    228 			}
    229 		}
    230 	}
    231 
    232 	detail := CommitDetail{
    233 		Hash:   commit,
    234 		Author: author,
    235 		Date:   date,
    236 		Status: status,
    237 		Files:  files,
    238 	}
    239 
    240 	w.Header().Set("Content-Type", "application/json")
    241 	json.NewEncoder(w).Encode(detail)
    242 }
    243 
    244 // serveBranchesAPI returns branch/commit data as JSON
    245 func serveBranchesAPI(w http.ResponseWriter) {
    246 	branches, err := getLocalBranches()
    247 	if err != nil {
    248 		http.Error(w, err.Error(), 500)
    249 		return
    250 	}
    251 
    252 	var branchInfos []BranchInfo
    253 	for _, branch := range branches {
    254 		commits, err := getBranchCommits(branch, 20)
    255 		if err != nil {
    256 			continue
    257 		}
    258 		branchInfos = append(branchInfos, BranchInfo{
    259 			Name:     branch,
    260 			IsRemote: false,
    261 			Commits:  commits,
    262 		})
    263 	}
    264 
    265 	w.Header().Set("Content-Type", "application/json")
    266 	json.NewEncoder(w).Encode(branchInfos)
    267 }
    268 
    269 func showMainPage(w http.ResponseWriter, r *http.Request) {
    270 	w.Header().Set("Content-Type", "text/html")
    271 	fmt.Fprint(w, `<!DOCTYPE html>
    272 <html>
    273 <head>
    274     <meta charset="utf-8">
    275     <meta name="viewport" content="width=device-width, initial-scale=1">
    276     <title>JCI</title>
    277     <style>
    278         * { box-sizing: border-box; margin: 0; padding: 0; }
    279         body {
    280             font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace;
    281             font-size: 12px;
    282             background: #1a1a1a;
    283             color: #e0e0e0;
    284             display: flex;
    285             height: 100vh;
    286             overflow: hidden;
    287         }
    288         a { color: #58a6ff; text-decoration: none; }
    289         a:hover { text-decoration: underline; }
    290         
    291         /* Left panel - commits */
    292         .commits-panel {
    293             width: 240px;
    294             background: #1e1e1e;
    295             border-right: 1px solid #333;
    296             display: flex;
    297             flex-direction: column;
    298             flex-shrink: 0;
    299         }
    300         .panel-header {
    301             padding: 6px 8px;
    302             background: #252525;
    303             border-bottom: 1px solid #333;
    304             display: flex;
    305             align-items: center;
    306             gap: 6px;
    307         }
    308         .panel-header h1 { font-size: 12px; font-weight: 600; color: #888; }
    309         .branch-selector {
    310             flex: 1;
    311             padding: 2px 4px;
    312             font-size: 11px;
    313             border: 1px solid #444;
    314             border-radius: 3px;
    315             background: #2a2a2a;
    316             color: #fff;
    317         }
    318         .commit-list {
    319             list-style: none;
    320             overflow-y: auto;
    321             flex: 1;
    322         }
    323         .commit-item {
    324             padding: 3px 6px;
    325             cursor: pointer;
    326             display: flex;
    327             align-items: center;
    328             gap: 4px;
    329             border-bottom: 1px solid #252525;
    330         }
    331         .commit-item:hover { background: #2a2a2a; }
    332         .commit-item.selected { background: #2d4a3e; }
    333         .commit-item.no-ci { opacity: 0.5; }
    334         .ci-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
    335         .ci-dot.success { background: #3fb950; }
    336         .ci-dot.failed { background: #f85149; }
    337         .ci-dot.none { background: #484f58; }
    338         .commit-hash { font-size: 10px; color: #58a6ff; flex-shrink: 0; }
    339         .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #888; font-size: 11px; }
    340         .ci-push-badge { font-size: 8px; color: #666; }
    341         .ci-push-badge.pushed { color: #3fb950; }
    342         
    343         /* Middle panel - files */
    344         .files-panel {
    345             width: 180px;
    346             background: #1e1e1e;
    347             border-right: 1px solid #333;
    348             display: flex;
    349             flex-direction: column;
    350             flex-shrink: 0;
    351         }
    352         .files-panel.hidden { display: none; }
    353         .commit-info {
    354             padding: 6px 8px;
    355             background: #252525;
    356             border-bottom: 1px solid #333;
    357             font-size: 11px;
    358         }
    359         .commit-info .status { font-weight: 600; }
    360         .commit-info .status.success { color: #3fb950; }
    361         .commit-info .status.failed { color: #f85149; }
    362         .commit-info .hash { color: #58a6ff; }
    363         .commit-info .meta { color: #666; margin-top: 2px; }
    364         .file-list {
    365             list-style: none;
    366             overflow-y: auto;
    367             flex: 1;
    368         }
    369         .file-item {
    370             padding: 3px 8px;
    371             cursor: pointer;
    372             border-bottom: 1px solid #252525;
    373             white-space: nowrap;
    374             overflow: hidden;
    375             text-overflow: ellipsis;
    376             font-size: 11px;
    377         }
    378         .file-item:hover { background: #2a2a2a; }
    379         .file-item.selected { background: #2d4a3e; }
    380         
    381         /* Right panel - content */
    382         .content-panel {
    383             flex: 1;
    384             display: flex;
    385             flex-direction: column;
    386             min-width: 0;
    387             background: #1a1a1a;
    388         }
    389         .content-header {
    390             padding: 4px 8px;
    391             background: #252525;
    392             border-bottom: 1px solid #333;
    393             font-size: 11px;
    394             color: #888;
    395         }
    396         .content-body {
    397             flex: 1;
    398             overflow: auto;
    399         }
    400         .content-body pre {
    401             padding: 8px;
    402             font-family: "Monaco", "Menlo", monospace;
    403             font-size: 11px;
    404             line-height: 1.4;
    405             white-space: pre-wrap;
    406             word-wrap: break-word;
    407         }
    408         .content-body iframe {
    409             width: 100%;
    410             height: 100%;
    411             border: none;
    412             background: #fff;
    413         }
    414         .empty-state {
    415             display: flex;
    416             align-items: center;
    417             justify-content: center;
    418             height: 100%;
    419             color: #666;
    420         }
    421         
    422         @media (max-width: 700px) {
    423             .commits-panel { width: 180px; }
    424             .files-panel { width: 140px; }
    425         }
    426     </style>
    427 </head>
    428 <body>
    429     <div class="commits-panel">
    430         <div class="panel-header">
    431             <h1>JCI</h1>
    432             <select class="branch-selector" id="branchSelect"></select>
    433         </div>
    434         <ul class="commit-list" id="commitList"></ul>
    435     </div>
    436     <div class="files-panel hidden" id="filesPanel">
    437         <div class="commit-info" id="commitInfo"></div>
    438         <ul class="file-list" id="fileList"></ul>
    439     </div>
    440     <div class="content-panel">
    441         <div class="content-header" id="contentHeader"></div>
    442         <div class="content-body" id="contentBody">
    443             <div class="empty-state">Select a commit</div>
    444         </div>
    445     </div>
    446 
    447     <script>
    448         let branches = [], currentCommit = null, currentFiles = [], currentFile = null;
    449 
    450         async function loadBranches() {
    451             const res = await fetch('/api/branches');
    452             branches = await res.json() || [];
    453             const select = document.getElementById('branchSelect');
    454             select.innerHTML = branches.map(b => '<option value="' + b.name + '">' + b.name + '</option>').join('');
    455             const def = branches.find(b => b.name === 'main') || branches[0];
    456             if (def) { select.value = def.name; showBranch(def.name); }
    457             
    458             // Check URL for initial commit
    459             const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/);
    460             if (m) selectCommitByHash(m[1]);
    461         }
    462 
    463         function showBranch(name) {
    464             const branch = branches.find(b => b.name === name);
    465             if (!branch) return;
    466             const list = document.getElementById('commitList');
    467             list.innerHTML = (branch.commits || []).map(c => {
    468                 const status = c.hasCI ? c.ciStatus : 'none';
    469                 const pushIcon = c.hasCI ? (c.ciPushed ? '↑' : '○') : '';
    470                 const pushClass = c.ciPushed ? 'pushed' : '';
    471                 const noCiClass = c.hasCI ? '' : 'no-ci';
    472                 return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' +
    473                     '<span class="ci-dot ' + status + '"></span>' +
    474                     '<span class="commit-hash">' + c.shortHash + '</span>' +
    475                     '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' +
    476                     '<span class="ci-push-badge ' + pushClass + '">' + pushIcon + '</span></li>';
    477             }).join('');
    478             list.querySelectorAll('.commit-item').forEach(el => {
    479                 el.onclick = () => selectCommit(el.dataset.hash, el.dataset.hasci === 'true');
    480             });
    481         }
    482 
    483         function selectCommitByHash(hash) {
    484             // Find full hash from branches
    485             for (const b of branches) {
    486                 const c = (b.commits || []).find(c => c.hash.startsWith(hash));
    487                 if (c) { selectCommit(c.hash, c.hasCI); return; }
    488             }
    489         }
    490 
    491         async function selectCommit(hash, hasCI) {
    492             currentCommit = hash;
    493             document.querySelectorAll('.commit-item').forEach(el => 
    494                 el.classList.toggle('selected', el.dataset.hash === hash)
    495             );
    496             
    497             const filesPanel = document.getElementById('filesPanel');
    498             const contentBody = document.getElementById('contentBody');
    499             const contentHeader = document.getElementById('contentHeader');
    500             
    501             if (!hasCI) {
    502                 filesPanel.classList.add('hidden');
    503                 contentHeader.textContent = '';
    504                 contentBody.innerHTML = '<div class="empty-state">No CI results. Run: git jci run</div>';
    505                 history.pushState(null, '', '/');
    506                 return;
    507             }
    508             
    509             filesPanel.classList.remove('hidden');
    510             history.pushState(null, '', '/jci/' + hash + '/');
    511             
    512             // Load commit info and files
    513             try {
    514                 const infoRes = await fetch('/api/commit/' + hash);
    515                 const info = await infoRes.json();
    516                 
    517                 document.getElementById('commitInfo').innerHTML = 
    518                     '<div><span class="status ' + info.status + '">' + (info.status === 'success' ? '✓' : '✗') + '</span> ' +
    519                     '<span class="hash">' + hash.slice(0,7) + '</span></div>' +
    520                     '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>';
    521                 
    522                 currentFiles = info.files || [];
    523                 const fileList = document.getElementById('fileList');
    524                 fileList.innerHTML = currentFiles.map(f => 
    525                     '<li class="file-item" data-file="' + f + '">' + f + '</li>'
    526                 ).join('');
    527                 fileList.querySelectorAll('.file-item').forEach(el => {
    528                     el.onclick = () => loadFile(el.dataset.file);
    529                 });
    530                 
    531                 // Load default file
    532                 const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0];
    533                 if (defaultFile) loadFile(defaultFile);
    534             } catch (e) {
    535                 contentBody.innerHTML = '<div class="empty-state">Failed to load</div>';
    536             }
    537         }
    538 
    539         function loadFile(name) {
    540             currentFile = name;
    541             document.querySelectorAll('.file-item').forEach(el => 
    542                 el.classList.toggle('selected', el.dataset.file === name)
    543             );
    544             
    545             const contentHeader = document.getElementById('contentHeader');
    546             const contentBody = document.getElementById('contentBody');
    547             contentHeader.textContent = name;
    548             
    549             history.pushState(null, '', '/jci/' + currentCommit + '/' + name);
    550             
    551             const ext = name.split('.').pop().toLowerCase();
    552             const textExts = ['txt', 'log', 'sh', 'go', 'py', 'js', 'json', 'yaml', 'yml', 'md', 'css', 'xml', 'toml', 'ini', 'conf'];
    553             const url = '/jci/' + currentCommit + '/' + name;
    554             
    555             if (ext === 'html' || ext === 'htm') {
    556                 contentBody.innerHTML = '<iframe src="' + url + '"></iframe>';
    557             } else if (textExts.includes(ext) || !name.includes('.')) {
    558                 fetch(url).then(r => r.text()).then(text => {
    559                     contentBody.innerHTML = '<pre>' + escapeHtml(text) + '</pre>';
    560                 });
    561             } else {
    562                 contentBody.innerHTML = '<div class="empty-state"><a href="' + url + '" download>Download ' + name + '</a></div>';
    563             }
    564         }
    565 
    566         function escapeHtml(t) {
    567             if (!t) return '';
    568             return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
    569         }
    570 
    571         window.onpopstate = () => {
    572             const m = location.pathname.match(/^\/jci\/([a-f0-9]+)(?:\/(.+))?/);
    573             if (m) {
    574                 if (m[1] !== currentCommit) selectCommitByHash(m[1]);
    575                 else if (m[2] && m[2] !== currentFile) loadFile(m[2]);
    576             }
    577         };
    578 
    579         document.getElementById('branchSelect').onchange = e => showBranch(e.target.value);
    580         loadBranches();
    581     </script>
    582 </body>
    583 </html>
    584 `)
    585 }
    586 
    587 func serveFromRef(w http.ResponseWriter, commit string, filePath string) {
    588 	ref := "refs/jci/" + commit
    589 	if !RefExists(ref) {
    590 		http.Error(w, "CI results not found for commit: "+commit, 404)
    591 		return
    592 	}
    593 
    594 	// Use git show to get file content from the ref
    595 	cmd := exec.Command("git", "show", ref+":"+filePath)
    596 	out, err := cmd.Output()
    597 	if err != nil {
    598 		http.Error(w, "File not found: "+filePath, 404)
    599 		return
    600 	}
    601 
    602 	// Set content type based on extension
    603 	ext := filepath.Ext(filePath)
    604 	switch ext {
    605 	case ".html":
    606 		w.Header().Set("Content-Type", "text/html")
    607 	case ".css":
    608 		w.Header().Set("Content-Type", "text/css")
    609 	case ".js":
    610 		w.Header().Set("Content-Type", "application/javascript")
    611 	case ".json":
    612 		w.Header().Set("Content-Type", "application/json")
    613 	case ".txt":
    614 		w.Header().Set("Content-Type", "text/plain")
    615 	default:
    616 		// Binary files (executables, etc.)
    617 		w.Header().Set("Content-Type", "application/octet-stream")
    618 		w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(filePath)))
    619 	}
    620 
    621 	w.Write(out)
    622 }
    623 
    624 // extractRef extracts files from a ref to a temp directory (not used currently but useful)
    625 func extractRef(ref string) (string, error) {
    626 	tmpDir, err := os.MkdirTemp("", "jci-view-*")
    627 	if err != nil {
    628 		return "", err
    629 	}
    630 
    631 	cmd := exec.Command("git", "archive", ref)
    632 	tar := exec.Command("tar", "-xf", "-", "-C", tmpDir)
    633 
    634 	tar.Stdin, _ = cmd.StdoutPipe()
    635 	tar.Start()
    636 	cmd.Run()
    637 	tar.Wait()
    638 
    639 	return tmpDir, nil
    640 }