+20
appview/pages/pages.go
+20
appview/pages/pages.go
···
974
974
return p.executeRepo("repo/pipelines/pipelines", w, params)
975
975
}
976
976
977
+
type LogBlockParams struct {
978
+
Id int
979
+
Name string
980
+
Command string
981
+
Collapsed bool
982
+
}
983
+
984
+
func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error {
985
+
return p.executePlain("repo/pipelines/fragments/logBlock", w, params)
986
+
}
987
+
988
+
type LogLineParams struct {
989
+
Id int
990
+
Content string
991
+
}
992
+
993
+
func (p *Pages) LogLine(w io.Writer, params LogLineParams) error {
994
+
return p.executePlain("repo/pipelines/fragments/logLine", w, params)
995
+
}
996
+
977
997
type WorkflowParams struct {
978
998
LoggedInUser *oauth.User
979
999
RepoInfo repoinfo.RepoInfo
+16
appview/pages/templates/repo/pipelines/fragments/logBlock.html
+16
appview/pages/templates/repo/pipelines/fragments/logBlock.html
···
1
+
{{ define "repo/pipelines/fragments/logBlock" }}
2
+
<div id="lines" hx-swap-oob="beforeend">
3
+
<details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group bg-gray-100 px-2 dark:bg-gray-900">
4
+
<summary class="sticky top-0 py-1 list-none cursor-pointer py-2 bg-gray-100 dark:bg-gray-900 hover:text-gray-500 hover:dark:text-gray-400">
5
+
<div class="group-open:hidden flex items-center gap-1">
6
+
{{ i "chevron-right" "w-4 h-4" }} {{ .Name }}
7
+
</div>
8
+
<div class="hidden group-open:flex items-center gap-1">
9
+
{{ i "chevron-down" "w-4 h-4" }} {{ .Name }}
10
+
</div>
11
+
</summary>
12
+
<div class="text-blue-600 dark:text-blue-300 font-mono">{{ .Command }}</div>
13
+
<div id="step-body-{{ .Id }}" class="font-mono"></div>
14
+
</details>
15
+
</div>
16
+
{{ end }}
+6
appview/pages/templates/repo/pipelines/fragments/logLine.html
+6
appview/pages/templates/repo/pipelines/fragments/logLine.html
+5
-13
appview/pages/templates/repo/pipelines/pipelines.html
+5
-13
appview/pages/templates/repo/pipelines/pipelines.html
···
8
8
9
9
{{ define "repoContent" }}
10
10
<div class="flex justify-between items-center gap-4">
11
-
<div class="flex gap-4">
12
-
</div>
13
-
14
-
</div>
15
-
<div class="error" id="issues"></div>
16
-
{{ end }}
17
-
18
-
{{ define "repoAfter" }}
19
-
<section
20
-
class="w-full flex flex-col gap-2 mt-2"
21
-
>
11
+
<div class="w-full flex flex-col gap-2">
22
12
{{ range .Pipelines }}
23
13
{{ block "pipeline" (list $ .) }} {{ end }}
24
14
{{ else }}
···
26
16
No pipelines run for this repository.
27
17
</p>
28
18
{{ end }}
29
-
</section>
19
+
</div>
20
+
</div>
30
21
{{ end }}
22
+
31
23
32
24
{{ define "pipeline" }}
33
25
{{ $root := index . 0 }}
34
26
{{ $p := index . 1 }}
35
-
<div class="py-4 px-6 bg-white dark:bg-gray-800 dark:text-white">
27
+
<div class="py-2 bg-white dark:bg-gray-800 dark:text-white">
36
28
{{ block "pipelineHeader" $ }} {{ end }}
37
29
</div>
38
30
{{ end }}
+3
-5
appview/pages/templates/repo/pipelines/workflow.html
+3
-5
appview/pages/templates/repo/pipelines/workflow.html
···
24
24
{{ $active := .Workflow }}
25
25
{{ with .Pipeline }}
26
26
{{ $id := .Id }}
27
-
<div class="grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
27
+
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
28
28
{{ range $name, $all := .Statuses }}
29
29
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
30
30
<div
···
58
58
59
59
{{ define "logs" }}
60
60
<div id="log-stream"
61
-
class="p-2 bg-gray-100 dark:bg-gray-900 font-mono text-sm min-h-96 max-h-screen overflow-auto flex flex-col-reverse [overflow-anchor:auto_!important]"
61
+
class="text-sm"
62
62
hx-ext="ws"
63
63
ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs">
64
-
<div id="lines">
65
-
<!-- Each log line should be rendered with class="item" like below -->
66
-
<!-- <div class="item">[INFO] Log line here</div> -->
64
+
<div id="lines" class="flex flex-col gap-2">
67
65
</div>
68
66
</div>
69
67
{{ end }}
+72
-58
appview/pipelines/pipelines.go
+72
-58
appview/pipelines/pipelines.go
···
1
1
package pipelines
2
2
3
3
import (
4
+
"bytes"
4
5
"context"
5
6
"encoding/json"
6
-
"fmt"
7
-
"html"
8
7
"log/slog"
9
8
"net/http"
10
9
"strings"
···
170
169
171
170
ctx, cancel := context.WithCancel(r.Context())
172
171
defer cancel()
173
-
go func() {
174
-
for {
175
-
if _, _, err := clientConn.NextReader(); err != nil {
176
-
l.Error("failed to read", "err", err)
177
-
cancel()
178
-
return
179
-
}
180
-
}
181
-
}()
182
172
183
173
user := p.oauth.GetUser(r)
184
174
f, err := p.repoResolver.Resolve(r)
···
238
228
defer spindleConn.Close()
239
229
240
230
// create a channel for incoming messages
241
-
msgChan := make(chan []byte, 10)
242
-
errChan := make(chan error, 1)
243
-
231
+
evChan := make(chan logEvent, 100)
244
232
// start a goroutine to read from spindle
245
-
go func() {
246
-
defer close(msgChan)
247
-
defer close(errChan)
248
-
249
-
for {
250
-
_, msg, err := spindleConn.ReadMessage()
251
-
if err != nil {
252
-
if websocket.IsCloseError(err,
253
-
websocket.CloseNormalClosure,
254
-
websocket.CloseGoingAway,
255
-
websocket.CloseAbnormalClosure) {
256
-
errChan <- nil // signal graceful end
257
-
} else {
258
-
errChan <- err
259
-
}
260
-
return
261
-
}
262
-
msgChan <- msg
263
-
}
264
-
}()
233
+
go readLogs(spindleConn, evChan)
265
234
266
235
stepIdx := 0
236
+
var fragment bytes.Buffer
267
237
for {
268
238
select {
269
239
case <-ctx.Done():
270
240
l.Info("client disconnected")
271
241
return
272
-
case err := <-errChan:
273
-
if err != nil {
274
-
l.Error("error reading from spindle", "err", err)
242
+
243
+
case ev, ok := <-evChan:
244
+
if !ok {
245
+
continue
275
246
}
276
247
277
-
if err == nil {
278
-
l.Info("log tail complete")
248
+
if ev.err != nil && ev.isCloseError() {
249
+
l.Debug("graceful shutdown, tail complete", "err", err)
250
+
return
251
+
}
252
+
if ev.err != nil {
253
+
l.Error("error reading from spindle", "err", err)
254
+
return
279
255
}
280
256
281
-
return
282
-
case msg := <-msgChan:
283
257
var logLine spindlemodel.LogLine
284
-
if err = json.Unmarshal(msg, &logLine); err != nil {
258
+
if err = json.Unmarshal(ev.msg, &logLine); err != nil {
285
259
l.Error("failed to parse logline", "err", err)
286
260
continue
287
261
}
288
262
289
-
var fragment []byte
263
+
fragment.Reset()
264
+
290
265
switch logLine.Kind {
291
266
case spindlemodel.LogKindControl:
292
267
// control messages create a new step block
293
268
stepIdx++
294
-
fragment = fmt.Appendf(nil, `
295
-
<div id="lines" hx-swap-oob="beforeend">
296
-
<details id="step-%d" open>
297
-
<summary>%s</summary>
298
-
<div id="step-body-%d"></div>
299
-
</details>
300
-
</div>
301
-
`, stepIdx, logLine.Content, stepIdx)
269
+
collapsed := false
270
+
if logLine.StepKind == spindlemodel.StepKindSystem {
271
+
collapsed = true
272
+
}
273
+
err = p.pages.LogBlock(&fragment, pages.LogBlockParams{
274
+
Id: stepIdx,
275
+
Name: logLine.Content,
276
+
Command: logLine.StepCommand,
277
+
Collapsed: collapsed,
278
+
})
302
279
case spindlemodel.LogKindData:
303
280
// data messages simply insert new log lines into current step
304
-
escaped := html.EscapeString(logLine.Content)
305
-
fragment = fmt.Appendf(nil, `
306
-
<div id="step-body-%d" hx-swap-oob="beforeend">
307
-
<p>%s</p>
308
-
</div>
309
-
`, stepIdx, escaped)
281
+
err = p.pages.LogLine(&fragment, pages.LogLineParams{
282
+
Id: stepIdx,
283
+
Content: logLine.Content,
284
+
})
285
+
}
286
+
if err != nil {
287
+
l.Error("failed to render log line", "err", err)
288
+
return
310
289
}
311
290
312
-
if err = clientConn.WriteMessage(websocket.TextMessage, fragment); err != nil {
291
+
if err = clientConn.WriteMessage(websocket.TextMessage, fragment.Bytes()); err != nil {
313
292
l.Error("error writing to client", "err", err)
314
293
return
315
294
}
295
+
316
296
case <-time.After(30 * time.Second):
317
297
l.Debug("sent keepalive")
318
298
if err = clientConn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil {
319
299
l.Error("failed to write control", "err", err)
300
+
return
320
301
}
321
302
}
322
303
}
323
304
}
305
+
306
+
// either a message or an error
307
+
type logEvent struct {
308
+
msg []byte
309
+
err error
310
+
}
311
+
312
+
func (ev *logEvent) isCloseError() bool {
313
+
return websocket.IsCloseError(
314
+
ev.err,
315
+
websocket.CloseNormalClosure,
316
+
websocket.CloseGoingAway,
317
+
websocket.CloseAbnormalClosure,
318
+
)
319
+
}
320
+
321
+
// read logs from spindle and pass through to chan
322
+
func readLogs(conn *websocket.Conn, ch chan logEvent) {
323
+
defer close(ch)
324
+
325
+
for {
326
+
if conn == nil {
327
+
return
328
+
}
329
+
330
+
_, msg, err := conn.ReadMessage()
331
+
if err != nil {
332
+
ch <- logEvent{err: err}
333
+
return
334
+
}
335
+
ch <- logEvent{msg: msg}
336
+
}
337
+
}
+2
-2
spindle/engine/engine.go
+2
-2
spindle/engine/engine.go
···
315
315
}
316
316
defer wfLogger.Close()
317
317
318
-
ctl := wfLogger.ControlWriter()
319
-
ctl.Write([]byte(step.Command))
318
+
ctl := wfLogger.ControlWriter(stepIdx, step)
319
+
ctl.Write([]byte(step.Name))
320
320
321
321
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
322
322
Follow: true,
+27
-12
spindle/engine/logger.go
+27
-12
spindle/engine/logger.go
···
41
41
42
42
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
43
43
// TODO: emit stream
44
-
return &jsonWriter{logger: l, kind: models.LogKindData}
44
+
return &dataWriter{
45
+
logger: l,
46
+
stream: stream,
47
+
}
45
48
}
46
49
47
-
func (l *WorkflowLogger) ControlWriter() io.Writer {
48
-
return &jsonWriter{logger: l, kind: models.LogKindControl}
50
+
func (l *WorkflowLogger) ControlWriter(idx int, step models.Step) io.Writer {
51
+
return &controlWriter{
52
+
logger: l,
53
+
idx: idx,
54
+
step: step,
55
+
}
49
56
}
50
57
51
-
type jsonWriter struct {
58
+
type dataWriter struct {
52
59
logger *WorkflowLogger
53
-
kind models.LogKind
60
+
stream string
54
61
}
55
62
56
-
func (w *jsonWriter) Write(p []byte) (int, error) {
63
+
func (w *dataWriter) Write(p []byte) (int, error) {
57
64
line := strings.TrimRight(string(p), "\r\n")
65
+
entry := models.NewDataLogLine(line, w.stream)
66
+
if err := w.logger.encoder.Encode(entry); err != nil {
67
+
return 0, err
68
+
}
69
+
return len(p), nil
70
+
}
58
71
59
-
entry := models.LogLine{
60
-
Kind: w.kind,
61
-
Content: line,
62
-
}
72
+
type controlWriter struct {
73
+
logger *WorkflowLogger
74
+
idx int
75
+
step models.Step
76
+
}
63
77
78
+
func (w *controlWriter) Write(_ []byte) (int, error) {
79
+
entry := models.NewControlLogLine(w.idx, w.step)
64
80
if err := w.logger.encoder.Encode(entry); err != nil {
65
81
return 0, err
66
82
}
67
-
68
-
return len(p), nil
83
+
return len(w.step.Name), nil
69
84
}
+21
spindle/models/models.go
+21
spindle/models/models.go
···
88
88
Stream string `json:"stream,omitempty"`
89
89
90
90
// fields if kind is "control"
91
+
StepId int `json:"step_id,omitempty"`
92
+
StepKind StepKind `json:"step_kind,omitempty"`
93
+
StepCommand string `json:"step_command,omitempty"`
94
+
}
95
+
96
+
func NewDataLogLine(content, stream string) LogLine {
97
+
return LogLine{
98
+
Kind: LogKindData,
99
+
Content: content,
100
+
Stream: stream,
101
+
}
102
+
}
103
+
104
+
func NewControlLogLine(idx int, step Step) LogLine {
105
+
return LogLine{
106
+
Kind: LogKindControl,
107
+
Content: step.Name,
108
+
StepId: idx,
109
+
StepKind: step.Kind,
110
+
StepCommand: step.Command,
111
+
}
91
112
}
+15
-1
spindle/models/pipeline.go
+15
-1
spindle/models/pipeline.go
···
15
15
Command string
16
16
Name string
17
17
Environment map[string]string
18
+
Kind StepKind
18
19
}
20
+
21
+
type StepKind int
22
+
23
+
const (
24
+
// steps injected by the CI runner
25
+
StepKindSystem StepKind = iota
26
+
// steps defined by the user in the original pipeline
27
+
StepKindUser
28
+
)
19
29
20
30
type Workflow struct {
21
31
Steps []Step
···
46
56
sstep.Environment = stepEnvToMap(tstep.Environment)
47
57
sstep.Command = tstep.Command
48
58
sstep.Name = tstep.Name
59
+
sstep.Kind = StepKindUser
49
60
swf.Steps = append(swf.Steps, sstep)
50
61
}
51
62
swf.Name = twf.Name
···
59
70
setup.addStep(nixConfStep())
60
71
setup.addStep(cloneStep(*twf, *pl.TriggerMetadata.Repo, cfg.Server.Dev))
61
72
setup.addStep(checkoutStep(*twf, *pl.TriggerMetadata))
62
-
setup.addStep(dependencyStep(*twf))
73
+
// this step could be empty
74
+
if s := dependencyStep(*twf); s != nil {
75
+
setup.addStep(*s)
76
+
}
63
77
64
78
// append setup steps in order to the start of workflow steps
65
79
swf.Steps = append(*setup, swf.Steps...)
+3
-3
spindle/models/setup_steps.go
+3
-3
spindle/models/setup_steps.go
···
83
83
// For dependencies using a custom registry (i.e. not nixpkgs), it collects
84
84
// all packages and adds a single 'nix profile install' step to the
85
85
// beginning of the workflow's step list.
86
-
func dependencyStep(twf tangled.Pipeline_Workflow) Step {
86
+
func dependencyStep(twf tangled.Pipeline_Workflow) *Step {
87
87
var customPackages []string
88
88
89
89
for _, d := range twf.Dependencies {
···
111
111
"NIX_SHOW_DOWNLOAD_PROGRESS": "0",
112
112
},
113
113
}
114
-
return installStep
114
+
return &installStep
115
115
}
116
-
return Step{}
116
+
return nil
117
117
}