Log pagination and styling

This commit is contained in:
Jonathon Leight committed 2026-06-20 18:32:17 -04:00
1 parent 857a8f13c4
commit 3c0f4d05b0
4 files changed
+262 -62

No files matched your search

+85 -32
View File
@@ -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,
+93
View File
@@ -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)
}
}
+44 -4
View File
@@ -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}
}
+40 -26
View File
@@ -10,36 +10,50 @@
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
<p class="text-secondary mb-0">
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.
</p>
</div>
{{if .Sessions}}
{{range .Sessions}}
<div class="card-header bg-transparent">
<h3 class="card-title mb-0">{{.SenderName}}</h3>
<span class="text-secondary ms-2">{{.StartedAt.Format "2006-01-02 15:04"}}{{if .EndedAt}}–{{.EndedAt.Format "15:04"}}{{else}} · in progress{{end}}</span>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>When</th><th>Command</th><th>Confirmed</th><th>Response</th></tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td class="text-secondary">{{.SentAt.Format "15:04:05"}}</td>
<td><code>{{.CommandText}}</code></td>
<td>{{if .AckReceived}}<span class="badge bg-success-lt">yes</span>{{else}}<span class="badge bg-yellow-lt">no reply</span>{{end}}</td>
<td><code class="text-wrap">{{if .ResponseText}}{{.ResponseText}}{{else}}—{{end}}</code></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{else}}
<div class="card-body pt-0"><p class="text-secondary mb-0">No commands have been sent yet.</p></div>
{{end}}
</div>
{{if .Sessions}}
<ul class="timeline mt-3">
{{range .Sessions}}
<li class="timeline-event">
<div class="timeline-event-icon">{{template "icon-terminal" ""}}</div>
<div class="card timeline-event-card">
<div class="card-body">
<div class="text-secondary float-end">{{.StartedAt.Format "2006-01-02 15:04"}}{{if .EndedAt}}–{{.EndedAt.Format "15:04"}}{{else}} · in progress{{end}}</div>
<h4 class="mb-2">{{.SenderName}}</h4>
<div class="table-responsive">
<table class="table table-vcenter">
<thead><tr><th>When</th><th>Command</th><th>Confirmed</th><th>Response</th></tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td class="text-secondary">{{.SentAt.Format "15:04:05"}}</td>
<td><code>{{.CommandText}}</code></td>
<td>{{if .AckReceived}}<span class="badge bg-success-lt">yes</span>{{else}}<span class="badge bg-yellow-lt">no reply</span>{{end}}</td>
<td><code class="text-wrap">{{if .ResponseText}}{{.ResponseText}}{{else}}—{{end}}</code></td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
</div>
</li>
{{end}}
</ul>
{{if .NextBefore}}
<div class="text-center mt-3">
<a class="btn btn-outline-secondary" href="?before={{.NextBefore}}">Show older sessions</a>
</div>
{{end}}
{{else}}
<div class="card mt-3">
<div class="card-body"><p class="text-secondary mb-0">No commands have been sent yet.</p></div>
</div>
{{end}}
<a class="back-link mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
{{end}}