Files
MeshTender/internal/store/command_log.go
T

224 lines
8.0 KiB
Go

package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
// CommandLogEntry is one row of the command audit log, joined with the sender's
// name for display.
type CommandLogEntry struct {
ID int64
RepeaterID int64
UserID *int64
SenderName string // username snapshotted at send time; "(deleted)" if unknown
CommandText string
SentAt time.Time
AckReceived bool
ResponseText *string
}
// LogCommand records a command send attempt within a session and returns its
// id. commandID may be 0 (stored NULL) when there's no catalog match.
func (s *Store) LogCommand(ctx context.Context, repeaterID, userID, sessionID, commandID int64, text string) (int64, error) {
var id int64
err := s.pool.QueryRow(ctx, `
INSERT INTO command_log (repeater_id, user_id, session_id, command_id, command_text, sender_username)
VALUES ($1, $2, $3, $4, $5, (SELECT username FROM users WHERE id = $2)) RETURNING id`,
repeaterID, userID, sessionID, nullID(commandID), text).Scan(&id)
if err != nil {
return 0, fmt.Errorf("log command: %w", err)
}
return id, nil
}
func nullID(id int64) *int64 {
if id == 0 {
return nil
}
return &id
}
// MarkCommandReply records that a reply was received for a logged command.
func (s *Store) MarkCommandReply(ctx context.Context, logID int64, response string) error {
_, err := s.pool.Exec(ctx,
`UPDATE command_log SET ack_received = TRUE, response_text = $2 WHERE id = $1`,
logID, response)
if err != nil {
return fmt.Errorf("mark command reply: %w", err)
}
return nil
}
// CommandSession groups command-log entries by the console session they were
// sent in.
type CommandSession struct {
ID int64
// SenderName is who held the session: their current display name (or username),
// falling back to the username snapshotted at session start if they've since
// been deleted.
SenderName string
// SenderUsername is the sender's current username, for linking to their profile
// (/u/{username}); empty when the user has been deleted (no profile to link).
SenderUsername string
StartedAt time.Time
EndedAt *time.Time
Entries []*CommandLogEntry // newest first, matching the newest-first session order
}
// 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 newest first) 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
}
// 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(u.display_name, ''), u.username, cs.sender_username, '(deleted)'),
COALESCE(u.username, '')
FROM console_sessions cs
LEFT JOIN users u ON u.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)
}
groups, err := collectRows(headers, func(r pgx.Row) (*CommandSession, error) {
var g CommandSession
err := r.Scan(&g.ID, &g.StartedAt, &g.EndedAt, &g.SenderName, &g.SenderUsername)
return &g, err
})
if err != nil {
return nil, false, fmt.Errorf("scan session: %w", 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, newest first so each
// session's slice matches the newest-first session order (consistent direction
// throughout the log).
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 DESC, l.id DESC`, 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
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)
}
if g := byID[sessionID]; g != nil {
g.Entries = append(g.Entries, &e)
}
}
return groups, hasMore, rows.Err()
}
// OwnerCommandLogEntry is a recent command across all repeaters a user owns,
// joined with the repeater's name and the sender's name (for the dashboard).
type OwnerCommandLogEntry struct {
RepeaterID int64
RepeaterPublicID string
RepeaterName string
SenderName string
SenderUsername string // current username for a profile link; empty if deleted
CommandText string
SentAt time.Time
AckReceived bool
ResponseText *string
}
// ListRecentCommandsForOwner returns the most recent commands run on any
// repeater owned by ownerID, newest first, bounded by limit.
func (s *Store) ListRecentCommandsForOwner(ctx context.Context, ownerID int64, limit int) ([]OwnerCommandLogEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT l.repeater_id, r.public_id, r.name,
COALESCE(NULLIF(u.display_name, ''), u.username, l.sender_username, '(deleted)'),
COALESCE(u.username, ''),
l.command_text, l.sent_at, l.ack_received, l.response_text
FROM command_log l
JOIN repeaters r ON r.id = l.repeater_id
LEFT JOIN users u ON u.id = l.user_id
WHERE r.owner_id = $1
ORDER BY l.sent_at DESC
LIMIT $2`, ownerID, limit)
if err != nil {
return nil, fmt.Errorf("list owner commands: %w", err)
}
return collectRows(rows, func(r pgx.Row) (OwnerCommandLogEntry, error) {
var e OwnerCommandLogEntry
err := r.Scan(&e.RepeaterID, &e.RepeaterPublicID, &e.RepeaterName, &e.SenderName, &e.SenderUsername,
&e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText)
return e, err
})
}
// ListCommandLog returns recent log entries for a repeater, newest first.
func (s *Store) ListCommandLog(ctx context.Context, repeaterID int64, limit int) ([]*CommandLogEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT l.id, l.repeater_id, l.user_id,
COALESCE(l.sender_username, '(deleted)'),
l.command_text, l.sent_at, l.ack_received, l.response_text
FROM command_log l
WHERE l.repeater_id = $1
ORDER BY l.sent_at DESC
LIMIT $2`, repeaterID, limit)
if err != nil {
return nil, fmt.Errorf("list command log: %w", err)
}
return collectRows(rows, func(r pgx.Row) (*CommandLogEntry, error) {
var e CommandLogEntry
err := r.Scan(&e.ID, &e.RepeaterID, &e.UserID, &e.SenderName,
&e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText)
return &e, err
})
}