forked from tangled.org/core
Monorepo for Tangled — https://tangled.org

appview,spindle: group logs based on step

spindle logs now inform the beginning of a new step using a "control"
message, with logs categorized under a "data" message.

the appview in turn, is able to group logs by step:

- upon encountering a control message, it begins a new step (a
collapsible details tag)
- upon encontering a data message, it adds the log line into the last
encountered step

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 08ab8698 80373ac0

verified
Changed files
+68 -34
appview
pages
templates
pipelines
repo
spindle
engine
models
+1 -2
appview/pages/templates/repo/settings.html
···
<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">
<option
value=""
-
disabled
selected
>
-
Choose a spindle
+
None
</option>
{{ range .Spindles }}
<option
+25 -6
appview/pipelines/pipelines.go
···
"context"
"encoding/json"
"fmt"
+
"html"
"log/slog"
"net/http"
"strings"
···
}
}()
+
stepIdx := 0
for {
select {
case <-ctx.Done():
···
continue
}
-
html := fmt.Appendf(nil, `
-
<div id="lines" hx-swap-oob="beforeend">
-
<p>%s: %s</p>
-
</div>
-
`, logLine.Stream, logLine.Data)
+
var fragment []byte
+
switch logLine.Kind {
+
case spindlemodel.LogKindControl:
+
// control messages create a new step block
+
stepIdx++
+
fragment = fmt.Appendf(nil, `
+
<div id="lines" hx-swap-oob="beforeend">
+
<details id="step-%d" open>
+
<summary>%s</summary>
+
<div id="step-body-%d"></div>
+
</details>
+
</div>
+
`, stepIdx, logLine.Content, stepIdx)
+
case spindlemodel.LogKindData:
+
// data messages simply insert new log lines into current step
+
escaped := html.EscapeString(logLine.Content)
+
fragment = fmt.Appendf(nil, `
+
<div id="step-body-%d" hx-swap-oob="beforeend">
+
<p>%s</p>
+
</div>
+
`, stepIdx, escaped)
+
}
-
if err = clientConn.WriteMessage(websocket.TextMessage, html); err != nil {
+
if err = clientConn.WriteMessage(websocket.TextMessage, fragment); err != nil {
l.Error("error writing to client", "err", err)
return
}
+1
appview/repo/repo.go
···
Owner: user.Did,
CreatedAt: f.CreatedAt,
Description: &newDescription,
+
Spindle: &f.Spindle,
},
},
})
+14 -11
spindle/engine/engine.go
···
// start tailing logs in background
tailDone := make(chan error, 1)
go func() {
-
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx)
+
tailDone <- e.TailStep(ctx, resp.ID, wid, stepIdx, step)
}()
// wait for container completion or timeout
···
return info.State, nil
}
-
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int) error {
+
func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId, stepIdx int, step models.Step) error {
+
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
+
if err != nil {
+
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
+
return err
+
}
+
defer wfLogger.Close()
+
+
ctl := wfLogger.ControlWriter()
+
ctl.Write([]byte(step.Command))
+
logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{
Follow: true,
ShowStdout: true,
···
return err
}
-
wfLogger, err := NewWorkflowLogger(e.cfg.Pipelines.LogDir, wid)
-
if err != nil {
-
e.l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
-
return err
-
}
-
defer wfLogger.Close()
-
_, err = stdcopy.StdCopy(
-
wfLogger.Writer("stdout", stepIdx),
-
wfLogger.Writer("stderr", stepIdx),
+
wfLogger.DataWriter("stdout"),
+
wfLogger.DataWriter("stderr"),
logs,
)
if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) {
+11 -12
spindle/engine/logger.go
···
}, nil
}
-
func (l *WorkflowLogger) Write(p []byte) (n int, err error) {
-
return l.file.Write(p)
+
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
+
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
+
return logFilePath
}
func (l *WorkflowLogger) Close() error {
return l.file.Close()
}
-
func LogFilePath(baseDir string, workflowID models.WorkflowId) string {
-
logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String()))
-
return logFilePath
+
func (l *WorkflowLogger) DataWriter(stream string) io.Writer {
+
// TODO: emit stream
+
return &jsonWriter{logger: l, kind: models.LogKindData}
}
-
func (l *WorkflowLogger) Writer(stream string, stepId int) io.Writer {
-
return &jsonWriter{logger: l, stream: stream, stepId: stepId}
+
func (l *WorkflowLogger) ControlWriter() io.Writer {
+
return &jsonWriter{logger: l, kind: models.LogKindControl}
}
type jsonWriter struct {
logger *WorkflowLogger
-
stream string
-
stepId int
+
kind models.LogKind
}
func (w *jsonWriter) Write(p []byte) (int, error) {
line := strings.TrimRight(string(p), "\r\n")
entry := models.LogLine{
-
Stream: w.stream,
-
Data: line,
-
StepId: w.stepId,
+
Kind: w.kind,
+
Content: line,
}
if err := w.logger.encoder.Encode(entry); err != nil {
+16 -3
spindle/models/models.go
···
return slices.Contains(FinishStates[:], s)
}
+
type LogKind string
+
+
var (
+
// step log data
+
LogKindData LogKind = "data"
+
// indicates start/end of a step
+
LogKindControl LogKind = "control"
+
)
+
type LogLine struct {
-
Stream string `json:"s"`
-
Data string `json:"d"`
-
StepId int `json:"i"`
+
Kind LogKind `json:"kind"`
+
Content string `json:"content"`
+
+
// fields if kind is "data"
+
Stream string `json:"stream,omitempty"`
+
+
// fields if kind is "control"
}