Merge branch 'console-ux-06-logs' into main
5 files changed,
+549
−27
internal/console/apilogs_test.go+169 −0internal/console/logsview_test.go+31 −0internal/console/server.go+188 −13internal/console/templates/app_logs.html+155 −13internal/console/templates/layout.html+6 −1
CI passed — run details
diff --git a/internal/console/apilogs_test.go b/internal/console/apilogs_test.go| index 5de42866c..4deb40d38 100644 |
| --- a/internal/console/apilogs_test.go |
| +++ b/internal/console/apilogs_test.go |
| @@ -4,12 +4,15 @@ import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| + "io" |
| "net/http" |
| + "net/url" |
| "strings" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -44,6 +47,24 @@ func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Rec |
| return recs |
| } |
| +func seedManyLogRecords(t *testing.T, fx *consoleFixture, now time.Time, n int) []applog.Record { |
| + t.Helper() |
| + recs := make([]applog.Record, n) |
| + for i := range recs { |
| + recs[i] = applog.Record{TS: now.Add(time.Duration(i) * time.Millisecond), Box: "box-many", |
| + Host: "titogo.example.com", Stream: applog.StreamApp, Msg: fmt.Sprintf("line %d", i), Seq: uint64(1000 + i)} |
| + } |
| + body, err := applog.EncodeChunk(recs) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + key := applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body)) |
| + if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + return recs |
| +} |
| + |
| // TestAPILogsTailFiltersAndFollow drives the endpoint end to end: default |
| // tail, stream/level/grep/field filters, and the cursor follow loop. |
| func TestAPILogsTailFiltersAndFollow(t *testing.T) { |
| @@ -151,10 +172,127 @@ func TestAPILogsRefusals(t *testing.T) { |
| _ = fmt.Sprintf("%v", resp) |
| } |
| +func TestAppLogsDataUsesBrowserSessionAndFilters(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-logs-browser-data") |
| + seedLogChunks(t, fx, time.Now().UTC()) |
| + fx.setUser("alice@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-logs-browser-data-signin") |
| + |
| + status, _, body := get(t, c, fx.u("/apps/titogo/logs/data?host=titogo.example.com&level=error")) |
| + if status != http.StatusOK { |
| + t.Fatalf("browser logs data = %d: %s", status, body) |
| + } |
| + var resp appLogsDataResponse |
| + if err := json.Unmarshal([]byte(body), &resp); err != nil { |
| + t.Fatalf("browser logs JSON: %v: %s", err, body) |
| + } |
| + if len(resp.Lines) != 1 || !strings.Contains(resp.Lines[0].Msg, "wake failed") || resp.Lines[0].Tone != "error" { |
| + t.Fatalf("filtered browser lines = %+v", resp.Lines) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?host=deleted.example.com")) |
| + if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) { |
| + t.Fatalf("historical host filter broadened = %d: %s", status, body) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?text=wake%5B")) |
| + if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) { |
| + t.Fatalf("browser text search was treated as a regular expression = %d: %s", status, body) |
| + } |
| + assertNonMember404(t, fx, "/apps/titogo/logs/data") |
| +} |
| + |
| +func TestAppLogsDataCursorPaginationAndRefusals(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-logs-browser-cursor") |
| + now := time.Now().UTC() |
| + recs := seedLogChunks(t, fx, now) |
| + fx.setUser("alice@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-logs-browser-cursor-signin") |
| + |
| + read := func(query string) (int, http.Header, appLogsDataResponse, string) { |
| + t.Helper() |
| + req, err := http.NewRequest(http.MethodGet, fx.u("/apps/titogo/logs/data?"+query), nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + res, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer res.Body.Close() |
| + b, err := io.ReadAll(res.Body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + status, headers, body := res.StatusCode, res.Header, string(b) |
| + var out appLogsDataResponse |
| + if status == http.StatusOK { |
| + if err := json.Unmarshal([]byte(body), &out); err != nil { |
| + t.Fatalf("decode logs data: %v: %s", err, body) |
| + } |
| + } |
| + return status, headers, out, body |
| + } |
| + |
| + status, headers, out, body := read("cursor=" + url.QueryEscape(applog.EncodeCursor(recs[0]))) |
| + if status != http.StatusOK || len(out.Lines) != 1 || !strings.Contains(out.Lines[0].Msg, "wake failed") { |
| + t.Fatalf("cursor read = %d %+v: %s", status, out, body) |
| + } |
| + if headers.Get("Cache-Control") != "no-store" { |
| + t.Fatalf("logs data Cache-Control = %q", headers.Get("Cache-Control")) |
| + } |
| + last := applog.EncodeCursor(recs[1]) |
| + _, _, out, _ = read("cursor=" + url.QueryEscape(last)) |
| + if len(out.Lines) != 0 || out.Cursor != last || out.Gap { |
| + t.Fatalf("empty cursor read = %+v", out) |
| + } |
| + expired := applog.EncodeCursor(applog.Record{TS: now.Add(-2 * time.Hour), Box: "old", Seq: 1}) |
| + _, _, out, _ = read("since=1h&cursor=" + url.QueryEscape(expired)) |
| + if !out.Gap || len(out.Lines) != 0 { |
| + t.Fatalf("expired cursor did not report a gap: %+v", out) |
| + } |
| + _, _, out, _ = read("cursor=not-a-cursor") |
| + if !out.Gap { |
| + t.Fatalf("invalid cursor did not report a gap: %+v", out) |
| + } |
| + |
| + seedManyLogRecords(t, fx, now.Add(-2*time.Minute), appLogsPageCap+1) |
| + _, _, out, _ = read("limit=999") |
| + if len(out.Lines) != appLogsPageCap || !out.Truncated || out.Gap { |
| + t.Fatalf("bounded page = %d lines, truncated=%v gap=%v", len(out.Lines), out.Truncated, out.Gap) |
| + } |
| + for _, query := range []string{"since=bad", "level=loud"} { |
| + status, _, _, _ := read(query) |
| + if status != http.StatusBadRequest { |
| + t.Errorf("%s = %d, want 400", query, status) |
| + } |
| + } |
| + fx.srv.cfg.Releases = nil |
| + status, headers, _, _ = read("") |
| + if status != http.StatusServiceUnavailable || headers.Get("Cache-Control") != "no-store" { |
| + t.Fatalf("bucketless logs data = %d, Cache-Control %q", status, headers.Get("Cache-Control")) |
| + } |
| +} |
| + |
| // 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") |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rt := range []registry.Route{ |
| + {Host: "legacy.example.com", Kind: "instance", App: "titogo", Account: "bab"}, |
| + {Host: "foreign.example.com", Kind: "instance", App: "titogo", Account: "other"}, |
| + } { |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := reg.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| now := time.Now().UTC() |
| seedLogChunks(t, fx, now) |
| fx.setUser("paul@keymail.dev") |
| @@ -170,11 +308,42 @@ func TestAppLogsPage(t *testing.T) { |
| t.Errorf("page missing %q", want) |
| } |
| } |
| + for _, want := range []string{"All instances", "Search logs", "All levels", "Follow live", "Pause"} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("page missing approved copy %q", want) |
| + } |
| + } |
| + if !strings.Contains(body, `option value="legacy.example.com"`) || strings.Contains(body, `option value="foreign.example.com"`) { |
| + t.Fatalf("instance filter did not include only the account's legacy routes") |
| + } |
| // 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) |
| } |
| + // Instance, severity and text filters compose on the browser page too. |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=titogo.example.com&level=error&text=wake")) |
| + if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") { |
| + t.Fatalf("composed browser filters: %d", status) |
| + } |
| + if !strings.Contains(body, `option value="titogo.example.com" selected`) { |
| + t.Fatalf("browser host filter was not retained: %s", body) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=deleted.example.com")) |
| + if status != http.StatusOK || strings.Contains(body, "wake failed") { |
| + t.Fatalf("historical browser host broadened: %d", status) |
| + } |
| + stamp := url.QueryEscape(now.Add(-30 * time.Minute).Format(time.RFC3339)) |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?since="+stamp+"&grep=wake&follow=1")) |
| + if status != http.StatusOK || !strings.Contains(body, `selected>`+now.Add(-30*time.Minute).Format(time.RFC3339)+`</option>`) || |
| + !strings.Contains(body, `type="hidden" name="grep" value="wake"`) || |
| + !strings.Contains(body, `name="text" value=""`) || !strings.Contains(body, `setFollowing(true); poll();`) { |
| + t.Fatalf("non-preset/legacy/follow state was not retained = %d: %s", status, body) |
| + } |
| + status, _, body = get(t, c, fx.u("/apps/titogo/logs?cursor=hand-written")) |
| + if status != http.StatusOK || !strings.Contains(body, `<option value="1h" selected>hour</option>`) { |
| + t.Fatalf("page cursor without since lost the bounded default window = %d: %s", status, body) |
| + } |
| // 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") { |
diff --git a/internal/console/logsview_test.go b/internal/console/logsview_test.go| index 2480b63cd..c5a3e3b33 100644 |
| --- a/internal/console/logsview_test.go |
| +++ b/internal/console/logsview_test.go |
| @@ -130,3 +130,34 @@ func TestAppLogsPageIsATerminalPane(t *testing.T) { |
| t.Fatalf("app output not rendered at all (escaped form absent) in:\n%s", html) |
| } |
| } |
| + |
| +func TestAppLogsEmptyStateKeepsTerminalAndFollowControls(t *testing.T) { |
| + p := page{ |
| + Title: "Logs", CSRF: "tok", AppName: "taskmonger", ActiveTab: "logs", |
| + Account: &Account{Name: "tom@keymail.dev", Sqid: "bdk"}, Base: "/a/bdk", Nav: "/a/bdk", |
| + LogSince: "1h", LogWidenURL: "/a/bdk/apps/taskmonger/logs?since=24h", |
| + } |
| + var buf bytes.Buffer |
| + if err := templates.ExecuteTemplate(&buf, "app_logs", p); err != nil { |
| + t.Fatalf("render empty app_logs: %v", err) |
| + } |
| + html := buf.String() |
| + for _, want := range []string{`class="term"`, `id="log-empty"`, `id="log-follow"`, `id="log-pause"`, `logs/data`} { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("empty Logs view missing %q:\n%s", want, html) |
| + } |
| + } |
| + for _, want := range []string{ |
| + "textContent = value", `querySelectorAll(".tl").length > 200`, "request.abort()", |
| + "var activeQuery = new URLSearchParams(new FormData(form))", |
| + "if (!following || thisGeneration !== generation) return", |
| + `form.addEventListener("input"`, `q.set("follow", "1")`, "location.assign", |
| + } { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("Logs follow script missing safety %q", want) |
| + } |
| + } |
| + if strings.Contains(html, "innerHTML") { |
| + t.Fatal("Logs follow script writes live records through innerHTML") |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 95ae94b90..3ac7e6952 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -22,6 +22,7 @@ import ( |
| "os" |
| "regexp" |
| "slices" |
| + "sort" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -1115,6 +1116,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs) |
| + s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData) |
| // The Errors tab and its two setting doors (errorsview.go): the tab is |
| // member-readable like every other app tab; the level is the owner's, |
| // at app and account level, and both POSTs go through the console gate |
| @@ -1457,11 +1459,20 @@ type page struct { |
| // 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 |
| + LogRows []applog.Record |
| + LogStream string |
| + LogSince string |
| + LogHost string |
| + LogLevel string |
| + LogGrep string |
| + LogLegacyGrep string |
| + LogFollow bool |
| + LogHosts []string |
| + LogCopy appLogsCopy |
| + LogWidenURL string |
| + LogCursor string |
| + LogTruncated bool |
| + LogsErr string |
| // App Errors (errorsview.go's page, apierrors.go's sibling): the |
| // assembled window plus the filter state the form re-renders with. Empty |
| @@ -1575,6 +1586,10 @@ type page struct { |
| ServiceSecret *ServiceSecretView |
| } |
| +type appLogsCopy struct { |
| + Instance, AllInstances, Search, AllLevels, Follow, Pause, Widen, FollowError, Refreshed string |
| +} |
| + |
| // IsOwner is the template's one permission question. |
| func (p page) IsOwner() bool { return p.Role == RoleOwner } |
| @@ -2848,19 +2863,64 @@ func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| q := r.URL.Query() |
| - p.LogStream = q.Get("stream") |
| - if p.LogStream != "" && !applog.ValidStream(p.LogStream) { |
| - p.LogStream = "" |
| + p.LogCopy = appLogsCopy{ |
| + Instance: "Instances", AllInstances: "All instances", Search: "Search logs", |
| + AllLevels: "All levels", Follow: "Follow live", Pause: "Pause", |
| + Widen: "Show the last 24 hours", |
| + FollowError: "Live updates failed. Follow live again to retry.", Refreshed: "Updated", |
| } |
| + p.LogStream = q.Get("stream") |
| p.LogSince = q.Get("since") |
| - if _, err := time.ParseDuration(p.LogSince); err != nil { |
| + if p.LogSince == "" { |
| p.LogSince = "1h" |
| } |
| - f := applog.Filter{Stream: p.LogStream} |
| - d, _ := time.ParseDuration(p.LogSince) |
| - f.Since = time.Now().Add(-d) |
| - |
| + p.LogHost = q.Get("host") |
| + p.LogLevel = q.Get("level") |
| + p.LogGrep = q.Get("text") |
| + p.LogLegacyGrep = q.Get("grep") |
| + p.LogFollow = q.Get("follow") == "1" |
| + widen, _ := url.ParseQuery(q.Encode()) |
| + widen.Set("since", "24h") |
| + widen.Del("cursor") |
| + widen.Del("follow") |
| + p.LogWidenURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/logs?" + widen.Encode() |
| + now := time.Now() |
| + f, _, bad := browserLogsFilterFromQuery(r, now) |
| + // Cursor is a data-endpoint concern. A hand-written page URL carrying one |
| + // must not turn the page's normal one-hour default into an all-history read. |
| + if f.Since.IsZero() { |
| + f.Since = now.Add(-logsDefaultWindow) |
| + } |
| + if bad != "" { |
| + p.LogsErr = bad |
| + } |
| + seenHosts := map[string]bool{} |
| + if p.LogHost != "" { |
| + seenHosts[p.LogHost] = true |
| + p.LogHosts = append(p.LogHosts, p.LogHost) |
| + } |
| + for _, rt := range p.Detail.Routes { |
| + if rt.Account == acct.Sqid && !seenHosts[rt.Host] { |
| + seenHosts[rt.Host] = true |
| + p.LogHosts = append(p.LogHosts, rt.Host) |
| + } |
| + } |
| if s.cfg.Releases != nil { |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, p.AppName, instanceListMax) |
| + cancel() |
| + if err == nil { |
| + for _, rec := range recs { |
| + if !seenHosts[rec.Host] { |
| + seenHosts[rec.Host] = true |
| + p.LogHosts = append(p.LogHosts, rec.Host) |
| + } |
| + } |
| + } |
| + } |
| + sort.Strings(p.LogHosts) |
| + |
| + if s.cfg.Releases != nil && bad == "" { |
| ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| defer cancel() |
| recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, p.AppName, f, logsCollectCap) |
| @@ -2874,10 +2934,125 @@ func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) { |
| p.LogTruncated = true |
| } |
| p.LogRows = recs |
| + if n := len(recs); n > 0 { |
| + p.LogCursor = applog.EncodeCursor(recs[n-1]) |
| + } |
| } |
| s.render(w, http.StatusOK, "app_logs", p) |
| } |
| +type appLogsDataLine struct { |
| + Time string `json:"time"` |
| + Stream string `json:"stream"` |
| + Host string `json:"host,omitempty"` |
| + Msg string `json:"msg"` |
| + Fields string `json:"fields,omitempty"` |
| + Tone string `json:"tone,omitempty"` |
| +} |
| + |
| +type appLogsDataResponse struct { |
| + Lines []appLogsDataLine `json:"lines"` |
| + Truncated bool `json:"truncated"` |
| + Gap bool `json:"gap,omitempty"` |
| + Cursor string `json:"cursor,omitempty"` |
| +} |
| + |
| +// handleAppLogsData is the browser's follow endpoint. It uses the session and |
| +// app-membership gate, then the API reader's filter/cursor semantics; browser |
| +// JavaScript never receives or manufactures a bearer credential. |
| +func (s *Server) handleAppLogsData(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + _, _, acct, _, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + now := time.Now() |
| + f, limit, bad := browserLogsFilterFromQuery(r, now) |
| + if bad != "" { |
| + jsonError(w, http.StatusBadRequest, bad) |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + jsonError(w, http.StatusServiceUnavailable, "could not read the logs") |
| + return |
| + } |
| + cursorText := r.URL.Query().Get("cursor") |
| + cur, hasCursor := applog.DecodeCursor(cursorText) |
| + if cursorText != "" && !hasCursor { |
| + writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true}) |
| + return |
| + } |
| + if hasCursor { |
| + if !f.Since.IsZero() && cur.TS.Before(f.Since) { |
| + writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true}) |
| + return |
| + } |
| + cursorSince := cur.TS.Add(-time.Second) |
| + if f.Since.IsZero() || cursorSince.After(f.Since) { |
| + f.Since = cursorSince |
| + } |
| + } |
| + limit = min(limit, appLogsPageCap) |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap) |
| + if err != nil { |
| + log.Printf("console: app logs data %s/%s: %v", acct.Sqid, app, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the logs") |
| + return |
| + } |
| + resp := appLogsDataResponse{Gap: trimmed && hasCursor} |
| + if hasCursor { |
| + var after []applog.Record |
| + for _, rec := range recs { |
| + if cur.Before(rec) { |
| + after = append(after, rec) |
| + } |
| + } |
| + if len(after) > limit { |
| + after = after[:limit] |
| + resp.Truncated = true |
| + } |
| + recs = after |
| + } else if len(recs) > limit { |
| + recs = recs[len(recs)-limit:] |
| + resp.Truncated = true |
| + } |
| + for _, line := range logLines(recs) { |
| + resp.Lines = append(resp.Lines, appLogsDataLine{ |
| + Time: line.Time, Stream: line.Stream, Host: line.Host, |
| + Msg: line.Msg, Fields: line.Fields, Tone: line.Tone, |
| + }) |
| + } |
| + if n := len(recs); n > 0 { |
| + resp.Cursor = applog.EncodeCursor(recs[n-1]) |
| + } else { |
| + resp.Cursor = cursorText |
| + } |
| + if resp.Lines == nil { |
| + resp.Lines = []appLogsDataLine{} |
| + } |
| + writeJSON(w, http.StatusOK, resp) |
| +} |
| + |
| +// browserLogsFilterFromQuery keeps the CLI's filter parser as the one source |
| +// of validation while making the console's search box literal. A stale |
| +// console URL carrying grep continues to use its original regular expression. |
| +func browserLogsFilterFromQuery(r *http.Request, now time.Time) (applog.Filter, int, string) { |
| + text := r.URL.Query().Get("text") |
| + if text == "" { |
| + return logsFilterFromQuery(r, now) |
| + } |
| + r2 := r.Clone(r.Context()) |
| + u := *r.URL |
| + q := u.Query() |
| + q.Set("grep", regexp.QuoteMeta(text)) |
| + q.Del("text") |
| + u.RawQuery = q.Encode() |
| + r2.URL = &u |
| + return logsFilterFromQuery(r2, now) |
| +} |
| + |
| // 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| index cd499df72..f4609771d 100644 |
| --- a/internal/console/templates/app_logs.html |
| +++ b/internal/console/templates/app_logs.html |
| @@ -5,7 +5,13 @@ |
| <h2>Logs</h2> |
| {{if .LogsErr}}<p class="banner error">{{.LogsErr}}</p>{{end}} |
| -<form method="get" class="small"> |
| +<form method="get" class="small" id="logs-filter" data-url="{{.Base}}/apps/{{.AppName}}/logs/data" data-cursor="{{.LogCursor}}" data-follow-error="{{.LogCopy.FollowError}}" data-refreshed="{{.LogCopy.Refreshed}}"> |
| + <label>{{.LogCopy.Instance}} |
| + <select name="host"> |
| + <option value="">{{.LogCopy.AllInstances}}</option> |
| + {{range .LogHosts}}<option value="{{.}}"{{if eq . $.LogHost}} selected{{end}}>{{.}}</option>{{end}} |
| + </select> |
| + </label> |
| <label>Show |
| <select name="stream"> |
| <option value=""{{if eq .LogStream ""}} selected{{end}}>everything</option> |
| @@ -15,6 +21,7 @@ |
| </label> |
| <label>from the last |
| <select name="since"> |
| + {{if and (ne .LogSince "15m") (ne .LogSince "1h") (ne .LogSince "6h") (ne .LogSince "24h") (ne .LogSince "168h")}}<option value="{{.LogSince}}" selected>{{.LogSince}}</option>{{end}} |
| <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> |
| @@ -22,30 +29,165 @@ |
| <option value="168h"{{if eq .LogSince "168h"}} selected{{end}}>week</option> |
| </select> |
| </label> |
| + <label>{{.LogCopy.AllLevels}} |
| + <select name="level"> |
| + <option value=""{{if eq .LogLevel ""}} selected{{end}}>{{.LogCopy.AllLevels}}</option> |
| + <option value="debug"{{if eq .LogLevel "debug"}} selected{{end}}>debug</option> |
| + <option value="info"{{if eq .LogLevel "info"}} selected{{end}}>info</option> |
| + <option value="warn"{{if eq .LogLevel "warn"}} selected{{end}}>warn</option> |
| + <option value="error"{{if eq .LogLevel "error"}} selected{{end}}>error</option> |
| + </select> |
| + </label> |
| + <label>{{.LogCopy.Search}} |
| + <input type="search" name="text" value="{{.LogGrep}}"> |
| + </label> |
| + {{if .LogLegacyGrep}}<input type="hidden" name="grep" value="{{.LogLegacyGrep}}">{{end}} |
| <button type="submit">Update</button> |
| + <button type="button" id="log-follow">{{.LogCopy.Follow}}</button> |
| + <button type="button" id="log-pause" hidden>{{.LogCopy.Pause}}</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="term" id="logterm" role="log" aria-label="Log output" tabindex="0"> |
| +<div class="term" id="logterm" role="log" aria-label="Log output" tabindex="0" aria-live="polite"> |
| {{- 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 -}} |
| +{{if and (not .LogRows) (not .LogsErr)}}<div id="log-empty">No log lines in this window. <a href="{{.LogWidenURL}}">{{.LogCopy.Widen}}</a></div>{{end}} |
| </div> |
| +<p class="small" id="log-status" aria-live="polite"></p> |
| +{{if .LogRows}} |
| <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 |
| (wakes, restarts, failures) appear even when the app itself printed nothing. A |
| site served from files alone has no app output.</p> |
| {{end}} |
| +<script> |
| +(function () { |
| + var form = document.getElementById("logs-filter"); |
| + var term = document.getElementById("logterm"); |
| + var follow = document.getElementById("log-follow"); |
| + var pause = document.getElementById("log-pause"); |
| + var status = document.getElementById("log-status"); |
| + if (!form || !term || !follow || !pause || !status) return; |
| + var cursor = form.dataset.cursor || ""; |
| + var timer = 0; |
| + var request = null; |
| + var fetchTimeout = 0; |
| + var timedOut = false; |
| + var following = false; |
| + var generation = 0; |
| + var dirty = false; |
| + var activeQuery = new URLSearchParams(new FormData(form)); |
| + |
| + function setFollowing(on) { |
| + var moveFocus = document.activeElement === (on ? follow : pause); |
| + following = on; |
| + follow.hidden = on; |
| + pause.hidden = !on; |
| + var q = new URLSearchParams(activeQuery); |
| + if (on) q.set("follow", "1"); else q.delete("follow"); |
| + history.replaceState(null, "", location.pathname + "?" + q.toString()); |
| + if (moveFocus) (on ? pause : follow).focus(); |
| + } |
| + function addSpan(row, cls, value, prefix) { |
| + if (!value) return; |
| + if (prefix) row.appendChild(document.createTextNode(prefix)); |
| + var span = document.createElement("span"); |
| + span.className = cls; |
| + span.textContent = value; |
| + row.appendChild(span); |
| + } |
| + function append(line) { |
| + var empty = document.getElementById("log-empty"); |
| + if (empty) empty.remove(); |
| + var row = document.createElement("div"); |
| + row.className = "tl" + (line.tone ? " tl-" + line.tone : ""); |
| + addSpan(row, "tl-t", line.time, ""); |
| + addSpan(row, "tl-s", line.stream, " "); |
| + addSpan(row, "tl-h", line.host, " "); |
| + addSpan(row, "tl-m", line.msg, " "); |
| + addSpan(row, "tl-f", line.fields, ""); |
| + term.appendChild(row); |
| + while (term.querySelectorAll(".tl").length > 200) term.querySelector(".tl").remove(); |
| + term.scrollTop = term.scrollHeight; |
| + } |
| + function clearLines() { |
| + term.querySelectorAll(".tl").forEach(function (row) { row.remove(); }); |
| + } |
| + function stop() { |
| + generation++; |
| + following = false; |
| + if (timer) window.clearTimeout(timer); |
| + timer = 0; |
| + if (request) request.abort(); |
| + request = null; |
| + if (fetchTimeout) window.clearTimeout(fetchTimeout); |
| + fetchTimeout = 0; |
| + timedOut = false; |
| + term.setAttribute("aria-busy", "false"); |
| + setFollowing(false); |
| + } |
| + function poll() { |
| + if (!following) return; |
| + var thisGeneration = generation; |
| + var q = new URLSearchParams(activeQuery); |
| + var hadCursor = Boolean(cursor); |
| + q.set("limit", "200"); |
| + if (cursor) q.set("cursor", cursor); |
| + request = new AbortController(); |
| + timedOut = false; |
| + var thisRequest = request; |
| + fetchTimeout = window.setTimeout(function () { |
| + if (following && thisGeneration === generation && request === thisRequest) { |
| + timedOut = true; |
| + thisRequest.abort(); |
| + } |
| + }, 20000); |
| + term.setAttribute("aria-busy", "true"); |
| + fetch(form.dataset.url + "?" + q.toString(), {signal: request.signal, headers: {"Accept": "application/json"}}) |
| + .then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) |
| + .then(function (data) { |
| + if (!following || thisGeneration !== generation) return; |
| + window.clearTimeout(fetchTimeout); |
| + fetchTimeout = 0; |
| + if (data.gap) { |
| + cursor = ""; |
| + stop(); |
| + status.textContent = form.dataset.followError; |
| + return; |
| + } |
| + if (!hadCursor) clearLines(); |
| + data.lines.forEach(append); |
| + cursor = data.cursor || cursor; |
| + status.textContent = form.dataset.refreshed + " " + new Date().toLocaleTimeString(); |
| + term.setAttribute("aria-busy", "false"); |
| + timer = window.setTimeout(poll, 2000); |
| + }) |
| + .catch(function (err) { |
| + window.clearTimeout(fetchTimeout); |
| + fetchTimeout = 0; |
| + if (err.name === "AbortError" && !timedOut) return; |
| + if (!following || thisGeneration !== generation) return; |
| + stop(); |
| + status.textContent = form.dataset.followError; |
| + }); |
| + } |
| + form.addEventListener("input", function () { dirty = true; if (following) stop(); }); |
| + form.addEventListener("change", function () { dirty = true; if (following) stop(); }); |
| + follow.addEventListener("click", function () { |
| + if (dirty) { |
| + var q = new URLSearchParams(new FormData(form)); |
| + q.set("follow", "1"); |
| + location.assign(location.pathname + "?" + q.toString()); |
| + return; |
| + } |
| + setFollowing(true); |
| + poll(); |
| + }); |
| + pause.addEventListener("click", stop); |
| + term.scrollTop = term.scrollHeight; |
| + {{if .LogFollow}}setFollowing(true); poll();{{end}} |
| +})(); |
| +</script> |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 35eba307f..2c8be0b8b 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -21,6 +21,7 @@ |
| --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f; |
| } |
| } |
| + [hidden] { display: none !important; } |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| background: var(--bg); color: var(--ink); |
| @@ -166,10 +167,14 @@ |
| 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. */ |
| + #logs-filter { display: flex; flex-wrap: wrap; align-items: end; gap: 0.65rem; margin-bottom: 1rem; } |
| + #logs-filter label { display: flex; flex-direction: column; gap: 0.25rem; max-width: 100%; } |
| + #logs-filter select, #logs-filter input { max-width: 100%; margin: 0; } |
| + #logs-filter select[name="host"] { max-width: 28rem; } |
| .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; |
| + min-height: 16rem; 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 |