web.go (43339B)
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 } 18 19 // CommitInfo holds commit data for the UI 20 type CommitInfo struct { 21 Hash string `json:"hash"` 22 ShortHash string `json:"shortHash"` 23 Message string `json:"message"` 24 HasCI bool `json:"hasCI"` 25 CIStatus string `json:"ciStatus"` // "success", "failed", or "" 26 CIPushed bool `json:"ciPushed"` // whether CI ref is pushed to remote 27 Runs []RunInfo `json:"runs"` // multiple runs (for cron) 28 } 29 30 // RunInfo holds info about a single CI run 31 type RunInfo struct { 32 RunID string `json:"runId"` 33 Status string `json:"status"` 34 Ref string `json:"ref"` 35 } 36 37 // Web starts a web server to view CI results 38 func Web(args []string) error { 39 port := "8000" 40 if len(args) > 0 { 41 port = args[0] 42 } 43 44 repoRoot, err := GetRepoRoot() 45 if err != nil { 46 return err 47 } 48 49 fmt.Printf("Starting JCI web server on http://localhost:%s\n", port) 50 fmt.Println("Press Ctrl+C to stop") 51 52 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 53 handleRequest(w, r, repoRoot) 54 }) 55 56 return http.ListenAndServe(":"+port, nil) 57 } 58 59 func handleRequest(w http.ResponseWriter, r *http.Request, repoRoot string) { 60 path := r.URL.Path 61 62 // API endpoint for branch data (names only, no commits) 63 if path == "/api/branches" { 64 serveBranchesAPI(w) 65 return 66 } 67 68 // API endpoint for paginated commits for a branch 69 if path == "/api/commits" { 70 serveCommitsAPI(w, r) 71 return 72 } 73 74 // API endpoint for commit info 75 if strings.HasPrefix(path, "/api/commit/") { 76 commit := strings.TrimPrefix(path, "/api/commit/") 77 serveCommitAPI(w, commit) 78 return 79 } 80 81 // /jci/<commit>/<file>/raw - serve raw file 82 // Also handles /jci/<commit>/<runid>/<file>/raw 83 if strings.HasPrefix(path, "/jci/") && strings.HasSuffix(path, "/raw") { 84 trimmed := strings.TrimPrefix(path, "/jci/") 85 trimmed = strings.TrimSuffix(trimmed, "/raw") 86 // trimmed is now: <commit>/<file> or <commit>/<runid>/<file> 87 parts := strings.SplitN(trimmed, "/", 3) 88 if len(parts) == 2 && parts[1] != "" { 89 // <commit>/<file> 90 serveFromRef(w, parts[0], "", parts[1]) 91 return 92 } else if len(parts) == 3 && parts[2] != "" { 93 // <commit>/<runid>/<file> 94 serveFromRef(w, parts[0], parts[1], parts[2]) 95 return 96 } 97 } 98 99 // Root or /jci/... - show main SPA (UI handles routing) 100 if path == "/" || strings.HasPrefix(path, "/jci/") { 101 showMainPage(w, r) 102 return 103 } 104 105 http.NotFound(w, r) 106 } 107 108 // getLocalBranches returns local branch names 109 func getLocalBranches() ([]string, error) { 110 out, err := git("branch", "--format=%(refname:short)") 111 if err != nil { 112 return nil, err 113 } 114 if out == "" { 115 return nil, nil 116 } 117 return strings.Split(out, "\n"), nil 118 } 119 120 // getRemoteJCIRefs returns a set of commits that have CI refs pushed to remote 121 122 123 124 // getCIStatus returns "success", "failed", or "running" based on status.txt 125 func getCIStatus(commit string) string { 126 return getCIStatusFromRef("refs/jci/" + commit) 127 } 128 129 // getCIStatusFromRef returns status from any ref 130 func getCIStatusFromRef(ref string) string { 131 // Try to read status.txt (new format) 132 cmd := exec.Command("git", "show", ref+":status.txt") 133 out, err := cmd.Output() 134 if err == nil { 135 status := strings.TrimSpace(string(out)) 136 switch status { 137 case "ok": 138 return "success" 139 case "err": 140 return "failed" 141 case "running": 142 return "running" 143 } 144 } 145 146 // Fallback: parse index.html for old results 147 cmd = exec.Command("git", "show", ref+":index.html") 148 out, err = cmd.Output() 149 if err != nil { 150 return "" 151 } 152 content := string(out) 153 if strings.Contains(content, "PASSED") || strings.Contains(content, "SUCCESS") { 154 return "success" 155 } 156 if strings.Contains(content, "FAILED") { 157 return "failed" 158 } 159 return "" 160 } 161 162 // CommitDetail holds detailed commit info for the API 163 type CommitDetail struct { 164 Hash string `json:"hash"` 165 Author string `json:"author"` 166 Date string `json:"date"` 167 Status string `json:"status"` 168 Files []string `json:"files"` 169 Ref string `json:"ref"` // The actual ref used 170 RunID string `json:"runId"` // Current run ID 171 Runs []RunInfo `json:"runs"` // All runs for this commit 172 } 173 174 // serveCommitAPI returns commit details and file list 175 // commit can be just <hash> or <hash>/<runid> 176 func serveCommitAPI(w http.ResponseWriter, commit string) { 177 var ref string 178 var actualCommit string 179 var currentRunID string 180 181 // Check if this is a run-specific request: <commit>/<runid> 182 if strings.Contains(commit, "/") { 183 parts := strings.SplitN(commit, "/", 2) 184 actualCommit = parts[0] 185 currentRunID = parts[1] 186 ref = "refs/jci-runs/" + actualCommit + "/" + currentRunID 187 } else { 188 actualCommit = commit 189 ref = "refs/jci/" + commit 190 // Check if single-run ref exists, otherwise try to find latest run 191 if !RefExists(ref) { 192 // Look for runs - get the latest one 193 runRefs, _ := ListJCIRunRefs() 194 for _, r := range runRefs { 195 if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { 196 ref = r // Use the last one (they're sorted) 197 parts := strings.Split(r, "/") 198 if len(parts) >= 4 { 199 currentRunID = parts[3] 200 } 201 } 202 } 203 } 204 } 205 206 if !RefExists(ref) { 207 http.Error(w, "not found", 404) 208 return 209 } 210 211 // Get all runs for this commit 212 var runs []RunInfo 213 runRefs, _ := ListJCIRunRefs() 214 for _, r := range runRefs { 215 if strings.HasPrefix(r, "refs/jci-runs/"+actualCommit+"/") { 216 parts := strings.Split(r, "/") 217 if len(parts) >= 4 { 218 runID := parts[3] 219 status := getCIStatusFromRef(r) 220 runs = append(runs, RunInfo{ 221 RunID: runID, 222 Status: status, 223 Ref: r, 224 }) 225 } 226 } 227 } 228 229 // Get commit info 230 author, _ := git("log", "-1", "--format=%an", actualCommit) 231 date, _ := git("log", "-1", "--format=%cr", actualCommit) 232 status := getCIStatusFromRef(ref) 233 234 // List files in the CI ref 235 filesOut, err := git("ls-tree", "--name-only", ref) 236 var files []string 237 if err == nil && filesOut != "" { 238 for _, f := range strings.Split(filesOut, "\n") { 239 if f != "" && f != "index.html" { 240 files = append(files, f) 241 } 242 } 243 } 244 245 detail := CommitDetail{ 246 Hash: actualCommit, 247 Author: author, 248 Date: date, 249 Status: status, 250 Files: files, 251 Ref: ref, 252 RunID: currentRunID, 253 Runs: runs, 254 } 255 256 w.Header().Set("Content-Type", "application/json") 257 json.NewEncoder(w).Encode(detail) 258 } 259 260 // serveBranchesAPI returns branch names as JSON (no commits for fast load) 261 func serveBranchesAPI(w http.ResponseWriter) { 262 branches, err := getLocalBranches() 263 if err != nil { 264 http.Error(w, err.Error(), 500) 265 return 266 } 267 268 var branchInfos []BranchInfo 269 for _, branch := range branches { 270 branchInfos = append(branchInfos, BranchInfo{ 271 Name: branch, 272 IsRemote: false, 273 }) 274 } 275 276 w.Header().Set("Content-Type", "application/json") 277 json.NewEncoder(w).Encode(branchInfos) 278 } 279 280 // CommitsPage holds a page of commits for the paginated API 281 type CommitsPage struct { 282 Branch string `json:"branch"` 283 Page int `json:"page"` 284 PageSize int `json:"pageSize"` 285 HasMore bool `json:"hasMore"` 286 Commits []CommitInfo `json:"commits"` 287 } 288 289 const commitsPageSize = 100 290 291 // serveCommitsAPI returns a paginated list of commits for a branch. 292 // Query params: branch (required), page (optional, 0-indexed, default 0) 293 func serveCommitsAPI(w http.ResponseWriter, r *http.Request) { 294 branch := r.URL.Query().Get("branch") 295 if branch == "" { 296 http.Error(w, "branch query parameter is required", 400) 297 return 298 } 299 300 page := 0 301 if p := r.URL.Query().Get("page"); p != "" { 302 fmt.Sscanf(p, "%d", &page) 303 if page < 0 { 304 page = 0 305 } 306 } 307 308 // Fetch one extra commit beyond the page size to detect whether more pages exist 309 offset := page * commitsPageSize 310 limit := commitsPageSize + 1 311 312 commits, err := getBranchCommitsPaginated(branch, offset, limit) 313 if err != nil { 314 http.Error(w, err.Error(), 500) 315 return 316 } 317 318 hasMore := len(commits) > commitsPageSize 319 if hasMore { 320 commits = commits[:commitsPageSize] 321 } 322 323 result := CommitsPage{ 324 Branch: branch, 325 Page: page, 326 PageSize: commitsPageSize, 327 HasMore: hasMore, 328 Commits: commits, 329 } 330 331 w.Header().Set("Content-Type", "application/json") 332 json.NewEncoder(w).Encode(result) 333 } 334 335 // getBranchCommitsPaginated returns commits for a branch starting at the given offset. 336 func getBranchCommitsPaginated(branch string, offset, limit int) ([]CommitInfo, error) { 337 out, err := git("log", branch, 338 fmt.Sprintf("--skip=%d", offset), 339 fmt.Sprintf("--max-count=%d", limit), 340 "--format=%H|%s") 341 if err != nil { 342 return nil, err 343 } 344 if out == "" { 345 return nil, nil 346 } 347 348 // Get local JCI refs (single-run) 349 jciRefs, _ := ListJCIRefs() 350 jciSet := make(map[string]bool) 351 for _, ref := range jciRefs { 352 commit := strings.TrimPrefix(ref, "jci/") 353 jciSet[commit] = true 354 } 355 356 // Get local JCI run refs (multi-run): commit -> list of run refs 357 jciRuns := make(map[string][]string) 358 runRefs, _ := ListJCIRunRefs() 359 for _, ref := range runRefs { 360 parts := strings.Split(strings.TrimPrefix(ref, "refs/jci-runs/"), "/") 361 if len(parts) >= 2 { 362 commit := parts[0] 363 jciRuns[commit] = append(jciRuns[commit], ref) 364 } 365 } 366 367 // Get remote JCI refs for CI push status 368 remoteCI := getRemoteJCIRefs("origin") 369 370 var commits []CommitInfo 371 for _, line := range strings.Split(out, "\n") { 372 parts := strings.SplitN(line, "|", 2) 373 if len(parts) != 2 { 374 continue 375 } 376 hash := parts[0] 377 msg := parts[1] 378 379 hasCI := jciSet[hash] || len(jciRuns[hash]) > 0 380 commit := CommitInfo{ 381 Hash: hash, 382 ShortHash: hash[:7], 383 Message: msg, 384 HasCI: hasCI, 385 CIPushed: remoteCI["refs/jci/"+hash], 386 } 387 388 if jciSet[hash] { 389 commit.CIStatus = getCIStatus(hash) 390 } 391 392 for _, runRef := range jciRuns[hash] { 393 rparts := strings.Split(strings.TrimPrefix(runRef, "refs/jci-runs/"), "/") 394 if len(rparts) >= 2 { 395 runID := rparts[1] 396 status := getCIStatusFromRef(runRef) 397 commit.Runs = append(commit.Runs, RunInfo{ 398 RunID: runID, 399 Status: status, 400 Ref: runRef, 401 }) 402 } 403 } 404 405 if commit.CIStatus == "" && len(commit.Runs) > 0 { 406 commit.CIStatus = commit.Runs[len(commit.Runs)-1].Status 407 } 408 409 commits = append(commits, commit) 410 } 411 412 return commits, nil 413 } 414 415 func showMainPage(w http.ResponseWriter, r *http.Request) { 416 w.Header().Set("Content-Type", "text/html") 417 fmt.Fprint(w, `<!DOCTYPE html> 418 <html> 419 <head> 420 <meta charset="utf-8"> 421 <meta name="viewport" content="width=device-width, initial-scale=1"> 422 <title>JCI</title> 423 <style> 424 * { box-sizing: border-box; margin: 0; padding: 0; } 425 body { 426 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace; 427 font-size: 12px; 428 background: #f5f5f5; 429 color: #333; 430 display: flex; 431 height: 100vh; 432 overflow: hidden; 433 } 434 a { color: #0969da; text-decoration: none; } 435 a:hover { text-decoration: underline; } 436 437 /* Left panel - commits */ 438 .commits-panel { 439 width: 280px; 440 background: #fff; 441 border-right: 1px solid #d0d7de; 442 display: flex; 443 flex-direction: column; 444 flex-shrink: 0; 445 } 446 .panel-header { 447 padding: 8px 10px; 448 background: #f6f8fa; 449 border-bottom: 1px solid #d0d7de; 450 display: flex; 451 align-items: center; 452 gap: 8px; 453 } 454 .panel-header h1 { font-size: 13px; font-weight: 600; color: #24292f; } 455 .logo { height: 24px; width: auto; } 456 .branch-selector { 457 flex: 1; 458 padding: 4px 8px; 459 font-size: 12px; 460 border: 1px solid #d0d7de; 461 border-radius: 6px; 462 background: #fff; 463 color: #24292f; 464 } 465 .commit-list { 466 list-style: none; 467 overflow-y: auto; 468 flex: 1; 469 } 470 .commit-item { 471 padding: 6px 10px; 472 cursor: pointer; 473 display: flex; 474 align-items: center; 475 gap: 8px; 476 border-bottom: 1px solid #eaeef2; 477 } 478 .commit-item:hover { background: #f6f8fa; } 479 .commit-item.selected { background: #ddf4ff; } 480 .commit-item.no-ci { opacity: 0.5; } 481 482 /* Status indicator */ 483 .status-badge { 484 font-size: 10px; 485 font-weight: 600; 486 padding: 2px 6px; 487 border-radius: 12px; 488 flex-shrink: 0; 489 } 490 .status-badge.success { background: #dafbe1; color: #1a7f37; } 491 .status-badge.failed { background: #ffebe9; color: #cf222e; } 492 .status-badge.running { background: #fff8c5; color: #9a6700; } 493 .status-badge.none { background: #eaeef2; color: #656d76; } 494 495 .commit-hash { font-size: 11px; color: #0969da; flex-shrink: 0; font-family: monospace; } 496 .commit-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #57606a; font-size: 12px; } 497 498 /* Push status badge */ 499 .push-badge { 500 font-size: 9px; 501 font-weight: 600; 502 padding: 1px 5px; 503 border-radius: 10px; 504 flex-shrink: 0; 505 } 506 .push-badge.pushed { background: #ddf4ff; color: #0969da; } 507 .push-badge.local { background: #fff8c5; color: #9a6700; } 508 509 /* Middle panel - files */ 510 .files-panel { 511 width: 200px; 512 background: #fff; 513 border-right: 1px solid #d0d7de; 514 display: flex; 515 flex-direction: column; 516 flex-shrink: 0; 517 } 518 .files-panel.hidden { display: none; } 519 .commit-info { 520 padding: 10px 12px; 521 background: #f6f8fa; 522 border-bottom: 1px solid #d0d7de; 523 font-size: 12px; 524 } 525 .commit-info .status-line { 526 display: flex; 527 align-items: center; 528 gap: 8px; 529 margin-bottom: 4px; 530 } 531 .commit-info .status-icon { font-size: 14px; font-weight: bold; } 532 .commit-info .status-icon.success { color: #1a7f37; } 533 .commit-info .status-icon.failed { color: #cf222e; } 534 .commit-info .status-icon.running { color: #9a6700; } 535 .commit-info .hash { color: #0969da; font-family: monospace; } 536 .commit-info .meta { color: #656d76; margin-top: 4px; font-size: 11px; } 537 .run-selector { margin-top: 8px; } 538 .run-selector select { width: 100%; padding: 4px 6px; font-size: 11px; border: 1px solid #d0d7de; border-radius: 4px; background: #fff; } 539 .run-nav { display: flex; gap: 4px; margin-top: 6px; } 540 .run-nav button { flex: 1; padding: 4px 8px; font-size: 10px; border: 1px solid #d0d7de; border-radius: 4px; background: #f6f8fa; cursor: pointer; } 541 .run-nav button:hover { background: #eaeef2; } 542 .run-nav button:disabled { opacity: 0.5; cursor: not-allowed; } 543 .file-list { 544 list-style: none; 545 overflow-y: auto; 546 flex: 1; 547 } 548 .file-item { 549 padding: 6px 12px; 550 cursor: pointer; 551 border-bottom: 1px solid #eaeef2; 552 white-space: nowrap; 553 overflow: hidden; 554 text-overflow: ellipsis; 555 font-size: 12px; 556 color: #24292f; 557 } 558 .file-item:hover { background: #f6f8fa; } 559 .file-item.selected { background: #ddf4ff; } 560 561 /* Right panel - content */ 562 .content-panel { 563 flex: 1; 564 display: flex; 565 flex-direction: column; 566 min-width: 0; 567 background: #fff; 568 } 569 .content-header { 570 padding: 6px 12px; 571 background: #f6f8fa; 572 border-bottom: 1px solid #d0d7de; 573 font-size: 12px; 574 color: #57606a; 575 font-family: monospace; 576 display: flex; 577 align-items: center; 578 justify-content: space-between; 579 } 580 .content-header .filename { flex: 1; } 581 .download-btn { 582 padding: 3px 8px; 583 font-size: 11px; 584 background: #fff; 585 border: 1px solid #d0d7de; 586 border-radius: 4px; 587 color: #24292f; 588 cursor: pointer; 589 text-decoration: none; 590 } 591 .download-btn:hover { background: #f6f8fa; text-decoration: none; } 592 .content-body { 593 flex: 1; 594 overflow: auto; 595 background: #fff; 596 } 597 .content-body pre { 598 padding: 12px; 599 font-family: "Monaco", "Menlo", "Consolas", monospace; 600 font-size: 12px; 601 line-height: 1.5; 602 white-space: pre-wrap; 603 word-wrap: break-word; 604 color: #24292f; 605 } 606 .content-body iframe { 607 width: 100%; 608 height: 100%; 609 border: none; 610 background: #fff; 611 } 612 .empty-state { 613 display: flex; 614 align-items: center; 615 justify-content: center; 616 height: 100%; 617 color: #656d76; 618 font-size: 13px; 619 } 620 621 @media (max-width: 700px) { 622 .commits-panel { width: 200px; } 623 .files-panel { width: 150px; } 624 } 625 </style> 626 </head> 627 <body> 628 <div class="commits-panel"> 629 <div class="panel-header"> 630 <img class="logo" src="data:image/png;base64,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" alt="JCI"> 631 <select class="branch-selector" id="branchSelect"></select> 632 </div> 633 <ul class="commit-list" id="commitList"></ul> 634 </div> 635 <div class="files-panel hidden" id="filesPanel"> 636 <div class="commit-info" id="commitInfo"></div> 637 <ul class="file-list" id="fileList"></ul> 638 </div> 639 <div class="content-panel"> 640 <div class="content-header" id="contentHeader"></div> 641 <div class="content-body" id="contentBody"> 642 <div class="empty-state">Select a commit</div> 643 </div> 644 </div> 645 646 <script> 647 let branches = [], currentCommit = null, currentFiles = [], currentFile = null, currentRuns = [], currentRunId = null; 648 let currentBranch = null, currentPage = 0, loadedCommits = [], hasMoreCommits = false; 649 650 async function loadBranches() { 651 const res = await fetch('/api/branches'); 652 branches = await res.json() || []; 653 const select = document.getElementById('branchSelect'); 654 select.innerHTML = branches.map(b => '<option value="' + b.name + '">' + b.name + '</option>').join(''); 655 const def = branches.find(b => b.name === 'main') || branches[0]; 656 if (def) { select.value = def.name; await showBranch(def.name); } 657 658 // Check URL for initial commit and file 659 const m = location.pathname.match(/^\/jci\/([a-f0-9]+)/); 660 if (m) selectCommitByHash(m[1]); 661 } 662 663 function getStatusLabel(status) { 664 switch(status) { 665 case 'success': return '✓ ok'; 666 case 'failed': return '✗ err'; 667 case 'running': return '⋯ run'; 668 default: return '—'; 669 } 670 } 671 672 function renderCommitItem(c) { 673 const status = c.hasCI ? (c.ciStatus || 'none') : 'none'; 674 const noCiClass = c.hasCI ? '' : 'no-ci'; 675 let pushBadge = ''; 676 if (c.hasCI) { 677 pushBadge = c.ciPushed 678 ? '<span class="push-badge pushed">pushed</span>' 679 : '<span class="push-badge local">local</span>'; 680 } 681 return '<li class="commit-item ' + noCiClass + '" data-hash="' + c.hash + '" data-hasci="' + c.hasCI + '">' + 682 '<span class="status-badge ' + status + '">' + getStatusLabel(status) + '</span>' + 683 '<span class="commit-hash">' + c.shortHash + '</span>' + 684 '<span class="commit-msg">' + escapeHtml(c.message) + '</span>' + 685 pushBadge + '</li>'; 686 } 687 688 function attachCommitClickHandlers() { 689 document.querySelectorAll('.commit-item').forEach(el => { 690 el.onclick = () => selectCommit(el.dataset.hash, el.dataset.hasci === 'true'); 691 }); 692 } 693 694 async function showBranch(name) { 695 currentBranch = name; 696 currentPage = 0; 697 loadedCommits = []; 698 hasMoreCommits = false; 699 const list = document.getElementById('commitList'); 700 list.innerHTML = '<li style="padding:8px 10px;color:#656d76;">Loading…</li>'; 701 await loadMoreCommits(); 702 } 703 704 async function loadMoreCommits() { 705 const res = await fetch('/api/commits?branch=' + encodeURIComponent(currentBranch) + '&page=' + currentPage); 706 const data = await res.json(); 707 loadedCommits = loadedCommits.concat(data.commits || []); 708 hasMoreCommits = data.hasMore || false; 709 710 const list = document.getElementById('commitList'); 711 // Remove existing load-more button if present 712 const oldBtn = document.getElementById('loadMoreBtn'); 713 if (oldBtn) oldBtn.remove(); 714 715 if (currentPage === 0) { 716 list.innerHTML = loadedCommits.map(renderCommitItem).join(''); 717 } else { 718 // Append newly loaded commits (remove loading placeholder first) 719 const placeholder = document.getElementById('loadMorePlaceholder'); 720 if (placeholder) placeholder.remove(); 721 const frag = document.createDocumentFragment(); 722 const tmp = document.createElement('ul'); 723 tmp.innerHTML = (data.commits || []).map(renderCommitItem).join(''); 724 while (tmp.firstChild) frag.appendChild(tmp.firstChild); 725 list.appendChild(frag); 726 } 727 728 attachCommitClickHandlers(); 729 730 if (hasMoreCommits) { 731 const btn = document.createElement('li'); 732 btn.id = 'loadMoreBtn'; 733 btn.style.cssText = 'padding:8px 10px;text-align:center;cursor:pointer;color:#0969da;border-top:1px solid #eaeef2;'; 734 btn.textContent = 'Load more commits…'; 735 btn.onclick = async () => { 736 btn.textContent = 'Loading…'; 737 btn.onclick = null; 738 currentPage++; 739 await loadMoreCommits(); 740 }; 741 list.appendChild(btn); 742 } 743 744 // Re-highlight selected commit if any 745 if (currentCommit) { 746 document.querySelectorAll('.commit-item').forEach(el => 747 el.classList.toggle('selected', el.dataset.hash === currentCommit) 748 ); 749 } 750 } 751 752 function selectCommitByHash(hash) { 753 // Search already-loaded commits 754 const c = loadedCommits.find(c => c.hash.startsWith(hash)); 755 if (c) { selectCommit(c.hash, c.hasCI); return; } 756 } 757 758 async function selectCommit(hash, hasCI) { 759 currentCommit = hash; 760 currentFile = null; 761 document.querySelectorAll('.commit-item').forEach(el => 762 el.classList.toggle('selected', el.dataset.hash === hash) 763 ); 764 765 const filesPanel = document.getElementById('filesPanel'); 766 const contentBody = document.getElementById('contentBody'); 767 const contentHeader = document.getElementById('contentHeader'); 768 769 if (!hasCI) { 770 filesPanel.classList.add('hidden'); 771 contentHeader.innerHTML = ''; 772 contentBody.innerHTML = '<div class="empty-state">No CI results. Run: git jci run</div>'; 773 history.pushState(null, '', '/'); 774 return; 775 } 776 777 filesPanel.classList.remove('hidden'); 778 // Only update URL to commit if not already on a file URL for this commit 779 if (!location.pathname.startsWith('/jci/' + hash)) { 780 history.pushState(null, '', '/jci/' + hash); 781 } 782 783 // Load commit info and files 784 try { 785 const infoRes = await fetch('/api/commit/' + hash); 786 const info = await infoRes.json(); 787 788 let statusIcon = '?'; 789 let statusClass = ''; 790 if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; } 791 else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; } 792 else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; } 793 794 // Store runs info 795 currentRuns = info.runs || []; 796 currentRunId = info.runId || null; 797 798 // Build run selector if multiple runs 799 let runSelectorHtml = ''; 800 if (currentRuns.length > 1) { 801 const runIdx = currentRuns.findIndex(r => r.runId === currentRunId); 802 runSelectorHtml = '<div class="run-selector">' + 803 '<select id="runSelect">' + 804 currentRuns.map((r, i) => { 805 const ts = parseInt(r.runId.split('-')[0]) * 1000; 806 const date = new Date(ts).toLocaleString(); 807 const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?'; 808 const selected = r.runId === currentRunId ? ' selected' : ''; 809 return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>'; 810 }).join('') + 811 '</select></div>' + 812 '<div class="run-nav">' + 813 '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' + 814 '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' + 815 '</div>'; 816 } else if (currentRuns.length === 1) { 817 const ts = parseInt(currentRuns[0].runId.split('-')[0]) * 1000; 818 const date = new Date(ts).toLocaleString(); 819 runSelectorHtml = '<div class="meta">Run: ' + date + '</div>'; 820 } 821 822 document.getElementById('commitInfo').innerHTML = 823 '<div class="status-line">' + 824 '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' + 825 '<span class="hash">' + hash.slice(0,7) + '</span></div>' + 826 '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' + 827 runSelectorHtml; 828 829 // Set up run selector events 830 const runSelect = document.getElementById('runSelect'); 831 if (runSelect) { 832 runSelect.onchange = (e) => selectRun(hash, e.target.value); 833 } 834 const prevBtn = document.getElementById('prevRun'); 835 const nextBtn = document.getElementById('nextRun'); 836 if (prevBtn) { 837 prevBtn.onclick = () => { 838 const idx = currentRuns.findIndex(r => r.runId === currentRunId); 839 if (idx > 0) selectRun(hash, currentRuns[idx-1].runId); 840 }; 841 } 842 if (nextBtn) { 843 nextBtn.onclick = () => { 844 const idx = currentRuns.findIndex(r => r.runId === currentRunId); 845 if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId); 846 }; 847 } 848 849 currentFiles = info.files || []; 850 const fileList = document.getElementById('fileList'); 851 fileList.innerHTML = currentFiles.map(f => 852 '<li class="file-item" data-file="' + f + '">' + f + '</li>' 853 ).join(''); 854 fileList.querySelectorAll('.file-item').forEach(el => { 855 el.onclick = () => loadFile(el.dataset.file); 856 }); 857 858 // Check URL for initial file, otherwise load default 859 const urlMatch = location.pathname.match(/^\/jci\/[a-f0-9]+\/(.+)$/); 860 if (urlMatch && urlMatch[1]) { 861 loadFile(urlMatch[1], true); 862 } else { 863 const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; 864 if (defaultFile) loadFile(defaultFile, true); 865 } 866 } catch (e) { 867 contentBody.innerHTML = '<div class="empty-state">Failed to load</div>'; 868 } 869 } 870 871 function loadFile(name, skipHistory) { 872 currentFile = name; 873 document.querySelectorAll('.file-item').forEach(el => 874 el.classList.toggle('selected', el.dataset.file === name) 875 ); 876 877 const contentHeader = document.getElementById('contentHeader'); 878 const contentBody = document.getElementById('contentBody'); 879 // Include runId in URL if available 880 const commitPath = currentRunId ? currentCommit + '/' + currentRunId : currentCommit; 881 const rawUrl = '/jci/' + commitPath + '/' + name + '/raw'; 882 883 contentHeader.innerHTML = '<span class="filename">' + escapeHtml(name) + '</span>' + 884 '<a class="download-btn" href="' + rawUrl + '" target="_blank">↓ Download</a>'; 885 886 if (!skipHistory) { 887 history.pushState(null, '', '/jci/' + commitPath + '/' + name); 888 } 889 890 const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; 891 const textExts = ['txt', 'log', 'sh', 'go', 'py', 'js', 'json', 'yaml', 'yml', 'md', 'css', 'xml', 'toml', 'ini', 'conf']; 892 const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico']; 893 894 if (ext === 'html' || ext === 'htm') { 895 contentBody.innerHTML = '<iframe src="' + rawUrl + '"></iframe>'; 896 } else if (imageExts.includes(ext)) { 897 contentBody.innerHTML = '<div style="padding: 12px; text-align: center;"><img src="' + rawUrl + '" style="max-width: 100%; max-height: 100%;"></div>'; 898 } else if (textExts.includes(ext)) { 899 fetch(rawUrl).then(r => r.text()).then(text => { 900 contentBody.innerHTML = '<pre>' + escapeHtml(text) + '</pre>'; 901 }); 902 } else { 903 contentBody.innerHTML = '<div class="empty-state">Binary file. <a href="' + rawUrl + '" target="_blank">Download ' + escapeHtml(name) + '</a></div>'; 904 } 905 } 906 907 async function selectRun(hash, runId) { 908 currentRunId = runId; 909 currentFile = null; 910 // Fetch the specific run 911 const infoRes = await fetch('/api/commit/' + hash + '/' + runId); 912 const info = await infoRes.json(); 913 914 let statusIcon = '?'; 915 let statusClass = ''; 916 if (info.status === 'success') { statusIcon = '✓'; statusClass = 'success'; } 917 else if (info.status === 'failed') { statusIcon = '✗'; statusClass = 'failed'; } 918 else if (info.status === 'running') { statusIcon = '⋯'; statusClass = 'running'; } 919 920 // Update runs from response 921 currentRuns = info.runs || []; 922 923 // Build run selector 924 let runSelectorHtml = ''; 925 if (currentRuns.length > 1) { 926 const runIdx = currentRuns.findIndex(r => r.runId === currentRunId); 927 runSelectorHtml = '<div class="run-selector">' + 928 '<select id="runSelect">' + 929 currentRuns.map((r, i) => { 930 const ts = parseInt(r.runId.split('-')[0]) * 1000; 931 const date = new Date(ts).toLocaleString(); 932 const statusEmoji = r.status === 'success' ? '✓' : r.status === 'failed' ? '✗' : '?'; 933 const selected = r.runId === currentRunId ? ' selected' : ''; 934 return '<option value="' + r.runId + '"' + selected + '>' + statusEmoji + ' Run ' + (i+1) + ' - ' + date + '</option>'; 935 }).join('') + 936 '</select></div>' + 937 '<div class="run-nav">' + 938 '<button id="prevRun"' + (runIdx <= 0 ? ' disabled' : '') + '>← Prev</button>' + 939 '<button id="nextRun"' + (runIdx >= currentRuns.length - 1 ? ' disabled' : '') + '>Next →</button>' + 940 '</div>'; 941 } 942 943 document.getElementById('commitInfo').innerHTML = 944 '<div class="status-line">' + 945 '<span class="status-icon ' + statusClass + '">' + statusIcon + '</span>' + 946 '<span class="hash">' + hash.slice(0,7) + '</span></div>' + 947 '<div class="meta">' + escapeHtml(info.author) + ' · ' + escapeHtml(info.date) + '</div>' + 948 runSelectorHtml; 949 950 // Re-attach event listeners 951 const runSelect = document.getElementById('runSelect'); 952 if (runSelect) { 953 runSelect.onchange = (e) => selectRun(hash, e.target.value); 954 } 955 const prevBtn = document.getElementById('prevRun'); 956 const nextBtn = document.getElementById('nextRun'); 957 if (prevBtn) { 958 prevBtn.onclick = () => { 959 const idx = currentRuns.findIndex(r => r.runId === currentRunId); 960 if (idx > 0) selectRun(hash, currentRuns[idx-1].runId); 961 }; 962 } 963 if (nextBtn) { 964 nextBtn.onclick = () => { 965 const idx = currentRuns.findIndex(r => r.runId === currentRunId); 966 if (idx < currentRuns.length - 1) selectRun(hash, currentRuns[idx+1].runId); 967 }; 968 } 969 970 // Update files 971 currentFiles = info.files || []; 972 const fileList = document.getElementById('fileList'); 973 fileList.innerHTML = currentFiles.map(f => 974 '<li class="file-item" data-file="' + f + '">' + f + '</li>' 975 ).join(''); 976 fileList.querySelectorAll('.file-item').forEach(el => { 977 el.onclick = () => loadFile(el.dataset.file); 978 }); 979 980 // Update URL 981 history.pushState(null, '', '/jci/' + hash + '/' + runId); 982 983 // Load default file 984 const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; 985 if (defaultFile) loadFile(defaultFile, true); 986 } 987 988 function escapeHtml(t) { 989 if (!t) return ''; 990 return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); 991 } 992 993 window.onpopstate = () => { 994 const m = location.pathname.match(/^\/jci\/([a-f0-9]+)(?:\/(.+))?/); 995 if (m) { 996 const commit = m[1]; 997 const file = m[2] || null; 998 if (commit !== currentCommit) { 999 selectCommitByHash(commit); 1000 } else if (file && file !== currentFile) { 1001 loadFile(file, true); 1002 } else if (!file && currentFile) { 1003 // Went back to commit-only URL 1004 currentFile = null; 1005 const defaultFile = currentFiles.find(f => f === 'run.output.txt') || currentFiles[0]; 1006 if (defaultFile) loadFile(defaultFile, true); 1007 } 1008 } else if (location.pathname === '/') { 1009 currentCommit = null; 1010 currentFile = null; 1011 document.querySelectorAll('.commit-item').forEach(el => el.classList.remove('selected')); 1012 document.getElementById('filesPanel').classList.add('hidden'); 1013 document.getElementById('contentHeader').innerHTML = ''; 1014 document.getElementById('contentBody').innerHTML = '<div class="empty-state">Select a commit</div>'; 1015 } 1016 }; 1017 1018 document.getElementById('branchSelect').onchange = e => showBranch(e.target.value); 1019 loadBranches(); 1020 1021 </script> 1022 </body> 1023 </html> 1024 `) 1025 } 1026 1027 func serveFromRef(w http.ResponseWriter, commit string, runID string, filePath string) { 1028 var ref string 1029 1030 // Build ref based on whether we have a runID 1031 if runID != "" { 1032 ref = "refs/jci-runs/" + commit + "/" + runID 1033 } else { 1034 ref = "refs/jci/" + commit 1035 // If single-run ref doesn't exist, try to find latest run 1036 if !RefExists(ref) { 1037 runRefs, _ := ListJCIRunRefs() 1038 for _, r := range runRefs { 1039 if strings.HasPrefix(r, "refs/jci-runs/"+commit+"/") { 1040 ref = r // Use last matching (sorted by timestamp) 1041 } 1042 } 1043 } 1044 } 1045 1046 if !RefExists(ref) { 1047 http.Error(w, "CI results not found for commit: "+commit, 404) 1048 return 1049 } 1050 1051 // Use git show to get file content from the ref 1052 cmd := exec.Command("git", "show", ref+":"+filePath) 1053 out, err := cmd.Output() 1054 if err != nil { 1055 http.Error(w, "File not found: "+filePath, 404) 1056 return 1057 } 1058 1059 // Set content type based on extension 1060 ext := filepath.Ext(filePath) 1061 switch ext { 1062 case ".html": 1063 w.Header().Set("Content-Type", "text/html") 1064 case ".css": 1065 w.Header().Set("Content-Type", "text/css") 1066 case ".js": 1067 w.Header().Set("Content-Type", "application/javascript") 1068 case ".json": 1069 w.Header().Set("Content-Type", "application/json") 1070 case ".txt": 1071 w.Header().Set("Content-Type", "text/plain") 1072 default: 1073 // Binary files (executables, etc.) 1074 w.Header().Set("Content-Type", "application/octet-stream") 1075 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filepath.Base(filePath))) 1076 } 1077 1078 w.Write(out) 1079 } 1080 1081 // extractRef extracts files from a ref to a temp directory (not used currently but useful) 1082 func extractRef(ref string) (string, error) { 1083 tmpDir, err := os.MkdirTemp("", "jci-view-*") 1084 if err != nil { 1085 return "", err 1086 } 1087 1088 cmd := exec.Command("git", "archive", ref) 1089 tar := exec.Command("tar", "-xf", "-", "-C", tmpDir) 1090 1091 tar.Stdin, _ = cmd.StdoutPipe() 1092 tar.Start() 1093 cmd.Run() 1094 tar.Wait() 1095 1096 return tmpDir, nil 1097 }