This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / pipelines / ssh / tui.go
12 kB 447 lines
1package ssh 2 3import ( 4 "encoding/json" 5 "fmt" 6 "strings" 7 "time" 8 9 "github.com/charmbracelet/bubbles/spinner" 10 "github.com/charmbracelet/bubbles/viewport" 11 tea "github.com/charmbracelet/bubbletea" 12 "github.com/charmbracelet/lipgloss" 13 "github.com/gorilla/websocket" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" 16 "tangled.org/core/appview/pipelines" 17 "tangled.org/core/orm" 18 spindlemodel "tangled.org/core/spindle/models" 19) 20 21var ( 22 colorWhite lipgloss.ANSIColor = 7 23 colorBlue lipgloss.ANSIColor = 4 24 colorBrightBlack lipgloss.ANSIColor = 8 25 colorDarkGrey lipgloss.ANSIColor = 0 26) 27 28type tickMsg time.Time 29 30type statusUpdateMsg struct { 31 pipeline models.Pipeline 32} 33 34type statusUpdateErrMsg struct{ err error } 35 36type pipelineModel struct { 37 renderer *lipgloss.Renderer 38 server *Server 39 pipeline models.Pipeline 40 workflows []string 41 selected int 42 logs map[string]*workflowLogs 43 statusCh chan struct{} 44 spinner spinner.Model 45 width int 46 height int 47} 48 49type workflowLogs struct { 50 steps []step 51 stepIndex map[int]int 52 vp viewport.Model 53 ready bool 54 done bool 55 err error 56} 57 58func newPipelineModel(renderer *lipgloss.Renderer, s *Server, pipeline models.Pipeline, width, height int) *pipelineModel { 59 workflows := pipeline.Workflows() 60 logs := make(map[string]*workflowLogs, len(workflows)) 61 for _, wf := range workflows { 62 logs[wf] = &workflowLogs{stepIndex: make(map[int]int)} 63 } 64 statusCh := s.pipelineNotifier.Subscribe(pipeline.AtUri()) 65 sp := spinner.New(spinner.WithSpinner(spinner.Line)) 66 return &pipelineModel{ 67 renderer: renderer, 68 server: s, 69 pipeline: pipeline, 70 workflows: workflows, 71 logs: logs, 72 statusCh: statusCh, 73 spinner: sp, 74 width: width, 75 height: height, 76 } 77} 78 79func (m *pipelineModel) Init() tea.Cmd { 80 cmds := []tea.Cmd{tick(), m.spinner.Tick, m.waitForStatusUpdate(m.statusCh)} 81 for _, wf := range m.workflows { 82 cmds = append(cmds, m.connectCmd(wf)) 83 } 84 return tea.Batch(cmds...) 85} 86 87func tick() tea.Cmd { 88 return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) 89} 90 91// waitForStatusUpdate blocks on the notifier channel, re-fetches pipeline statuses, and returns the result as a tea.Msg. 92func (m *pipelineModel) waitForStatusUpdate(ch chan struct{}) tea.Cmd { 93 knot := m.pipeline.Knot 94 rkey := m.pipeline.Rkey 95 return func() tea.Msg { 96 if _, ok := <-ch; !ok { 97 return nil 98 } 99 ps, err := db.GetPipelineStatuses(m.server.db, 1, 100 orm.FilterEq("p.knot", knot), 101 orm.FilterEq("p.rkey", rkey), 102 ) 103 if err != nil || len(ps) == 0 { 104 return statusUpdateErrMsg{err: fmt.Errorf("refreshing pipeline: %w", err)} 105 } 106 return statusUpdateMsg{pipeline: ps[0]} 107 } 108} 109 110// connectCmd dials the spindle websocket for the given workflow and starts streaming log events. 111func (m *pipelineModel) connectCmd(workflow string) tea.Cmd { 112 return func() tea.Msg { 113 ws, ok := m.pipeline.Statuses[workflow] 114 if !ok || len(ws.Data) == 0 { 115 return logDoneMsg{workflow: workflow} 116 } 117 url := pipelines.SpindleURL(m.server.config.Core.Dev, ws.Data[0].Spindle, m.pipeline.Knot, m.pipeline.Rkey, workflow) 118 conn, _, err := websocket.DefaultDialer.Dial(url, nil) 119 if err != nil { 120 return logDoneMsg{workflow: workflow, err: fmt.Errorf("connecting to spindle: %w", err)} 121 } 122 ch := make(chan pipelines.LogEvent, 100) 123 go pipelines.ReadLogs(conn, ch) 124 return readNextLogEvent(workflow, conn, ch) 125 } 126} 127 128func (m *pipelineModel) vpHeight() int { 129 return max(m.height-2, 1) // topbar + divider take 2 lines 130} 131 132// resizeViewports updates all viewport dimensions and re-renders their content after a terminal resize. 133// 134// TODO: can be tedious if we have logs of logs 135func (m *pipelineModel) resizeViewports() { 136 for _, wl := range m.logs { 137 if !wl.ready { 138 continue 139 } 140 atBottom := wl.vp.AtBottom() 141 wl.vp.Width = m.width 142 wl.vp.Height = m.vpHeight() 143 wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 144 if atBottom { 145 wl.vp.GotoBottom() 146 } 147 } 148} 149 150func (m *pipelineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 151 switch msg := msg.(type) { 152 case tea.WindowSizeMsg: 153 m.width, m.height = msg.Width, msg.Height 154 m.resizeViewports() 155 156 case tickMsg: 157 return m, tick() 158 159 case spinner.TickMsg: 160 var cmd tea.Cmd 161 m.spinner, cmd = m.spinner.Update(msg) 162 return m, cmd 163 164 case tea.KeyMsg: 165 switch msg.String() { 166 case "q", "ctrl+c": 167 m.server.pipelineNotifier.Unsubscribe(m.pipeline.AtUri(), m.statusCh) 168 return m, tea.Quit 169 case "tab", "right", "l": 170 m.selected = (m.selected + 1) % len(m.workflows) 171 return m, nil 172 case "shift+tab", "left", "h": 173 m.selected = (m.selected - 1 + len(m.workflows)) % len(m.workflows) 174 return m, nil 175 } 176 if wl := m.selectedLogs(); wl != nil && wl.ready { 177 switch msg.String() { 178 case "g": 179 wl.vp.GotoTop() 180 return m, nil 181 case "G": 182 wl.vp.GotoBottom() 183 return m, nil 184 case "ctrl+e": 185 wl.vp.ScrollDown(1) 186 return m, nil 187 case "ctrl+y": 188 wl.vp.ScrollUp(1) 189 return m, nil 190 } 191 var cmd tea.Cmd 192 wl.vp, cmd = wl.vp.Update(msg) 193 return m, cmd 194 } 195 196 case logEventMsg: 197 return m, m.handleLogEvent(msg) 198 199 case logDoneMsg: 200 if wl, ok := m.logs[msg.workflow]; ok { 201 wl.done, wl.err = true, msg.err 202 m.initViewport(wl) 203 wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 204 wl.vp.GotoBottom() 205 } 206 207 case statusUpdateMsg: 208 // detect any workflows that are new since the last update 209 known := make(map[string]bool, len(m.workflows)) 210 for _, wf := range m.workflows { 211 known[wf] = true 212 } 213 m.pipeline = msg.pipeline 214 var newCmds []tea.Cmd 215 for _, wf := range msg.pipeline.Workflows() { 216 if !known[wf] { 217 m.workflows = append(m.workflows, wf) 218 m.logs[wf] = &workflowLogs{stepIndex: make(map[int]int)} 219 newCmds = append(newCmds, m.connectCmd(wf)) 220 } 221 } 222 // re-subscribe for the next update 223 newCmds = append(newCmds, m.waitForStatusUpdate(m.statusCh)) 224 return m, tea.Batch(newCmds...) 225 226 case statusUpdateErrMsg: 227 // re-subscribe even on error so we don't stop listening 228 return m, m.waitForStatusUpdate(m.statusCh) 229 } 230 231 return m, nil 232} 233 234func (m *pipelineModel) selectedLogs() *workflowLogs { 235 if len(m.workflows) == 0 { 236 return nil 237 } 238 return m.logs[m.workflows[m.selected]] 239} 240 241func (m *pipelineModel) initViewport(wl *workflowLogs) { 242 if wl.ready { 243 return 244 } 245 wl.vp = viewport.New(m.width, m.vpHeight()) 246 wl.ready = true 247} 248 249// handleLogEvent processes a single log event, updates the step state, and re-renders the viewport. 250func (m *pipelineModel) handleLogEvent(msg logEventMsg) tea.Cmd { 251 wl, ok := m.logs[msg.workflow] 252 if !ok { 253 return nil 254 } 255 if msg.ev.Err != nil { 256 wl.done = true 257 if !msg.ev.IsCloseError() { 258 wl.err = msg.ev.Err 259 } 260 m.initViewport(wl) 261 wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 262 return nil 263 } 264 var line spindlemodel.LogLine 265 if err := json.Unmarshal(msg.ev.Msg, &line); err != nil { 266 return readNextCmd(msg.workflow, msg.conn, msg.ch) 267 } 268 applyLogLine(wl, line) 269 m.initViewport(wl) 270 atBottom := wl.vp.AtBottom() 271 wl.vp.SetContent(renderLogs(m.renderer, wl, m.width)) 272 if atBottom { 273 wl.vp.GotoBottom() 274 } 275 return readNextCmd(msg.workflow, msg.conn, msg.ch) 276} 277 278// applyLogLine mutates wl by appending the log line to the appropriate step. 279func applyLogLine(wl *workflowLogs, line spindlemodel.LogLine) { 280 switch line.Kind { 281 case spindlemodel.LogKindControl: 282 switch line.StepStatus { 283 case spindlemodel.StepStatusStart: 284 idx := len(wl.steps) 285 wl.stepIndex[line.StepId] = idx 286 wl.steps = append(wl.steps, step{ 287 id: line.StepId, name: line.Content, command: line.StepCommand, 288 kind: line.StepKind, startTime: line.Time, 289 }) 290 case spindlemodel.StepStatusEnd: 291 if idx, ok := wl.stepIndex[line.StepId]; ok { 292 wl.steps[idx].endTime, wl.steps[idx].finished = line.Time, true 293 } 294 } 295 case spindlemodel.LogKindData: 296 if idx, ok := wl.stepIndex[line.StepId]; ok { 297 wl.steps[idx].lines = append(wl.steps[idx].lines, line.Content) 298 } 299 } 300} 301 302// renderLogs builds the full log content string for a workflow, used as viewport content. 303func renderLogs(r *lipgloss.Renderer, wl *workflowLogs, width int) string { 304 headerStyle := r.NewStyle().Foreground(colorWhite).Background(colorBrightBlack).Bold(true) 305 cmdStyle := r.NewStyle().Foreground(colorBlue).Background(colorDarkGrey).Width(width) 306 lineStyle := r.NewStyle().Foreground(colorWhite).Background(colorDarkGrey).Width(width) 307 now := time.Now() 308 var sb strings.Builder 309 for i := range wl.steps { 310 st := &wl.steps[i] 311 dur := "" 312 if st.finished { 313 dur = st.endTime.Sub(st.startTime).Round(time.Millisecond).String() 314 } else if !st.startTime.IsZero() { 315 dur = now.Sub(st.startTime).Round(time.Second).String() 316 } 317 durRendered := headerStyle.Render(dur) 318 nameWidth := max(width-lipgloss.Width(dur)-1, 1) 319 header := fmt.Sprintf("%-*s ", nameWidth, st.name) + durRendered 320 sb.WriteString(headerStyle.Width(width).Render(header) + "\n") 321 if st.command != "" { 322 sb.WriteString(cmdStyle.Render(st.command) + "\n") 323 } 324 for _, l := range st.lines { 325 sb.WriteString(lineStyle.Render(l) + "\n") 326 } 327 sb.WriteString("\n") 328 } 329 if wl.done && wl.err != nil { 330 sb.WriteString("error: " + wl.err.Error() + "\n") 331 } 332 return sb.String() 333} 334 335func (m *pipelineModel) View() string { 336 r := m.renderer 337 divider := r.NewStyle().Foreground(colorBrightBlack).Render(strings.Repeat("─", m.width)) 338 body := "" 339 if wl := m.selectedLogs(); wl != nil && wl.ready { 340 body = wl.vp.View() 341 } 342 return lipgloss.JoinVertical(lipgloss.Left, m.topbarView(), divider, body) 343} 344 345// topbarView renders the single-line tab bar with workflow tabs left and trigger info + help right. 346func (m *pipelineModel) topbarView() string { 347 r := m.renderer 348 activeStyle := r.NewStyle().Background(colorBlue).Foreground(colorWhite) 349 350 now := time.Now() 351 352 var tabs strings.Builder 353 for i, wf := range m.workflows { 354 status := spindlemodel.StatusKindPending 355 elapsed := "" 356 if ws, ok := m.pipeline.Statuses[wf]; ok { 357 latest := ws.Latest() 358 status = latest.Status 359 if t := ws.TimeTaken(); t > 0 { 360 elapsed = t.Round(time.Second).String() 361 } else { 362 elapsed = now.Sub(latest.Created).Round(time.Second).String() 363 } 364 } 365 dim := r.NewStyle().Faint(true) 366 base := " " + statusIcon(status, m.spinner.View()) + " " + wf 367 if i == m.selected { 368 tab := base 369 if elapsed != "" { 370 tab += " " + elapsed 371 } 372 tab += " " 373 tabs.WriteString(activeStyle.Render(tab)) 374 } else { 375 tabs.WriteString(base) 376 if elapsed != "" { 377 tabs.WriteString(" " + dim.Render(elapsed)) 378 } 379 tabs.WriteString(" ") 380 } 381 } 382 383 tabsStr := tabs.String() 384 infoStr := triggerLine(r, m.pipeline.Trigger, m.pipeline.Sha) + " · " + helpText(r) 385 386 gap := max(m.width-lipgloss.Width(tabsStr)-lipgloss.Width(infoStr), 1) 387 388 return tabsStr + strings.Repeat(" ", gap) + infoStr 389} 390 391func helpText(r *lipgloss.Renderer) string { 392 key := r.NewStyle().Foreground(colorWhite) 393 action := r.NewStyle().Faint(true) 394 sep := action.Render(" · ") 395 396 items := []string{ 397 key.Render("←/→") + " " + action.Render("switch"), 398 key.Render("↑/↓") + " " + action.Render("scroll"), 399 key.Render("q") + " " + action.Render("quit"), 400 } 401 return strings.Join(items, sep) 402} 403 404func shortSha(sha string) string { 405 if len(sha) >= 8 { 406 return sha[:8] 407 } 408 return sha 409} 410 411func triggerLine(r *lipgloss.Renderer, t *models.Trigger, sha string) string { 412 hash := shortSha(sha) 413 dim := r.NewStyle().Faint(true) 414 if t == nil { 415 return dim.Render(hash) 416 } 417 if t.IsPush() { 418 return t.TargetRef() + dim.Render("@"+hash) + dim.Render(" (push)") 419 } 420 if t.IsPullRequest() { 421 source := "" 422 if t.PRSourceBranch != nil { 423 source = *t.PRSourceBranch 424 } 425 return t.TargetRef() + dim.Render(" <- "+source+"@"+hash) + dim.Render(" (pull-request)") 426 } 427 return dim.Render(hash) 428} 429 430func statusIcon(status spindlemodel.StatusKind, spinnerFrame string) string { 431 switch status { 432 case spindlemodel.StatusKindSuccess: 433 return "✓" 434 case spindlemodel.StatusKindFailed: 435 return "×" 436 case spindlemodel.StatusKindRunning: 437 return spinnerFrame 438 case spindlemodel.StatusKindPending: 439 return "·" 440 case spindlemodel.StatusKindTimeout: 441 return "⌀" 442 case spindlemodel.StatusKindCancelled: 443 return "-" 444 default: 445 return "?" 446 } 447}