+1
-2
appview/pages/templates/repo/settings.html
+1
-2
appview/pages/templates/repo/settings.html
···
91
91
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
92
92
<option
93
93
value=""
94
-
disabled
95
94
selected
96
95
>
97
-
Choose a spindle
96
+
None
98
97
</option>
99
98
{{ range .Spindles }}
100
99
<option
+25
-6
appview/pipelines/pipelines.go
+25
-6
appview/pipelines/pipelines.go
···
4
4
"context"
5
5
"encoding/json"
6
6
"fmt"
7
+
"html"
7
8
"log/slog"
8
9
"net/http"
9
10
"strings"
···
262
263
}
263
264
}()
264
265
266
+
stepIdx := 0
265
267
for {
266
268
select {
267
269
case <-ctx.Done():
···
284
286
continue
285
287
}
286
288
287
-
html := fmt.Appendf(nil, `
288
-
<div id="lines" hx-swap-oob="beforeend">
289
-
<p>%s: %s</p>
290
-
</div>
291
-
`, logLine.Stream, logLine.Data)
289
+
var fragment []byte
290
+
switch logLine.Kind {
291
+
case spindlemodel.LogKindControl:
292
+
// control messages create a new step block
293
+
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)
302
+
case spindlemodel.LogKindData:
303
+
// 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)
310
+
}
292
311
293
-
if err = clientConn.WriteMessage(websocket.TextMessage, html); err != nil {
312
+
if err = clientConn.WriteMessage(websocket.TextMessage, fragment); err != nil {
294
313
l.Error("error writing to client", "err", err)
295
314
return
296
315
}
+1
appview/repo/repo.go
+1
appview/repo/repo.go
+14
-11
spindle/engine/engine.go
+14
-11
spindle/engine/engine.go
···
227
227
// start tailing logs in background
228
228
tailDone := make(chan error, 1)
229
229
go func() {
230
-
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx)
230
+
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step)
231
231
}()
232
232
233
233
// wait for container completion or timeout
···
307
307
return info.State, nil
308
308
}
309
309
310
-
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int) error {
310
+
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
311
+
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
312
+
if err != nil {
313
+
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
314
+
return err
315
+
}
316
+
defer wfLogger.Close()
317
+
318
+
ctl := wfLogger.ControlWriter()
319
+
ctl.Write([]byte(step.Command))
320
+
311
321
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
312
322
Follow: true,
313
323
ShowStdout: true,
···
319
329
return err
320
330
}
321
331
322
-
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
323
-
if err != nil {
324
-
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
325
-
return err
326
-
}
327
-
defer wfLogger.Close()
328
-
329
332
_, err = stdcopy.StdCopy(
330
-
wfLogger.Writer("stdout", stepIdx),
331
-
wfLogger.Writer("stderr", stepIdx),
333
+
wfLogger.DataWriter("stdout"),
334
+
wfLogger.DataWriter("stderr"),
332
335
logs,
333
336
)
334
337
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+11
-12
spindle/engine/logger.go
+11
-12
spindle/engine/logger.go
···
30
30
}, nil
31
31
}
32
32
33
-
func (l *WorkflowLogger) Write(p []byte) (n int, err error) {
34
-
return l.file.Write(p)
33
+
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
34
+
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
35
+
return logFilePath
35
36
}
36
37
37
38
func (l *WorkflowLogger) Close() error {
38
39
return l.file.Close()
39
40
}
40
41
41
-
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
42
-
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
43
-
return logFilePath
42
+
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
43
+
// TODO: emit stream
44
+
return &jsonWriter{logger: l, kind: models.LogKindData}
44
45
}
45
46
46
-
func (l *WorkflowLogger) Writer(stream string, stepId int) io.Writer {
47
-
return &jsonWriter{logger: l, stream: stream, stepId: stepId}
47
+
func (l *WorkflowLogger) ControlWriter() io.Writer {
48
+
return &jsonWriter{logger: l, kind: models.LogKindControl}
48
49
}
49
50
50
51
type jsonWriter struct {
51
52
logger *WorkflowLogger
52
-
stream string
53
-
stepId int
53
+
kind models.LogKind
54
54
}
55
55
56
56
func (w *jsonWriter) Write(p []byte) (int, error) {
57
57
line := strings.TrimRight(string(p), "\r\n")
58
58
59
59
entry := models.LogLine{
60
-
Stream: w.stream,
61
-
Data: line,
62
-
StepId: w.stepId,
60
+
Kind: w.kind,
61
+
Content: line,
63
62
}
64
63
65
64
if err := w.logger.encoder.Encode(entry); err != nil {
+16
-3
spindle/models/models.go
+16
-3
spindle/models/models.go
···
71
71
return slices.Contains(FinishStates[:], s)
72
72
}
73
73
74
+
type LogKind string
75
+
76
+
var (
77
+
// step log data
78
+
LogKindData LogKind = "data"
79
+
// indicates start/end of a step
80
+
LogKindControl LogKind = "control"
81
+
)
82
+
74
83
type LogLine struct {
75
-
Stream string `json:"s"`
76
-
Data string `json:"d"`
77
-
StepId int `json:"i"`
84
+
Kind LogKind `json:"kind"`
85
+
Content string `json:"content"`
86
+
87
+
// fields if kind is "data"
88
+
Stream string `json:"stream,omitempty"`
89
+
90
+
// fields if kind is "control"
78
91
}