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