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