github.com/zaquestion/lab@v0.25.1/cmd/ci_view.go (about) 1 package cmd 2 3 import ( 4 "bufio" 5 "context" 6 "fmt" 7 "io" 8 "os" 9 "runtime/debug" 10 "strings" 11 "time" 12 13 "github.com/MakeNowJust/heredoc/v2" 14 "github.com/gdamore/tcell/v2" 15 "github.com/pkg/errors" 16 "github.com/rivo/tview" 17 "github.com/rsteube/carapace" 18 "github.com/spf13/cobra" 19 20 "github.com/lunixbochs/vtclean" 21 gitlab "github.com/xanzy/go-gitlab" 22 23 "github.com/zaquestion/lab/internal/action" 24 lab "github.com/zaquestion/lab/internal/gitlab" 25 ) 26 27 var ( 28 projectID string 29 pipelineID int 30 ) 31 32 // ciViewCmd represents the ci command 33 var ciViewCmd = &cobra.Command{ 34 Use: "view [remote [branch/tag]]", 35 Short: "View, run, trace, and/or cancel CI jobs current pipeline", 36 Long: heredoc.Doc(` 37 Supports viewing, running, tracing, and canceling jobs. 38 39 The <tab> key navigates and <enter> confirms. Also supports vi style 40 (hjkl,Gg) bindings and arrow keys for navigating jobs and logs. 41 42 'r', 'p' to run/retry/play a job 43 't' to toggle trace/logs (runs in background) 44 'T' to toggle trace/logs (suspending application) 45 'c' to cancel job`), 46 Example: heredoc.Doc(` 47 lab ci view 48 lab ci view upstream --merge-request 49 lab ci view upstream --merge-request --bridge 'security-tests'`), 50 PersistentPreRun: labPersistentPreRun, 51 Run: func(cmd *cobra.Command, args []string) { 52 a := tview.NewApplication() 53 defer recoverPanic(a) 54 var ( 55 rn string 56 err error 57 ) 58 59 forMR, err := cmd.Flags().GetBool("merge-request") 60 if err != nil { 61 log.Fatal(err) 62 } 63 64 bridgeName, err = cmd.Flags().GetString("bridge") 65 if err != nil { 66 log.Fatal(err) 67 } else if bridgeName != "" { 68 followBridge = true 69 } else { 70 followBridge, err = cmd.Flags().GetBool("follow") 71 if err != nil { 72 log.Fatal(err) 73 } 74 } 75 76 rn, pipelineID, err = getPipelineFromArgs(args, forMR) 77 if err != nil { 78 log.Fatal(err) 79 } 80 81 projectID = rn 82 root := tview.NewPages() 83 root.SetBorderPadding(1, 1, 2, 2) 84 85 boxes = make(map[string]*tview.TextView) 86 jobsCh := make(chan []*gitlab.Job) 87 inputCh := make(chan struct{}, 1) 88 89 screen, err := tcell.NewScreen() 90 if err != nil { 91 log.Fatal(err) 92 } 93 screen.Init() 94 95 var navi navigator 96 a.SetInputCapture(inputCapture(a, root, navi, inputCh)) 97 go updateJobs(a, jobsCh) 98 go func() { 99 defer recoverPanic(a) 100 for { 101 a.SetFocus(root) 102 jobsView(a, jobsCh, inputCh, root) 103 a.Draw() 104 } 105 }() 106 if err := a.SetScreen(screen).SetRoot(root, true).SetAfterDrawFunc(connectJobsView(a)).Run(); err != nil { 107 log.Fatal(err) 108 } 109 }, 110 } 111 112 func inputCapture(a *tview.Application, root *tview.Pages, navi navigator, inputCh chan struct{}) func(event *tcell.EventKey) *tcell.EventKey { 113 queueInputUpdate := func() { 114 if len(inputCh) == 0 { 115 inputCh <- struct{}{} 116 } 117 } 118 return func(event *tcell.EventKey) *tcell.EventKey { 119 if event.Rune() == 'q' || event.Key() == tcell.KeyEscape { 120 switch { 121 case modalVisible: 122 modalVisible = !modalVisible 123 root.HidePage("yesno") 124 queueInputUpdate() 125 case logsVisible: 126 logsVisible = !logsVisible 127 root.HidePage("logs-" + curJob.Name) 128 queueInputUpdate() 129 a.ForceDraw() 130 default: 131 a.Stop() 132 return nil 133 } 134 } 135 if !modalVisible && !logsVisible { 136 curJob = navi.Navigate(jobs, event) 137 root.SendToFront("jobs-" + curJob.Name) 138 // update jobs view on input changes 139 queueInputUpdate() 140 } 141 switch event.Rune() { 142 case 'c': 143 job, err := lab.CICancel(projectID, curJob.ID) 144 if err != nil { 145 a.Stop() 146 log.Fatal(err) 147 } 148 curJob = job 149 root.RemovePage("logs-" + curJob.Name) 150 queueInputUpdate() 151 a.ForceDraw() 152 case 'p', 'r': 153 if modalVisible { 154 break 155 } 156 modalVisible = true 157 modal := tview.NewModal(). 158 SetText(fmt.Sprintf("Are you sure you want to run %s", curJob.Name)). 159 AddButtons([]string{"No", "Yes"}). 160 SetDoneFunc(func(buttonIndex int, buttonLabel string) { 161 modalVisible = false 162 root.RemovePage("yesno") 163 if buttonLabel == "" || buttonLabel == "No" { 164 a.ForceDraw() 165 return 166 } 167 root.RemovePage("logs-" + curJob.Name) 168 a.ForceDraw() 169 170 job, err := lab.CIPlayOrRetry(projectID, curJob.ID, curJob.Status) 171 if err != nil { 172 a.Stop() 173 log.Fatal(err) 174 } 175 if job != nil { 176 curJob = job 177 a.ForceDraw() 178 } 179 }) 180 root.AddAndSwitchToPage("yesno", modal, false) 181 queueInputUpdate() 182 a.ForceDraw() 183 return nil 184 case 't': 185 logsVisible = !logsVisible 186 if !logsVisible { 187 root.HidePage("logs-" + curJob.Name) 188 } 189 queueInputUpdate() 190 a.ForceDraw() 191 return nil 192 case 'T': 193 a.Suspend(func() { 194 ctx, cancel := context.WithCancel(context.Background()) 195 go func() { 196 err := doTrace(ctx, os.Stdout, projectID, pipelineID, curJob.Name) 197 if err != nil { 198 a.Stop() 199 log.Fatal(err) 200 } 201 if ctx.Err() == nil { // not done or cancelled 202 fmt.Println("\nPush <Enter> to resume ci view") 203 } 204 }() 205 reader := bufio.NewReader(os.Stdin) 206 for { 207 r, _, err := reader.ReadRune() 208 if err != io.EOF && err != nil { 209 a.Stop() 210 log.Fatal(err) 211 } 212 if r == '\n' { 213 cancel() 214 break 215 } 216 } 217 }) 218 queueInputUpdate() 219 return nil 220 } 221 queueInputUpdate() 222 return event 223 } 224 } 225 226 var ( 227 logsVisible, modalVisible bool 228 curJob *gitlab.Job 229 jobs []*gitlab.Job 230 boxes map[string]*tview.TextView 231 ) 232 233 // navigator manages the internal state for processing tcell.EventKeys 234 type navigator struct { 235 depth, idx int 236 } 237 238 // Navigate uses the ci stages as boundaries and returns the currently focused 239 // job index after processing a *tcell.EventKey 240 func (n *navigator) Navigate(jobs []*gitlab.Job, event *tcell.EventKey) *gitlab.Job { 241 stage := jobs[n.idx].Stage 242 prev, next := adjacentStages(jobs, stage) 243 switch event.Key() { 244 case tcell.KeyLeft: 245 stage = prev 246 case tcell.KeyRight: 247 stage = next 248 } 249 switch event.Rune() { 250 case 'h': 251 stage = prev 252 case 'l': 253 stage = next 254 } 255 l, u := stageBounds(jobs, stage) 256 257 switch event.Key() { 258 case tcell.KeyDown: 259 n.depth++ 260 if n.depth > u-l { 261 n.depth = u - l 262 } 263 case tcell.KeyUp: 264 n.depth-- 265 } 266 switch event.Rune() { 267 case 'j': 268 n.depth++ 269 if n.depth > u-l { 270 n.depth = u - l 271 } 272 case 'k': 273 n.depth-- 274 case 'g': 275 n.depth = 0 276 case 'G': 277 n.depth = u - l 278 } 279 280 if n.depth < 0 { 281 n.depth = 0 282 } 283 n.idx = l + n.depth 284 if n.idx > u { 285 n.idx = u 286 } 287 return jobs[n.idx] 288 } 289 290 func stageBounds(jobs []*gitlab.Job, s string) (l, u int) { 291 if len(jobs) <= 1 { 292 return 0, 0 293 } 294 p := jobs[0].Stage 295 for i, v := range jobs { 296 if v.Stage != s && u != 0 { 297 return 298 } 299 if v.Stage != p { 300 l = i 301 p = v.Stage 302 } 303 if v.Stage == s { 304 u = i 305 } 306 } 307 return 308 } 309 310 func adjacentStages(jobs []*gitlab.Job, s string) (p, n string) { 311 if len(jobs) == 0 { 312 return "", "" 313 } 314 p = jobs[0].Stage 315 316 for _, v := range jobs { 317 if v.Stage != s && n != "" { 318 n = v.Stage 319 return 320 } 321 if v.Stage == s { 322 n = "cur" 323 } 324 if n == "" { 325 p = v.Stage 326 } 327 } 328 n = jobs[len(jobs)-1].Stage 329 return 330 } 331 332 func jobsView(app *tview.Application, jobsCh chan []*gitlab.Job, inputCh chan struct{}, root *tview.Pages) { 333 select { 334 case jobs = <-jobsCh: 335 case <-inputCh: 336 case <-time.NewTicker(time.Second * 1).C: 337 } 338 if jobs == nil { 339 jobs = <-jobsCh 340 } 341 if curJob == nil && len(jobs) > 0 { 342 curJob = jobs[0] 343 } 344 if modalVisible { 345 return 346 } 347 if logsVisible { 348 logsKey := "logs-" + curJob.Name 349 if !root.SwitchToPage(logsKey).HasPage(logsKey) { 350 tv := tview.NewTextView() 351 tv.SetDynamicColors(true) 352 tv.SetBorderPadding(0, 0, 1, 1).SetBorder(true) 353 354 go func() { 355 err := doTrace(context.Background(), vtclean.NewWriter(tview.ANSIWriter(tv), true), projectID, pipelineID, curJob.Name) 356 if err != nil { 357 app.Stop() 358 log.Fatal(err) 359 } 360 }() 361 root.AddAndSwitchToPage("logs-"+curJob.Name, tv, true) 362 } 363 return 364 } 365 px, _, maxX, maxY := root.GetInnerRect() 366 var ( 367 stages = 0 368 lastStage = "" 369 ) 370 // get the number of stages 371 for _, j := range jobs { 372 if j.Stage != lastStage { 373 lastStage = j.Stage 374 stages++ 375 } 376 } 377 lastStage = "" 378 var ( 379 rowIdx = 0 380 stageIdx = 0 381 maxTitle = 20 382 ) 383 for _, j := range jobs { 384 boxX := px + (maxX / stages * stageIdx) 385 if j.Stage != lastStage { 386 rowIdx = 0 387 stageIdx++ 388 lastStage = j.Stage 389 key := "stage-" + j.Stage 390 391 x, y, w, h := boxX, maxY/6-4, maxTitle+2, 3 392 b := box(root, key, x, y, w, h) 393 394 b.SetText(strings.Title(j.Stage)) 395 b.SetTextAlign(tview.AlignCenter) 396 397 } 398 } 399 lastStage = jobs[0].Stage 400 rowIdx = 0 401 stageIdx = 0 402 for _, j := range jobs { 403 if j.Stage != lastStage { 404 rowIdx = 0 405 lastStage = j.Stage 406 stageIdx++ 407 } 408 boxX := px + (maxX / stages * stageIdx) 409 410 key := "jobs-" + j.Name 411 x, y, w, h := boxX, maxY/6+(rowIdx*5), maxTitle+2, 4 412 b := box(root, key, x, y, w, h) 413 b.SetTitle(j.Name) 414 // The scope of jobs to show, one or array of: created, pending, running, 415 // failed, success, canceled, skipped; showing all jobs if none provided 416 var statChar rune 417 switch j.Status { 418 case "success": 419 b.SetBorderColor(tcell.ColorGreen) 420 statChar = '✔' 421 case "failed": 422 b.SetBorderColor(tcell.ColorRed) 423 statChar = '✘' 424 case "running": 425 b.SetBorderColor(tcell.ColorBlue) 426 statChar = '●' 427 case "pending": 428 b.SetBorderColor(tcell.ColorYellow) 429 statChar = '●' 430 case "manual": 431 b.SetBorderColor(tcell.ColorGrey) 432 statChar = '●' 433 } 434 // retryChar := '⟳' 435 title := fmt.Sprintf("%c %s", statChar, j.Name) 436 // trim the suffix if it matches the stage, I've seen 437 // the pattern in 2 different places to handle 438 // different stages for the same service and it tends 439 // to make the title spill over the max 440 title = strings.TrimSuffix(title, ":"+j.Stage) 441 b.SetTitle(title) 442 // tview default aligns center, which is nice, but if 443 // the title is too long we want to bias towards seeing 444 // the beginning of it 445 if tview.TaggedStringWidth(title) > maxTitle { 446 b.SetTitleAlign(tview.AlignLeft) 447 } 448 if j.StartedAt != nil { 449 end := time.Now() 450 if j.FinishedAt != nil { 451 end = *j.FinishedAt 452 } 453 b.SetText("\n" + fmtDuration(end.Sub(*j.StartedAt))) 454 b.SetTextAlign(tview.AlignRight) 455 } else { 456 b.SetText("") 457 } 458 rowIdx++ 459 460 } 461 root.SendToFront("jobs-" + curJob.Name) 462 463 } 464 func fmtDuration(d time.Duration) string { 465 d = d.Round(time.Second) 466 m := d / time.Minute 467 d -= m * time.Minute 468 s := d / time.Second 469 return fmt.Sprintf("%02dm %02ds", m, s) 470 } 471 func box(root *tview.Pages, key string, x, y, w, h int) *tview.TextView { 472 b, ok := boxes[key] 473 if !ok { 474 b = tview.NewTextView() 475 b.SetBorder(true) 476 boxes[key] = b 477 } 478 b.SetRect(x, y, w, h) 479 480 root.AddPage(key, b, false, true) 481 return b 482 } 483 484 func recoverPanic(app *tview.Application) { 485 if r := recover(); r != nil { 486 app.Stop() 487 log.Fatalf("%s\n%s\n", r, string(debug.Stack())) 488 } 489 } 490 491 func updateJobs(app *tview.Application, jobsCh chan []*gitlab.Job) { 492 defer recoverPanic(app) 493 for { 494 if modalVisible { 495 time.Sleep(time.Second * 1) 496 continue 497 } 498 jobStructList, err := lab.CIJobs(projectID, pipelineID, followBridge, bridgeName) 499 if len(jobStructList) == 0 || err != nil { 500 app.Stop() 501 log.Fatal(errors.Wrap(err, "failed to find ci jobs")) 502 } 503 504 jobs := make([]*gitlab.Job, 0) 505 for _, jobStruct := range jobStructList { 506 jobs = append(jobs, jobStruct.Job) 507 } 508 509 jobsCh <- latestJobs(jobs) 510 time.Sleep(time.Second * 5) 511 } 512 } 513 514 func connectJobsView(app *tview.Application) func(screen tcell.Screen) { 515 return func(screen tcell.Screen) { 516 defer recoverPanic(app) 517 err := connectJobs(screen, jobs, boxes) 518 if err != nil { 519 app.Stop() 520 log.Fatal(err) 521 } 522 } 523 } 524 525 func connectJobs(screen tcell.Screen, jobs []*gitlab.Job, boxes map[string]*tview.TextView) error { 526 if logsVisible || modalVisible { 527 return nil 528 } 529 for i, j := range jobs { 530 if _, ok := boxes["jobs-"+j.Name]; !ok { 531 return errors.Errorf("jobs-%s not found at index: %d", jobs[i].Name, i) 532 } 533 } 534 var padding int 535 // find the abount of space between two jobs is adjacent stages 536 for i, k := 0, 1; k < len(jobs); i, k = i+1, k+1 { 537 if jobs[i].Stage == jobs[k].Stage { 538 continue 539 } 540 x1, _, w, _ := boxes["jobs-"+jobs[i].Name].GetRect() 541 x2, _, _, _ := boxes["jobs-"+jobs[k].Name].GetRect() 542 stageWidth := x2 - x1 - w 543 switch { 544 case stageWidth <= 3: 545 padding = 1 546 case stageWidth <= 6: 547 padding = 2 548 case stageWidth > 6: 549 padding = 3 550 } 551 } 552 for i, k := 0, 1; k < len(jobs); i, k = i+1, k+1 { 553 v1 := boxes["jobs-"+jobs[i].Name] 554 v2 := boxes["jobs-"+jobs[k].Name] 555 connect(screen, v1.Box, v2.Box, padding, 556 jobs[i].Stage == jobs[0].Stage, // is first stage? 557 jobs[i].Stage == jobs[len(jobs)-1].Stage) // is last stage? 558 } 559 return nil 560 } 561 562 func connect(screen tcell.Screen, v1 *tview.Box, v2 *tview.Box, padding int, firstStage, lastStage bool) { 563 x1, y1, w, h := v1.GetRect() 564 x2, y2, _, _ := v2.GetRect() 565 566 dx, dy := x2-x1, y2-y1 567 568 p := padding 569 570 // drawing stages 571 if dx != 0 { 572 hline(screen, x1+w, y2+h/2, dx-w) 573 if dy != 0 { 574 // dy != 0 means the last stage had multple jobs 575 screen.SetContent(x1+w+p-1, y2+h/2, '┳', nil, tcell.StyleDefault) 576 } 577 return 578 } 579 580 // Drawing a job in the same stage 581 // left of view 582 if !firstStage { 583 if r, _, _, _ := screen.GetContent(x2-p, y1+h/2); r == '┗' { 584 screen.SetContent(x2-p, y1+h/2, '┣', nil, tcell.StyleDefault) 585 } else { 586 screen.SetContent(x2-p, y1+h/2, '┳', nil, tcell.StyleDefault) 587 } 588 589 for i := 1; i < p; i++ { 590 screen.SetContent(x2-i, y2+h/2, '━', nil, tcell.StyleDefault) 591 } 592 screen.SetContent(x2-p, y2+h/2, '┗', nil, tcell.StyleDefault) 593 594 vline(screen, x2-p, y1+h-1, dy-1) 595 } 596 // right of view 597 if !lastStage { 598 if r, _, _, _ := screen.GetContent(x2+w+p-1, y1+h/2); r == '┛' { 599 screen.SetContent(x2+w+p-1, y1+h/2, '┫', nil, tcell.StyleDefault) 600 } 601 for i := 0; i < p-1; i++ { 602 screen.SetContent(x2+w+i, y2+h/2, '━', nil, tcell.StyleDefault) 603 } 604 screen.SetContent(x2+w+p-1, y2+h/2, '┛', nil, tcell.StyleDefault) 605 606 vline(screen, x2+w+p-1, y1+h-1, dy-1) 607 } 608 } 609 610 func hline(screen tcell.Screen, x, y, l int) { 611 for i := 0; i < l; i++ { 612 screen.SetContent(x+i, y, '━', nil, tcell.StyleDefault) 613 } 614 } 615 616 func vline(screen tcell.Screen, x, y, l int) { 617 for i := 0; i < l; i++ { 618 screen.SetContent(x, y+i, '┃', nil, tcell.StyleDefault) 619 } 620 } 621 622 // latestJobs returns a list of unique jobs favoring the last stage+name 623 // version of a job in the provided list 624 func latestJobs(jobs []*gitlab.Job) []*gitlab.Job { 625 var ( 626 lastJob = make(map[string]*gitlab.Job, len(jobs)) 627 dupIdx = -1 628 ) 629 for i, j := range jobs { 630 _, ok := lastJob[j.Stage+j.Name] 631 if dupIdx == -1 && ok { 632 dupIdx = i 633 } 634 // always want the latest job 635 lastJob[j.Stage+j.Name] = j 636 } 637 if dupIdx == -1 { 638 dupIdx = len(jobs) 639 } 640 // first duplicate marks where retries begin 641 outJobs := make([]*gitlab.Job, dupIdx) 642 for i := range outJobs { 643 j := jobs[i] 644 outJobs[i] = lastJob[j.Stage+j.Name] 645 } 646 647 return outJobs 648 } 649 650 func init() { 651 ciViewCmd.Flags().Bool("merge-request", false, "use merge request pipeline if enabled") 652 ciCmd.AddCommand(ciViewCmd) 653 carapace.Gen(ciViewCmd).PositionalCompletion( 654 action.Remotes(), 655 action.RemoteBranches(0), 656 ) 657 }