mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-10-23 13:32:53 +00:00
Log pagination and styling
This commit is contained in:
1 parent
857a8f13c4
commit
3c0f4d05b0
4 files changed
+262
-62
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user