carlos / platform Public

Merge pull request #209 from carlosframework/logs-terminal-pane

console: the Logs tab is a terminal, not a table
Paul Campbell 89b6bbc20026459252d302d0896914317954e327 parent d7077d1parent 64b6944
5 files changed, +280 −12
  • internal/console/logsview.go +96 −0
  • internal/console/logsview_test.go +132 −0
  • internal/console/server.go +4 −0
  • internal/console/templates/app_logs.html +13 −12
  • internal/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, "&lt;script&gt;alert(1)&lt;/script&gt;") {
+ 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; }