package console // The Logs tab's fold: applog.Records in, terminal lines out. // // The console's Logs tab and `carlos logs` are two windows onto the same // records, so they print the same line — logs.go's printLogRecords is the // format this mirrors: UTC time to the second, the stream in brackets, the // route host, two spaces, the message, then any structured fields as sorted // k=v pairs. The one thing the pane adds is COLUMN ALIGNMENT: stream and // host are padded to the widest in the window (the way `docker compose // logs` does it), which costs nothing when every line comes from the same // instance and keeps the messages in one column when they don't. // // Level never becomes text — the CLI doesn't print the word "error" either, // it colours the message. Tone is that colour, decided here so the template // carries no severity logic of its own. import ( "sort" "strings" "unicode/utf8" "github.com/carlosframework/platform/internal/applog" ) // logLineTimeFormat is printLogRecords' stamp, to the second: enough to // order a busy window by eye, short enough that it never crowds the message. const logLineTimeFormat = "2006-01-02 15:04:05" // logLine is one rendered line of the pane. Every field is finished text — // padding included — so the template is spans and nothing else. type logLine struct { Time string // UTC, to the second Stream string // "[app]", padded to the window's widest tag Host string // padded to the window's widest host; "" when no row has one Msg string // the record's own message, verbatim Fields string // " k=v k=v", sorted; "" when the record carried none Tone string // "", "warn" or "error" — the class the message wears } // logLines folds a window of records into the pane's lines. Order is the // caller's (oldest first, newest at the foot, as a terminal scrolls). func logLines(recs []applog.Record) []logLine { streamW, hostW := 0, 0 for _, r := range recs { if n := utf8.RuneCountInString(r.Stream) + 2; n > streamW { // + the brackets streamW = n } if n := utf8.RuneCountInString(r.Host); n > hostW { hostW = n } } out := make([]logLine, 0, len(recs)) for _, r := range recs { l := logLine{ Time: r.TS.UTC().Format(logLineTimeFormat), Stream: pad("["+r.Stream+"]", streamW), Msg: r.Msg, } // A window with no hosts at all renders no host column, rather // than a run of padding spaces in front of every message. if hostW > 0 { l.Host = pad(r.Host, hostW) } switch r.Level { case applog.LevelError: l.Tone = "error" case applog.LevelWarn: l.Tone = "warn" } if len(r.Fields) > 0 { keys := make([]string, 0, len(r.Fields)) for k := range r.Fields { keys = append(keys, k) } sort.Strings(keys) var b strings.Builder for _, k := range keys { b.WriteString(" " + k + "=" + r.Fields[k]) } l.Fields = b.String() } out = append(out, l) } return out } // pad right-pads s with spaces to w runes. The pane is monospaced, so a // space is a column. func pad(s string, w int) string { if n := w - utf8.RuneCountInString(s); n > 0 { return s + strings.Repeat(" ", n) } return s }