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,'&').replace(/</g,'<').replace(/>/g,'>'); 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 }