From 3c0f4d05b046acfc6ad2d4f9577fd9f8e72ebad4 Mon Sep 17 00:00:00 2001 From: Jonathon Leight Date: Sat, 20 Jun 2026 18:32:17 -0400 Subject: [PATCH] Log pagination and styling --- internal/store/command_log.go | 117 ++++++++++++++++------ internal/store/command_log_paging_test.go | 93 +++++++++++++++++ internal/web/audit.go | 48 ++++++++- internal/web/templates/command_log.html | 66 +++++++----- 4 files changed, 262 insertions(+), 62 deletions(-) create mode 100644 internal/store/command_log_paging_test.go diff --git a/internal/store/command_log.go b/internal/store/command_log.go index fe9c454..0f61032 100644 --- a/internal/store/command_log.go +++ b/internal/store/command_log.go @@ -61,47 +61,100 @@ type CommandSession struct { Entries []*CommandLogEntry // chronological (oldest first) } -// ListCommandLogSessions returns recent command-log entries for a repeater -// grouped into console sessions, newest session first. limit bounds the number -// of underlying log rows scanned. -func (s *Store) ListCommandLogSessions(ctx context.Context, repeaterID int64, limit int) ([]*CommandSession, error) { - rows, err := s.pool.Query(ctx, ` - SELECT l.id, l.command_text, l.sent_at, l.ack_received, l.response_text, - l.session_id, cs.started_at, cs.ended_at, - COALESCE(NULLIF(su.display_name, ''), su.username, '(deleted)') - FROM command_log l - JOIN console_sessions cs ON cs.id = l.session_id - LEFT JOIN users su ON su.id = cs.user_id - WHERE l.repeater_id = $1 - ORDER BY l.sent_at DESC - LIMIT $2`, repeaterID, limit) - if err != nil { - return nil, fmt.Errorf("list command sessions: %w", err) +// CommandLogPageSize is the number of console sessions shown per log page. +const CommandLogPageSize = 25 + +// CommandLogCursor is the keyset position in a repeater's session log: the +// (started_at, id) of the last session on the current page. The next page seeks +// strictly older than it. +type CommandLogCursor struct { + StartedAt time.Time + ID int64 +} + +// ListCommandLogSessionsPage returns one keyset page of a repeater's command log +// grouped into console sessions, newest session first. before is the cursor from +// the previous page, or nil for the first page. It returns the sessions (each +// with its entries chronological) and whether older sessions remain. +// +// Sessions are the pagination unit (the page renders one card per session), so +// a session is never split across pages — and the per-page work is bounded by +// the page size, unlike the old fixed 500-row cap that silently hid older +// history. Both the session seek and the entry fetch ride existing indexes. +func (s *Store) ListCommandLogSessionsPage(ctx context.Context, repeaterID int64, before *CommandLogCursor) ([]*CommandSession, bool, error) { + first := before == nil + var curStarted time.Time + var curID int64 + if !first { + curStarted, curID = before.StartedAt, before.ID } - defer rows.Close() + // Fetch one extra session to detect whether an older page exists. Only + // sessions that actually logged a command are shown (matching the inner-join + // behavior of the previous query). + headers, err := s.pool.Query(ctx, ` + SELECT cs.id, cs.started_at, cs.ended_at, + COALESCE(NULLIF(su.display_name, ''), su.username, '(deleted)') + FROM console_sessions cs + LEFT JOIN users su ON su.id = cs.user_id + WHERE cs.repeater_id = $1 + AND ($2 OR (cs.started_at, cs.id) < ($3, $4)) + AND EXISTS (SELECT 1 FROM command_log l WHERE l.session_id = cs.id) + ORDER BY cs.started_at DESC, cs.id DESC + LIMIT $5`, repeaterID, first, curStarted, curID, CommandLogPageSize+1) + if err != nil { + return nil, false, fmt.Errorf("list command sessions: %w", err) + } + defer headers.Close() var groups []*CommandSession - byID := map[int64]*CommandSession{} + for headers.Next() { + var g CommandSession + if err := headers.Scan(&g.ID, &g.StartedAt, &g.EndedAt, &g.SenderName); err != nil { + return nil, false, fmt.Errorf("scan session: %w", err) + } + groups = append(groups, &g) + } + if err := headers.Err(); err != nil { + return nil, false, err + } + + hasMore := len(groups) > CommandLogPageSize + if hasMore { + groups = groups[:CommandLogPageSize] + } + if len(groups) == 0 { + return groups, false, nil + } + + // Index the page's sessions so the entry fetch can attach rows by session. + byID := make(map[int64]*CommandSession, len(groups)) + ids := make([]int64, 0, len(groups)) + for _, g := range groups { + byID[g.ID] = g + ids = append(ids, g.ID) + } + // Load all entries for the page's sessions at once, oldest first so each + // session's slice ends up chronological. + rows, err := s.pool.Query(ctx, ` + SELECT l.id, l.session_id, l.command_text, l.sent_at, l.ack_received, l.response_text + FROM command_log l + WHERE l.session_id = ANY($1) + ORDER BY l.sent_at ASC, l.id ASC`, ids) + if err != nil { + return nil, false, fmt.Errorf("list command entries: %w", err) + } + defer rows.Close() for rows.Next() { var e CommandLogEntry var sessionID int64 - var startedAt time.Time - var endedAt *time.Time - var sender string - if err := rows.Scan(&e.ID, &e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText, - &sessionID, &startedAt, &endedAt, &sender); err != nil { - return nil, fmt.Errorf("scan session row: %w", err) + if err := rows.Scan(&e.ID, &sessionID, &e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText); err != nil { + return nil, false, fmt.Errorf("scan entry: %w", err) } - g := byID[sessionID] - if g == nil { - g = &CommandSession{ID: sessionID, SenderName: sender, StartedAt: startedAt, EndedAt: endedAt} - byID[sessionID] = g - groups = append(groups, g) // encounter order = newest session first + if g := byID[sessionID]; g != nil { + g.Entries = append(g.Entries, &e) } - // Rows arrive newest-first; prepend to make each session chronological. - g.Entries = append([]*CommandLogEntry{&e}, g.Entries...) } - return groups, rows.Err() + return groups, hasMore, rows.Err() } // OwnerCommandLogEntry is a recent command across all repeaters a user owns, diff --git a/internal/store/command_log_paging_test.go b/internal/store/command_log_paging_test.go new file mode 100644 index 0000000..aebe969 --- /dev/null +++ b/internal/store/command_log_paging_test.go @@ -0,0 +1,93 @@ +package store + +import ( + "fmt" + "strings" + "testing" +) + +// TestListCommandLogSessionsPage walks the keyset-paginated session log and +// checks every session with commands appears once, newest-first, across pages — +// and that a session with no commands is omitted. +func TestListCommandLogSessionsPage(t *testing.T) { + st, ctx := orgTestStore(t) + + owner, err := st.CreateUser(ctx, "logowner", "") + if err != nil { + t.Fatal(err) + } + rep, err := st.CreateRepeater(ctx, &Repeater{ + OwnerID: owner.ID, Name: "R", PublicKeyHex: strings.Repeat("c", 64), + RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5, + }) + if err != nil { + t.Fatal(err) + } + + // More sessions than a page, each with a couple of commands. + total := CommandLogPageSize*2 + 3 + for i := 0; i < total; i++ { + sid, err := st.StartConsoleSession(ctx, rep.ID, owner.ID) + if err != nil { + t.Fatalf("start session %d: %v", i, err) + } + for j := 0; j < 2; j++ { + if _, err := st.LogCommand(ctx, rep.ID, owner.ID, sid, 0, fmt.Sprintf("cmd %d-%d", i, j)); err != nil { + t.Fatalf("log command: %v", err) + } + } + } + // A session with no commands must not show up in the log. + if _, err := st.StartConsoleSession(ctx, rep.ID, owner.ID); err != nil { + t.Fatal(err) + } + + var ( + seen = map[int64]bool{} + count int + cursor *CommandLogCursor + pages int + ) + var prev *CommandLogCursor + for { + page, hasMore, err := st.ListCommandLogSessionsPage(ctx, rep.ID, cursor) + if err != nil { + t.Fatalf("page: %v", err) + } + pages++ + if len(page) > CommandLogPageSize { + t.Fatalf("page returned %d sessions, exceeds %d", len(page), CommandLogPageSize) + } + for _, g := range page { + if seen[g.ID] { + t.Fatalf("session %d returned twice", g.ID) + } + seen[g.ID] = true + if len(g.Entries) == 0 { + t.Fatalf("session %d has no entries (should have been filtered)", g.ID) + } + // Strictly descending by (started_at, id). + if prev != nil { + if g.StartedAt.After(prev.StartedAt) || + (g.StartedAt.Equal(prev.StartedAt) && g.ID >= prev.ID) { + t.Fatalf("session order violated: (%v,%d) after (%v,%d)", + g.StartedAt, g.ID, prev.StartedAt, prev.ID) + } + } + prev = &CommandLogCursor{StartedAt: g.StartedAt, ID: g.ID} + count++ + } + if !hasMore { + break + } + last := page[len(page)-1] + cursor = &CommandLogCursor{StartedAt: last.StartedAt, ID: last.ID} + } + + if count != total { + t.Errorf("walked %d sessions across %d pages, want %d (empty session excluded)", count, pages, total) + } + if pages < 3 { + t.Errorf("expected at least 3 pages, got %d", pages) + } +} diff --git a/internal/web/audit.go b/internal/web/audit.go index 3881340..d9338da 100644 --- a/internal/web/audit.go +++ b/internal/web/audit.go @@ -1,10 +1,16 @@ package web import ( + "encoding/base64" + "encoding/json" "net/http" + "time" + + "github.com/jleight/meshtender/internal/store" ) -// pageCommandLog shows the command audit log for a repeater (owner only). +// pageCommandLog shows the command audit log for a repeater (owner only), +// keyset-paginated by console session via an opaque ?before token. func (s *Server) pageCommandLog(w http.ResponseWriter, r *http.Request) { owner := s.auth.CurrentUserID(r.Context()) id, ok := s.repeaterID(r) @@ -17,13 +23,47 @@ func (s *Server) pageCommandLog(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) // owner-only return } - sessions, err := s.store.ListCommandLogSessions(r.Context(), id, 500) + sessions, hasMore, err := s.store.ListCommandLogSessionsPage(r.Context(), id, decodeLogCursor(r.URL.Query().Get("before"))) if err != nil { http.Error(w, "could not load log", http.StatusInternalServerError) return } - s.render(w, r, "command_log.html", map[string]any{ + data := map[string]any{ "Repeater": rep, "Sessions": sessions, - }) + } + if hasMore && len(sessions) > 0 { + last := sessions[len(sessions)-1] + data["NextBefore"] = encodeLogCursor(last.StartedAt, last.ID) + } + s.render(w, r, "command_log.html", data) +} + +// logCursor is the wire form of a command-log keyset position. +type logCursor struct { + StartedAt time.Time `json:"t"` + ID int64 `json:"i"` +} + +// encodeLogCursor packs a session position into an opaque, URL-safe token. +func encodeLogCursor(startedAt time.Time, id int64) string { + b, _ := json.Marshal(logCursor{StartedAt: startedAt, ID: id}) + return base64.RawURLEncoding.EncodeToString(b) +} + +// decodeLogCursor reverses encodeLogCursor. A missing or malformed token decodes +// to nil — the first page — so a tampered URL just resets paging. +func decodeLogCursor(tok string) *store.CommandLogCursor { + if tok == "" { + return nil + } + b, err := base64.RawURLEncoding.DecodeString(tok) + if err != nil { + return nil + } + var c logCursor + if json.Unmarshal(b, &c) != nil { + return nil + } + return &store.CommandLogCursor{StartedAt: c.StartedAt, ID: c.ID} } diff --git a/internal/web/templates/command_log.html b/internal/web/templates/command_log.html index d8e1c71..0e75353 100644 --- a/internal/web/templates/command_log.html +++ b/internal/web/templates/command_log.html @@ -10,36 +10,50 @@ {{define "content"}}
-

+

Commands grouped by console session (one modem connection). “Confirmed” means a reply came back; no reply doesn't mean the command failed — it may have run without us hearing the response.

- {{if .Sessions}} - {{range .Sessions}} -
-

{{.SenderName}}

- {{.StartedAt.Format "2006-01-02 15:04"}}{{if .EndedAt}}–{{.EndedAt.Format "15:04"}}{{else}} · in progress{{end}} -
-
- - - - {{range .Entries}} - - - - - - - {{end}} - -
WhenCommandConfirmedResponse
{{.SentAt.Format "15:04:05"}}{{.CommandText}}{{if .AckReceived}}yes{{else}}no reply{{end}}{{if .ResponseText}}{{.ResponseText}}{{else}}—{{end}}
-
- {{end}} - {{else}} -

No commands have been sent yet.

- {{end}}
+{{if .Sessions}} + +{{if .NextBefore}} +
+ Show older sessions +
+{{end}} +{{else}} +
+

No commands have been sent yet.

+
+{{end}} {{template "icon-arrow-left" "me-1"}}Back to dashboard {{end}}