Merge pull request #209 from carlosframework/logs-terminal-pane
console: the Logs tab is a terminal, not a table
5 files changed,
+280
−12
internal/console/logsview.go+96 −0internal/console/logsview_test.go+132 −0internal/console/server.go+4 −0internal/console/templates/app_logs.html+13 −12internal/console/templates/layout.html+35 −0
diff --git a/internal/console/logsview.go b/internal/console/logsview.go| new file mode 100644 |
| index 000000000..8e9d30b1b |
| --- /dev/null |
| +++ b/internal/console/logsview.go |
| @@ -0,0 +1,96 @@ |
| +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 |
| +} |
diff --git a/internal/console/logsview_test.go b/internal/console/logsview_test.go| new file mode 100644 |
| index 000000000..2480b63cd |
| --- /dev/null |
| +++ b/internal/console/logsview_test.go |
| @@ -0,0 +1,132 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/applog" |
| +) |
| + |
| +// TestLogLinesMatchTheCLIAndAlign is the Logs tab's whole promise in one |
| +// test: the console renders the SAME line `carlos logs` prints (logs.go's |
| +// printLogRecords — UTC time to the second, [stream], host, two spaces, |
| +// message, then sorted k=v fields), and it column-aligns that line by |
| +// padding stream and host to the widest in the window, the way a terminal |
| +// log tool does. A record's level becomes a tone the pane can colour, never |
| +// text — the CLI doesn't print the word "error" either. |
| +func TestLogLinesMatchTheCLIAndAlign(t *testing.T) { |
| + ts := time.Date(2026, 8, 16, 13, 49, 38, 848000000, time.UTC) |
| + got := logLines([]applog.Record{ |
| + {TS: ts, Stream: "app", Host: "test1.bdk.oncarlos.com", Msg: "database ready"}, |
| + {TS: ts.Add(time.Second), Stream: "platform", Host: "a.oncarlos.com", Msg: "instance woke"}, |
| + {TS: ts.Add(2 * time.Second), Stream: "app", Host: "a.oncarlos.com", Msg: "boom", |
| + Level: applog.LevelError, Fields: map[string]string{"status": "500", "ms": "3"}}, |
| + }) |
| + if len(got) != 3 { |
| + t.Fatalf("logLines returned %d lines, want 3", len(got)) |
| + } |
| + |
| + // Line one sets the column widths: "[platform]" is the widest tag in |
| + // this window and "test1.bdk.oncarlos.com" the widest host, so every |
| + // line pads to those, including the ones that need no padding. |
| + want := []logLine{ |
| + { |
| + Time: "2026-08-16 13:49:38", Stream: "[app] ", |
| + Host: "test1.bdk.oncarlos.com", Msg: "database ready", |
| + }, |
| + { |
| + Time: "2026-08-16 13:49:39", Stream: "[platform]", |
| + Host: "a.oncarlos.com ", Msg: "instance woke", |
| + }, |
| + { |
| + Time: "2026-08-16 13:49:40", Stream: "[app] ", |
| + Host: "a.oncarlos.com ", Msg: "boom", |
| + Fields: " ms=3 status=500", Tone: "error", |
| + }, |
| + } |
| + for i := range want { |
| + if got[i] != want[i] { |
| + t.Fatalf("line %d = %#v, want %#v", i, got[i], want[i]) |
| + } |
| + } |
| +} |
| + |
| +// TestLogLinesToneAndEmptyColumns covers the rest of the fold: warn is its |
| +// own tone, an unknown/absent level has none, and a window whose records |
| +// carry no host at all renders no host column rather than a run of padding |
| +// spaces before every message. |
| +func TestLogLinesToneAndEmptyColumns(t *testing.T) { |
| + ts := time.Date(2026, 8, 16, 13, 0, 0, 0, time.UTC) |
| + got := logLines([]applog.Record{ |
| + {TS: ts, Stream: "platform", Msg: "slow wake", Level: applog.LevelWarn}, |
| + {TS: ts, Stream: "platform", Msg: "woke", Level: applog.LevelInfo}, |
| + {TS: ts, Stream: "platform", Msg: "unlevelled"}, |
| + }) |
| + if got[0].Tone != "warn" { |
| + t.Fatalf("warn record tone = %q, want %q", got[0].Tone, "warn") |
| + } |
| + for _, i := range []int{1, 2} { |
| + if got[i].Tone != "" { |
| + t.Fatalf("line %d tone = %q, want none (only warn/error colour a line)", i, got[i].Tone) |
| + } |
| + } |
| + for i, l := range got { |
| + if l.Host != "" { |
| + t.Fatalf("line %d host = %q, want empty — no record in this window has one", i, l.Host) |
| + } |
| + } |
| +} |
| + |
| +// TestAppLogsPageIsATerminalPane renders the Logs tab and asserts the shape |
| +// the tab now has: a monospaced pane of whole lines, NOT the four-column |
| +// table it used to be (which wrapped every message across three rows), and |
| +// no header row, because a terminal has none. A message is app output — |
| +// arbitrary bytes from someone else's process — so the escaping is asserted |
| +// here too, on the page that prints it verbatim. |
| +func TestAppLogsPageIsATerminalPane(t *testing.T) { |
| + ts := time.Date(2026, 8, 16, 13, 49, 38, 0, time.UTC) |
| + p := page{ |
| + Title: "Logs", CSRF: "tok", AppName: "taskmonger", ActiveTab: "logs", |
| + Account: &Account{Name: "tom@keymail.dev", Sqid: "bdk"}, Nav: "/a/bdk", |
| + LogSince: "1h", |
| + LogRows: []applog.Record{ |
| + {TS: ts, Stream: "app", Host: "test1.bdk.oncarlos.com", Msg: `hi <script>alert(1)</script>`}, |
| + {TS: ts.Add(time.Second), Stream: "platform", Host: "test1.bdk.oncarlos.com", Msg: "instance woke"}, |
| + }, |
| + } |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "app_logs", p); err != nil { |
| + t.Fatalf("render app_logs: %v", err) |
| + } |
| + html := buf.String() |
| + |
| + if !strings.Contains(html, `class="term"`) { |
| + t.Fatalf("Logs tab has no terminal pane (.term) in:\n%s", html) |
| + } |
| + if strings.Contains(html, `<table`) { |
| + t.Fatalf("Logs tab still renders a table — the pane replaced it:\n%s", html) |
| + } |
| + for _, gone := range []string{">When<", ">Stream<", ">Instance<", ">Message<"} { |
| + if strings.Contains(html, gone) { |
| + t.Fatalf("Logs tab still renders the %s column header; a terminal has none:\n%s", gone, html) |
| + } |
| + } |
| + // The whole line, in the CLI's order, with the stream tag padded to the |
| + // window's widest ("[platform]"). |
| + for _, want := range []string{ |
| + "2026-08-16 13:49:38", "[app] ", "[platform]", |
| + "test1.bdk.oncarlos.com", "instance woke", |
| + } { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("Logs pane missing %q in:\n%s", want, html) |
| + } |
| + } |
| + if strings.Contains(html, "<script>alert(1)</script>") { |
| + t.Fatalf("app output rendered unescaped into the page:\n%s", html) |
| + } |
| + if !strings.Contains(html, "<script>alert(1)</script>") { |
| + t.Fatalf("app output not rendered at all (escaped form absent) in:\n%s", html) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index c9adc66c4..f42207a8a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -109,6 +109,10 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| // healthSlug: the Running tab's health-filter links and the hidden |
| // carried-filter field both need a health word's URL form (listquery.go). |
| "healthSlug": healthSlug, |
| + // logLines: the Logs tab's window folded into the terminal pane's lines |
| + // (logsview.go). The padding is computed across the whole window, so it |
| + // has to be one call over all the records, not one per row. |
| + "logLines": logLines, |
| // bytesOrDash renders a byte count in the smallest unit that keeps it |
| // readable; nil (no reading, from a unit that reported CPU but not |
| // memory/IO that tick) is "—", never a fabricated 0 B. |
diff --git a/internal/console/templates/app_logs.html b/internal/console/templates/app_logs.html| index e04d66787..1c58a87e3 100644 |
| --- a/internal/console/templates/app_logs.html |
| +++ b/internal/console/templates/app_logs.html |
| @@ -27,20 +27,21 @@ |
| {{if .LogRows}} |
| {{if .LogTruncated}}<p class="small">Showing the newest {{len .LogRows}} lines of this window — narrow the window, or use <code>carlos logs</code> for more.</p>{{end}} |
| -<div class="tablewrap"> |
| -<table class="logs"> |
| - <tr><th scope="col">When</th><th scope="col">Stream</th><th scope="col">Instance</th><th scope="col">Message</th></tr> |
| - {{range .LogRows}} |
| - <tr> |
| - <td class="small">{{fmtTime .TS}}</td> |
| - <td><code>{{.Stream}}</code></td> |
| - <td class="small">{{.Host}}</td> |
| - <td>{{if eq .Level "error"}}<strong>{{.Msg}}</strong>{{else}}{{.Msg}}{{end}}</td> |
| - </tr> |
| - {{end}} |
| -</table> |
| +<div class="term" id="logterm" role="log" aria-label="Log output" tabindex="0"> |
| +{{- range logLines .LogRows -}} |
| +<div class="tl{{if .Tone}} tl-{{.Tone}}{{end}}"><span class="tl-t">{{.Time}}</span> <span class="tl-s">{{.Stream}}</span>{{if .Host}} <span class="tl-h">{{.Host}}</span>{{end}} <span class="tl-m">{{.Msg}}</span>{{if .Fields}}<span class="tl-f">{{.Fields}}</span>{{end}}</div> |
| +{{- end -}} |
| </div> |
| <p class="small">Times are UTC. Follow live with <code>carlos logs -app {{.AppName}} -f</code>.</p> |
| +<script> |
| +// Enhancement only: the pane is a scrolled region of plain lines, oldest |
| +// first. A terminal opens at its foot, so this puts the newest line in view |
| +// on load. With JS off the pane simply opens at the top. |
| +(function () { |
| + var t = document.getElementById("logterm"); |
| + if (t) t.scrollTop = t.scrollHeight; |
| +})(); |
| +</script> |
| {{else if not .LogsErr}} |
| <p>No log lines in this window.</p> |
| <p class="small">An app's output appears here shortly after it runs; platform events |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 386a384a8..b0440d1d8 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -144,6 +144,41 @@ |
| padding: 0.6rem 0.9rem; margin: 0 0 1.5rem; max-width: 40rem; |
| } |
| .tablewrap { overflow-x: auto; } |
| + /* The Logs tab (logsview.go): the pane reads as the terminal it mirrors — |
| + the same line `carlos logs` prints, monospaced, one line per record, no |
| + wrapping. A table wrapped every message across three rows and put the |
| + eye back at the left margin for each one, which is precisely what a log |
| + reader is scanning past. |
| + |
| + Ground is the shell-command <pre>'s own fill rather than a terminal's |
| + black: this page is a light document in light mode, and a black |
| + rectangle down the middle of it would read as a foreign object, not as |
| + the console's own output. The pane is a scrolled region in BOTH axes — |
| + sideways because lines do not wrap, and down because 200 lines must not |
| + turn the page itself into the scrollbar. */ |
| + .term { |
| + background: var(--accent-soft); border-radius: 0.5rem; |
| + padding: 0.8rem 0 0.8rem 1rem; margin: 0 0 1rem; |
| + max-height: 70vh; overflow: auto; |
| + font-family: var(--mono); font-size: 0.8rem; line-height: 1.55; |
| + } |
| + /* white-space:pre belongs to the LINE, not the pane: on the pane it would |
| + render the newlines BETWEEN these divs as blank lines. width:max-content |
| + with min-width:100% makes each line as wide as its own text, so the |
| + pane's horizontal scroll reaches the end of the longest one — and the |
| + right-hand gutter is the LINE's padding, not the pane's, because a |
| + scrolled-away pane padding leaves the longest line hard against the |
| + edge. */ |
| + .tl { white-space: pre; width: max-content; min-width: 100%; padding-right: 1rem; } |
| + /* Time, host and trailing k=v fields are the line's furniture; the message |
| + is what the reader came for, so it alone gets full-strength ink. */ |
| + .tl-t, .tl-h, .tl-f { color: var(--muted); } |
| + .tl-s { color: var(--accent); } |
| + /* The CLI colours a level rather than printing its name (logs.go's |
| + printLogRecords); the pane says it the same way, in the same two tones |
| + the rest of this console uses for warn and bad. */ |
| + .tl-warn .tl-m { color: var(--warn, #a6790f); } |
| + .tl-error .tl-m { color: var(--bad); font-weight: 600; } |
| table { border-collapse: collapse; width: 100%; margin: 0 0 1rem; font-size: 0.95rem; } |
| .tiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0 0 1.5rem; } |
| .tile { flex: 1 1 10rem; background: var(--accent-soft); border-radius: 0.6rem; padding: 1rem 1.1rem; } |