console: Logs tab on the app page
Same merged timeline as the API (applog.ReadWindow), tailed to 200 rows, stream + window filters as GET params so a view is a URL; honest empty state explains platform-events-only apps and points at carlos logs -f.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+137
−0
internal/console/apilogs_test.go+32 −0internal/console/server.go+54 −0internal/console/templates/app_logs.html+50 −0internal/console/templates/appnav.html+1 −0
diff --git a/internal/console/apilogs_test.go b/internal/console/apilogs_test.go| index 85b1bcbf7..46ca0b002 100644 |
| --- a/internal/console/apilogs_test.go |
| +++ b/internal/console/apilogs_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "fmt" |
| "net/http" |
| + "strings" |
| "testing" |
| "time" |
| @@ -149,3 +150,34 @@ func TestAPILogsRefusals(t *testing.T) { |
| } |
| _ = fmt.Sprintf("%v", resp) |
| } |
| + |
| +// TestAppLogsPage: the console tab renders the same timeline — filters as |
| +// GET params, member-gated like every app page, honest empty state. |
| +func TestAppLogsPage(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-logs-page") |
| + now := time.Now().UTC() |
| + seedLogChunks(t, fx, now) |
| + fx.setUser("paul@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-logs-signin") |
| + |
| + status, _, body := get(t, c, fx.u("/apps/titogo/logs")) |
| + if status != http.StatusOK { |
| + t.Fatalf("logs page: %d %s", status, body) |
| + } |
| + for _, want := range []string{"listening on socket", "wake failed", "carlos logs -app titogo -f"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("page missing %q", want) |
| + } |
| + } |
| + // Stream filter narrows. |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?stream=platform")) |
| + if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") { |
| + t.Fatalf("stream filter page: %d", status) |
| + } |
| + // Window with nothing in it: the honest empty state, still a 200. |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?since=1ns")) |
| + if status != http.StatusOK || !strings.Contains(body, "No log lines in this window") { |
| + t.Fatalf("empty window page: %d", status) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 4a9f9226a..fcce1093a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -22,6 +22,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/applog" |
| "github.com/carlosframework/platform/internal/domains" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -320,6 +321,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances) |
| + s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote) |
| @@ -519,6 +521,15 @@ type page struct { |
| // App Instances: the one table — routes joined with their readings. |
| InstanceRows []InstanceRow |
| + // App Logs (apilogs.go's sibling page): the merged timeline plus the |
| + // filter state the form re-renders with. Empty LogRows with LogsErr "" |
| + // is the normal nothing-logged-yet state, never an error. |
| + LogRows []applog.Record |
| + LogStream string |
| + LogSince string |
| + LogTruncated bool |
| + LogsErr string |
| + |
| // Action confirm interstitial (confirm.go): what is about to happen, |
| // carried fields and all. Nil everywhere except the confirm page. |
| Confirm *ConfirmView |
| @@ -1589,6 +1600,49 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "app_instances", p) |
| } |
| +// appLogsPageCap bounds the Logs tab to what a page can usefully show; the |
| +// CLI (`carlos logs`) is the door for more, and the page says so. |
| +const appLogsPageCap = 200 |
| + |
| +// handleAppLogs renders the app's merged log timeline — the same window the |
| +// CLI reads (apilogs.go), tailed to the newest appLogsPageCap records, with |
| +// stream and window filters as plain GET params so a view is a URL. |
| +func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) { |
| + p, acct, ok := s.appPage(w, r, "logs") |
| + if !ok { |
| + return |
| + } |
| + q := r.URL.Query() |
| + p.LogStream = q.Get("stream") |
| + if p.LogStream != "" && !applog.ValidStream(p.LogStream) { |
| + p.LogStream = "" |
| + } |
| + p.LogSince = q.Get("since") |
| + if _, err := time.ParseDuration(p.LogSince); err != nil { |
| + p.LogSince = "1h" |
| + } |
| + f := applog.Filter{Stream: p.LogStream} |
| + d, _ := time.ParseDuration(p.LogSince) |
| + f.Since = time.Now().Add(-d) |
| + |
| + if s.cfg.Releases != nil { |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, p.AppName, f, logsCollectCap) |
| + if err != nil { |
| + log.Printf("console: app logs %s/%s: %v", acct.Sqid, p.AppName, err) |
| + p.LogsErr = "The logs could not be read just now — try again in a moment." |
| + } |
| + p.LogTruncated = trimmed |
| + if len(recs) > appLogsPageCap { |
| + recs = recs[len(recs)-appLogsPageCap:] |
| + p.LogTruncated = true |
| + } |
| + p.LogRows = recs |
| + } |
| + s.render(w, http.StatusOK, "app_logs", p) |
| +} |
| + |
| // handleAppActivity is the console's own audit trail for this app. |
| func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) { |
| p, acct, ok := s.appPage(w, r, "activity") |
diff --git a/internal/console/templates/app_logs.html b/internal/console/templates/app_logs.html| new file mode 100644 |
| index 000000000..e04d66787 |
| --- /dev/null |
| +++ b/internal/console/templates/app_logs.html |
| @@ -0,0 +1,50 @@ |
| +{{define "app_logs"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{template "appnav" .}} |
| + |
| +<h2>Logs</h2> |
| +{{if .LogsErr}}<p class="banner error">{{.LogsErr}}</p>{{end}} |
| +<form method="get" class="small"> |
| + <label>Show |
| + <select name="stream"> |
| + <option value=""{{if eq .LogStream ""}} selected{{end}}>everything</option> |
| + <option value="app"{{if eq .LogStream "app"}} selected{{end}}>app output</option> |
| + <option value="platform"{{if eq .LogStream "platform"}} selected{{end}}>platform events</option> |
| + </select> |
| + </label> |
| + <label>from the last |
| + <select name="since"> |
| + <option value="15m"{{if eq .LogSince "15m"}} selected{{end}}>15 minutes</option> |
| + <option value="1h"{{if eq .LogSince "1h"}} selected{{end}}>hour</option> |
| + <option value="6h"{{if eq .LogSince "6h"}} selected{{end}}>6 hours</option> |
| + <option value="24h"{{if eq .LogSince "24h"}} selected{{end}}>day</option> |
| + <option value="168h"{{if eq .LogSince "168h"}} selected{{end}}>week</option> |
| + </select> |
| + </label> |
| + <button type="submit">Update</button> |
| +</form> |
| + |
| +{{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> |
| +<p class="small">Times are UTC. Follow live with <code>carlos logs -app {{.AppName}} -f</code>.</p> |
| +{{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 |
| +(wakes, restarts, failures) appear even when the app itself printed nothing. A |
| +site served from files alone has no app output.</p> |
| +{{end}} |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html| index d6696818c..c4b4a3a11 100644 |
| --- a/internal/console/templates/appnav.html |
| +++ b/internal/console/templates/appnav.html |
| @@ -7,6 +7,7 @@ |
| <a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Versions</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/logs"{{if eq .ActiveTab "logs"}} aria-current="page"{{end}}>Logs</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a> |
| </nav> |