| 1 | package console |
| 2 | |
| 3 | // The Logs tab's fold: applog.Records in, terminal lines out. |
| 4 | // |
| 5 | // The console's Logs tab and `carlos logs` are two windows onto the same |
| 6 | // records, so they print the same line — logs.go's printLogRecords is the |
| 7 | // format this mirrors: UTC time to the second, the stream in brackets, the |
| 8 | // route host, two spaces, the message, then any structured fields as sorted |
| 9 | // k=v pairs. The one thing the pane adds is COLUMN ALIGNMENT: stream and |
| 10 | // host are padded to the widest in the window (the way `docker compose |
| 11 | // logs` does it), which costs nothing when every line comes from the same |
| 12 | // instance and keeps the messages in one column when they don't. |
| 13 | // |
| 14 | // Level never becomes text — the CLI doesn't print the word "error" either, |
| 15 | // it colours the message. Tone is that colour, decided here so the template |
| 16 | // carries no severity logic of its own. |
| 17 | |
| 18 | import ( |
| 19 | "sort" |
| 20 | "strings" |
| 21 | "unicode/utf8" |
| 22 | |
| 23 | "github.com/carlosframework/platform/internal/applog" |
| 24 | ) |
| 25 | |
| 26 | // logLineTimeFormat is printLogRecords' stamp, to the second: enough to |
| 27 | // order a busy window by eye, short enough that it never crowds the message. |
| 28 | const logLineTimeFormat = "2006-01-02 15:04:05" |
| 29 | |
| 30 | // logLine is one rendered line of the pane. Every field is finished text — |
| 31 | // padding included — so the template is spans and nothing else. |
| 32 | type logLine struct { |
| 33 | Time string // UTC, to the second |
| 34 | Stream string // "[app]", padded to the window's widest tag |
| 35 | Host string // padded to the window's widest host; "" when no row has one |
| 36 | Msg string // the record's own message, verbatim |
| 37 | Fields string // " k=v k=v", sorted; "" when the record carried none |
| 38 | Tone string // "", "warn" or "error" — the class the message wears |
| 39 | } |
| 40 | |
| 41 | // logLines folds a window of records into the pane's lines. Order is the |
| 42 | // caller's (oldest first, newest at the foot, as a terminal scrolls). |
| 43 | func logLines(recs []applog.Record) []logLine { |
| 44 | streamW, hostW := 0, 0 |
| 45 | for _, r := range recs { |
| 46 | if n := utf8.RuneCountInString(r.Stream) + 2; n > streamW { // + the brackets |
| 47 | streamW = n |
| 48 | } |
| 49 | if n := utf8.RuneCountInString(r.Host); n > hostW { |
| 50 | hostW = n |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | out := make([]logLine, 0, len(recs)) |
| 55 | for _, r := range recs { |
| 56 | l := logLine{ |
| 57 | Time: r.TS.UTC().Format(logLineTimeFormat), |
| 58 | Stream: pad("["+r.Stream+"]", streamW), |
| 59 | Msg: r.Msg, |
| 60 | } |
| 61 | // A window with no hosts at all renders no host column, rather |
| 62 | // than a run of padding spaces in front of every message. |
| 63 | if hostW > 0 { |
| 64 | l.Host = pad(r.Host, hostW) |
| 65 | } |
| 66 | switch r.Level { |
| 67 | case applog.LevelError: |
| 68 | l.Tone = "error" |
| 69 | case applog.LevelWarn: |
| 70 | l.Tone = "warn" |
| 71 | } |
| 72 | if len(r.Fields) > 0 { |
| 73 | keys := make([]string, 0, len(r.Fields)) |
| 74 | for k := range r.Fields { |
| 75 | keys = append(keys, k) |
| 76 | } |
| 77 | sort.Strings(keys) |
| 78 | var b strings.Builder |
| 79 | for _, k := range keys { |
| 80 | b.WriteString(" " + k + "=" + r.Fields[k]) |
| 81 | } |
| 82 | l.Fields = b.String() |
| 83 | } |
| 84 | out = append(out, l) |
| 85 | } |
| 86 | return out |
| 87 | } |
| 88 | |
| 89 | // pad right-pads s with spaces to w runes. The pane is monospaced, so a |
| 90 | // space is a column. |
| 91 | func pad(s string, w int) string { |
| 92 | if n := w - utf8.RuneCountInString(s); n > 0 { |
| 93 | return s + strings.Repeat(" ", n) |
| 94 | } |
| 95 | return s |
| 96 | } |
| 97 | |