This repository has no description
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}