carlos / platform Public

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>
Paul Campbell 5cbd67c8061671683d603e9c0bac5bfe6f173028 parent 6567e49
4 files changed, +137 −0
  • internal/console/apilogs_test.go +32 −0
  • internal/console/server.go +54 −0
  • internal/console/templates/app_logs.html +50 −0
  • internal/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>