feat(server/diagnostics): device debug-reporting ingest + admin timeline + retention (M9)
New diagnostic_events table + per-account users.debug_mode_enabled flag.
When an account's flag is on, its client(s) POST a batch timeseries of
connectivity / UPnP-sync / power / lifecycle events to /api/diagnostics
(no-op 204 when off, kind whitelist mirrors the CHECK constraint).
Admin surface: GET /api/admin/diagnostics (optional account/device/kind/
time-window filters, RFC3339-or-epoch-ms, export-sized paging) + a
/diagnostics/devices overview + PUT /api/admin/users/{id}/debug-mode to
flip an account remotely while a bug is live. debug_mode_enabled is now
exposed on /api/me (client gate) and the admin user views.
Retention: a 30-day gc-worker sweep (GcPruneDiagnostics), keyed on the
server clock so a skewed device clock can't keep rows alive.
Refs Scribe M9 (#119), tasks #1172 #1173.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K55iTxn95BtshocgdE1shW
This commit is contained in:
@@ -22,6 +22,7 @@ type adminUserView struct {
|
|||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
AutoApproveRequests bool `json:"auto_approve_requests"`
|
AutoApproveRequests bool `json:"auto_approve_requests"`
|
||||||
|
DebugModeEnabled bool `json:"debug_mode_enabled"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ func (h *handlers) handleAdminListUsers(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
IsAdmin: row.IsAdmin,
|
IsAdmin: row.IsAdmin,
|
||||||
AutoApproveRequests: row.AutoApproveRequests,
|
AutoApproveRequests: row.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: row.DebugModeEnabled,
|
||||||
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: row.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
out = append(out, v)
|
out = append(out, v)
|
||||||
@@ -120,6 +122,7 @@ func (h *handlers) handleUpdateUserAdmin(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: updated.DisplayName,
|
DisplayName: updated.DisplayName,
|
||||||
IsAdmin: updated.IsAdmin,
|
IsAdmin: updated.IsAdmin,
|
||||||
AutoApproveRequests: updated.AutoApproveRequests,
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,7 @@ func (h *handlers) handleAdminCreateUser(w http.ResponseWriter, r *http.Request)
|
|||||||
DisplayName: user.DisplayName,
|
DisplayName: user.DisplayName,
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
AutoApproveRequests: user.AutoApproveRequests,
|
AutoApproveRequests: user.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: user.DebugModeEnabled,
|
||||||
CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: user.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -329,6 +333,7 @@ func (h *handlers) handleAdminAutoApproveToggle(w http.ResponseWriter, r *http.R
|
|||||||
DisplayName: updated.DisplayName,
|
DisplayName: updated.DisplayName,
|
||||||
IsAdmin: updated.IsAdmin,
|
IsAdmin: updated.IsAdmin,
|
||||||
AutoApproveRequests: updated.AutoApproveRequests,
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
// load failures). Admin-only inbox; any user can report.
|
// load failures). Admin-only inbox; any user can report.
|
||||||
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
authed.Post("/playback-errors", h.handleReportPlaybackError)
|
||||||
|
|
||||||
|
// Device diagnostics ingest (M9). Any signed-in user can
|
||||||
|
// POST a batch, but events are only stored when the
|
||||||
|
// account's debug_mode_enabled flag is on (handler no-ops
|
||||||
|
// otherwise). Admin views live under /admin/diagnostics.
|
||||||
|
authed.Post("/diagnostics", h.handleReportDiagnostics)
|
||||||
|
|
||||||
// Self-hosted in-app update channel (#397). Auth-gated to
|
// Self-hosted in-app update channel (#397). Auth-gated to
|
||||||
// prevent anonymous bandwidth abuse on the APK stream;
|
// prevent anonymous bandwidth abuse on the APK stream;
|
||||||
// /apk additionally per-user rate-limited.
|
// /apk additionally per-user rate-limited.
|
||||||
@@ -181,6 +187,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
admin.Delete("/users/{id}", h.handleAdminDeleteUser)
|
||||||
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
admin.Post("/users/{id}/reset-password", h.handleAdminResetPassword)
|
||||||
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
admin.Put("/users/{id}/auto-approve", h.handleAdminAutoApproveToggle)
|
||||||
|
admin.Put("/users/{id}/debug-mode", h.handleAdminDebugModeToggle)
|
||||||
|
|
||||||
|
// Device diagnostics timeline + device overview (M9).
|
||||||
|
admin.Get("/diagnostics", h.handleListAdminDiagnostics)
|
||||||
|
admin.Get("/diagnostics/devices", h.handleListAdminDiagnosticDevices)
|
||||||
|
|
||||||
admin.Get("/cover-sources", h.handleListCoverSources)
|
admin.Get("/cover-sources", h.handleListCoverSources)
|
||||||
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
admin.Patch("/cover-sources/{provider_id}", h.handleUpdateCoverSource)
|
||||||
|
|||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validDiagnosticKinds is the COARSE category whitelist. Mirrors the
|
||||||
|
// CHECK constraint in migration 0036 — keep both in sync (per the
|
||||||
|
// enum-CHECK-whitelist rule). The finer event discriminator + all
|
||||||
|
// event-specific fields live inside each event's payload, so new event
|
||||||
|
// variants do NOT require a new category here.
|
||||||
|
var validDiagnosticKinds = map[string]struct{}{
|
||||||
|
"connectivity": {},
|
||||||
|
"upnp_sync": {},
|
||||||
|
"power": {},
|
||||||
|
"lifecycle": {},
|
||||||
|
"heartbeat": {},
|
||||||
|
"http": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxDiagnosticBatch caps a single ingest call. The client buffers and
|
||||||
|
// flushes in batches of ~100; 500 leaves generous headroom while
|
||||||
|
// bounding a misbehaving client's per-request work.
|
||||||
|
const maxDiagnosticBatch = 500
|
||||||
|
|
||||||
|
// Diagnostics list paging. Larger than the generic parsePaging caps
|
||||||
|
// because the admin timeline is meant to be exported as a slice and
|
||||||
|
// handed off for analysis — a heartbeat-dense window has many rows.
|
||||||
|
const (
|
||||||
|
defaultDiagnosticPageSize = 500
|
||||||
|
maxDiagnosticPageSize = 5000
|
||||||
|
)
|
||||||
|
|
||||||
|
// diagnosticEventIn is one event in a POST /api/diagnostics batch.
|
||||||
|
// OccurredAt is the client device clock as epoch milliseconds (may be
|
||||||
|
// skewed — the server also stamps received_at). Payload is opaque JSON
|
||||||
|
// carrying the event's sub-type + fields.
|
||||||
|
type diagnosticEventIn struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
OccurredAt int64 `json:"occurred_at"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportDiagnosticsRequest is the body of POST /api/diagnostics — one
|
||||||
|
// batch from one device.
|
||||||
|
type reportDiagnosticsRequest struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
AppVersion *string `json:"app_version,omitempty"`
|
||||||
|
OsVersion *string `json:"os_version,omitempty"`
|
||||||
|
Events []diagnosticEventIn `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReportDiagnostics implements POST /api/diagnostics. Any signed-in
|
||||||
|
// user can call it, but events are only stored when the account's
|
||||||
|
// debug_mode_enabled flag is on; otherwise the call is a 204 no-op so the
|
||||||
|
// client can safely drop the batch (it gates locally on /api/me, this is
|
||||||
|
// the race-safe backstop for "admin just disabled debug").
|
||||||
|
func (h *handlers) handleReportDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !user.DebugModeEnabled {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req reportDiagnosticsRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.ClientID) == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "client_id required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Events) == 0 {
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Events) > maxDiagnosticBatch {
|
||||||
|
writeErr(w, apierror.BadRequest("batch_too_large", "too many events in one batch"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range req.Events {
|
||||||
|
if _, valid := validDiagnosticKinds[req.Events[i].Kind]; !valid {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_kind", "unknown diagnostic kind"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := pgx.BeginFunc(r.Context(), h.pool, func(tx pgx.Tx) error {
|
||||||
|
q := dbq.New(tx)
|
||||||
|
for i := range req.Events {
|
||||||
|
ev := req.Events[i]
|
||||||
|
payload := ev.Payload
|
||||||
|
if len(payload) == 0 {
|
||||||
|
payload = json.RawMessage("{}")
|
||||||
|
}
|
||||||
|
if _, err := q.InsertDiagnosticEvent(r.Context(), dbq.InsertDiagnosticEventParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
ClientID: req.ClientID,
|
||||||
|
AppVersion: req.AppVersion,
|
||||||
|
OsVersion: req.OsVersion,
|
||||||
|
Kind: ev.Kind,
|
||||||
|
Payload: payload,
|
||||||
|
OccurredAt: pgtype.Timestamptz{Time: time.UnixMilli(ev.OccurredAt).UTC(), Valid: true},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "diagnostics: insert batch failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]int{"accepted": len(req.Events)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDiagnosticView is one row in the admin timeline response. Payload
|
||||||
|
// is passed through verbatim as raw JSON so the SPA can render / export
|
||||||
|
// the structured event without the server re-shaping it.
|
||||||
|
type adminDiagnosticView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
AppVersion *string `json:"app_version,omitempty"`
|
||||||
|
OsVersion *string `json:"os_version,omitempty"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
OccurredAt string `json:"occurred_at"`
|
||||||
|
ReceivedAt string `json:"received_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAdminDiagnostics implements GET /api/admin/diagnostics with
|
||||||
|
// optional filters user_id, client_id, kind, from, to (RFC3339 or epoch
|
||||||
|
// millis) plus limit/offset. Newest-first; the SPA reverses for a
|
||||||
|
// chronological timeline + export.
|
||||||
|
func (h *handlers) handleListAdminDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
params, ok := h.buildDiagnosticsFilter(w, q)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListAdminDiagnostics(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list diagnostics failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]adminDiagnosticView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, adminDiagnosticView{
|
||||||
|
ID: uuidToString(row.ID),
|
||||||
|
UserID: uuidToString(row.UserID),
|
||||||
|
Username: row.Username,
|
||||||
|
ClientID: row.ClientID,
|
||||||
|
AppVersion: row.AppVersion,
|
||||||
|
OsVersion: row.OsVersion,
|
||||||
|
Kind: row.Kind,
|
||||||
|
Payload: json.RawMessage(row.Payload),
|
||||||
|
OccurredAt: formatTimestamp(row.OccurredAt),
|
||||||
|
ReceivedAt: formatTimestamp(row.ReceivedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDiagnosticsFilter parses the query string into a query param
|
||||||
|
// struct, writing a 400 and returning ok=false on a malformed time.
|
||||||
|
func (h *handlers) buildDiagnosticsFilter(
|
||||||
|
w http.ResponseWriter, q map[string][]string,
|
||||||
|
) (dbq.ListAdminDiagnosticsParams, bool) {
|
||||||
|
get := func(k string) string {
|
||||||
|
if v, present := q[k]; present && len(v) > 0 {
|
||||||
|
return v[0]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
limit, offset, err := parseDiagPaging(get("limit"), get("offset"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid limit or offset"))
|
||||||
|
return dbq.ListAdminDiagnosticsParams{}, false
|
||||||
|
}
|
||||||
|
from, okFrom := parseDiagTime(get("from"))
|
||||||
|
to, okTo := parseDiagTime(get("to"))
|
||||||
|
if !okFrom || !okTo {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid from/to timestamp"))
|
||||||
|
return dbq.ListAdminDiagnosticsParams{}, false
|
||||||
|
}
|
||||||
|
params := dbq.ListAdminDiagnosticsParams{
|
||||||
|
ClientID: optionalQuery(get("client_id")),
|
||||||
|
Kind: optionalQuery(get("kind")),
|
||||||
|
FromTs: from,
|
||||||
|
ToTs: to,
|
||||||
|
Off: int32(offset),
|
||||||
|
Lim: int32(limit),
|
||||||
|
}
|
||||||
|
if uid, valid := parseUUID(get("user_id")); valid {
|
||||||
|
params.UserID = uid
|
||||||
|
}
|
||||||
|
return params, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDiagnosticDeviceView is one device in the "who is reporting"
|
||||||
|
// overview / device filter dropdown.
|
||||||
|
type adminDiagnosticDeviceView struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
OsVersion string `json:"os_version"`
|
||||||
|
LastSeen string `json:"last_seen"`
|
||||||
|
EventCount int64 `json:"event_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAdminDiagnosticDevices implements GET
|
||||||
|
// /api/admin/diagnostics/devices?user_id=. Lists distinct reporting
|
||||||
|
// devices so the admin UI can populate its device filter.
|
||||||
|
func (h *handlers) handleListAdminDiagnosticDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var userID pgtype.UUID
|
||||||
|
if uid, valid := parseUUID(r.URL.Query().Get("user_id")); valid {
|
||||||
|
userID = uid
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListDiagnosticDevices(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: list diagnostic devices failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]adminDiagnosticDeviceView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, adminDiagnosticDeviceView{
|
||||||
|
ClientID: row.ClientID,
|
||||||
|
UserID: uuidToString(row.UserID),
|
||||||
|
Username: row.Username,
|
||||||
|
AppVersion: row.AppVersion,
|
||||||
|
OsVersion: row.OsVersion,
|
||||||
|
LastSeen: formatTimestamp(row.LastSeen),
|
||||||
|
EventCount: row.EventCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminDebugModeReq is the body of PUT /api/admin/users/{id}/debug-mode.
|
||||||
|
type adminDebugModeReq struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAdminDebugModeToggle implements PUT
|
||||||
|
// /api/admin/users/{id}/debug-mode — flip an account's diagnostics
|
||||||
|
// opt-in remotely while a bug is live. Echoes the updated user.
|
||||||
|
func (h *handlers) handleAdminDebugModeToggle(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetID, ok := requireURLUUID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req adminDebugModeReq
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := dbq.New(h.pool).SetDebugMode(r.Context(), dbq.SetDebugModeParams{
|
||||||
|
ID: targetID,
|
||||||
|
DebugModeEnabled: req.Enabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeErrWithLog(w, h.logger, "admin: set debug mode failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Info("admin: debug mode toggled",
|
||||||
|
"target_user", uuidToString(targetID), "enabled", req.Enabled)
|
||||||
|
writeJSON(w, http.StatusOK, adminUserView{
|
||||||
|
ID: uuidToString(updated.ID),
|
||||||
|
Username: updated.Username,
|
||||||
|
DisplayName: updated.DisplayName,
|
||||||
|
IsAdmin: updated.IsAdmin,
|
||||||
|
AutoApproveRequests: updated.AutoApproveRequests,
|
||||||
|
DebugModeEnabled: updated.DebugModeEnabled,
|
||||||
|
CreatedAt: updated.CreatedAt.Time.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiagPaging reads limit/offset with diagnostics-specific caps.
|
||||||
|
// Blank → defaults; out-of-range clamps; non-numeric → error.
|
||||||
|
func parseDiagPaging(rawLimit, rawOffset string) (limit, offset int, err error) {
|
||||||
|
limit = defaultDiagnosticPageSize
|
||||||
|
if s := strings.TrimSpace(rawLimit); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, perr
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > maxDiagnosticPageSize {
|
||||||
|
n = maxDiagnosticPageSize
|
||||||
|
}
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(rawOffset); s != "" {
|
||||||
|
n, perr := strconv.Atoi(s)
|
||||||
|
if perr != nil {
|
||||||
|
return 0, 0, perr
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return limit, offset, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalQuery returns nil for an absent/blank query value so the sqlc
|
||||||
|
// NULL-or-equals filter treats it as "no filter."
|
||||||
|
func optionalQuery(raw string) *string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiagTime accepts RFC3339 or epoch-millis. Empty = absent (NULL
|
||||||
|
// filter, ok=true). Returns ok=false only on a non-empty unparseable value.
|
||||||
|
func parseDiagTime(raw string) (pgtype.Timestamptz, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return pgtype.Timestamptz{}, true
|
||||||
|
}
|
||||||
|
if t, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||||
|
return pgtype.Timestamptz{Time: t.UTC(), Valid: true}, true
|
||||||
|
}
|
||||||
|
if ms, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||||
|
return pgtype.Timestamptz{Time: time.UnixMilli(ms).UTC(), Valid: true}, true
|
||||||
|
}
|
||||||
|
return pgtype.Timestamptz{}, false
|
||||||
|
}
|
||||||
@@ -35,6 +35,10 @@ type meProfileResp struct {
|
|||||||
DisplayName *string `json:"display_name"`
|
DisplayName *string `json:"display_name"`
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
// DebugModeEnabled is the account's diagnostics opt-in. The client
|
||||||
|
// reads it here to decide whether to run the on-device diagnostics
|
||||||
|
// reporter (M9). Admin-set; the client also has a local OFF switch.
|
||||||
|
DebugModeEnabled bool `json:"debug_mode_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -120,10 +124,11 @@ func (h *handlers) handleUpdateMyProfile(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func profileViewFromUser(u dbq.User) meProfileResp {
|
func profileViewFromUser(u dbq.User) meProfileResp {
|
||||||
return meProfileResp{
|
return meProfileResp{
|
||||||
ID: uuidToString(u.ID),
|
ID: uuidToString(u.ID),
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
DisplayName: u.DisplayName,
|
DisplayName: u.DisplayName,
|
||||||
Email: u.Email,
|
Email: u.Email,
|
||||||
IsAdmin: u.IsAdmin,
|
IsAdmin: u.IsAdmin,
|
||||||
|
DebugModeEnabled: u.DebugModeEnabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: diagnostics.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gcPruneDiagnostics = `-- name: GcPruneDiagnostics :execrows
|
||||||
|
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days'
|
||||||
|
`
|
||||||
|
|
||||||
|
// Retention sweep for the gc worker. Deletes diagnostic_events older
|
||||||
|
// than 30 days by server clock (received_at) so a skewed client clock
|
||||||
|
// can't keep rows alive. Matches the other gc lifecycle sweeps'
|
||||||
|
// hardcoded-threshold style; diagnostics are debug telemetry, not user
|
||||||
|
// data, and 30 days is ample to investigate a live incident after the
|
||||||
|
// fact. Returns the affected row count for the sweep log line.
|
||||||
|
func (q *Queries) GcPruneDiagnostics(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, gcPruneDiagnostics)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertDiagnosticEvent = `-- name: InsertDiagnosticEvent :one
|
||||||
|
INSERT INTO diagnostic_events (
|
||||||
|
user_id, client_id, app_version, os_version, kind, payload, occurred_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, received_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertDiagnosticEventParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ClientID string
|
||||||
|
AppVersion *string
|
||||||
|
OsVersion *string
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
OccurredAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type InsertDiagnosticEventRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
ReceivedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records one client-reported diagnostic event. The writer validates the
|
||||||
|
// `kind` category against its whitelist before this runs; the CHECK
|
||||||
|
// constraint is the belt-and-braces guard. payload carries the finer
|
||||||
|
// event discriminator + all event-specific fields. Returns received_at
|
||||||
|
// so the caller can confirm ingest.
|
||||||
|
func (q *Queries) InsertDiagnosticEvent(ctx context.Context, arg InsertDiagnosticEventParams) (InsertDiagnosticEventRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, insertDiagnosticEvent,
|
||||||
|
arg.UserID,
|
||||||
|
arg.ClientID,
|
||||||
|
arg.AppVersion,
|
||||||
|
arg.OsVersion,
|
||||||
|
arg.Kind,
|
||||||
|
arg.Payload,
|
||||||
|
arg.OccurredAt,
|
||||||
|
)
|
||||||
|
var i InsertDiagnosticEventRow
|
||||||
|
err := row.Scan(&i.ID, &i.ReceivedAt)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAdminDiagnostics = `-- name: ListAdminDiagnostics :many
|
||||||
|
SELECT
|
||||||
|
de.id AS id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.app_version AS app_version,
|
||||||
|
de.os_version AS os_version,
|
||||||
|
de.kind AS kind,
|
||||||
|
de.payload AS payload,
|
||||||
|
de.occurred_at AS occurred_at,
|
||||||
|
de.received_at AS received_at
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||||
|
AND ($2::text IS NULL OR de.client_id = $2::text)
|
||||||
|
AND ($3::text IS NULL OR de.kind = $3::text)
|
||||||
|
AND ($4::timestamptz IS NULL
|
||||||
|
OR de.occurred_at >= $4::timestamptz)
|
||||||
|
AND ($5::timestamptz IS NULL
|
||||||
|
OR de.occurred_at <= $5::timestamptz)
|
||||||
|
ORDER BY de.occurred_at DESC
|
||||||
|
LIMIT $7::int OFFSET $6::int
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAdminDiagnosticsParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ClientID *string
|
||||||
|
Kind *string
|
||||||
|
FromTs pgtype.Timestamptz
|
||||||
|
ToTs pgtype.Timestamptz
|
||||||
|
Off int32
|
||||||
|
Lim int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListAdminDiagnosticsRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Username string
|
||||||
|
ClientID string
|
||||||
|
AppVersion *string
|
||||||
|
OsVersion *string
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
OccurredAt pgtype.Timestamptz
|
||||||
|
ReceivedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin timeline query. All filters are optional (NULL = no filter):
|
||||||
|
// account (user_id), device (client_id), category (kind), and the
|
||||||
|
// occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
|
||||||
|
// a chronological timeline / export. Joined with users for the username
|
||||||
|
// so the view renders without a second round-trip.
|
||||||
|
func (q *Queries) ListAdminDiagnostics(ctx context.Context, arg ListAdminDiagnosticsParams) ([]ListAdminDiagnosticsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAdminDiagnostics,
|
||||||
|
arg.UserID,
|
||||||
|
arg.ClientID,
|
||||||
|
arg.Kind,
|
||||||
|
arg.FromTs,
|
||||||
|
arg.ToTs,
|
||||||
|
arg.Off,
|
||||||
|
arg.Lim,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAdminDiagnosticsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAdminDiagnosticsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Username,
|
||||||
|
&i.ClientID,
|
||||||
|
&i.AppVersion,
|
||||||
|
&i.OsVersion,
|
||||||
|
&i.Kind,
|
||||||
|
&i.Payload,
|
||||||
|
&i.OccurredAt,
|
||||||
|
&i.ReceivedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listDiagnosticDevices = `-- name: ListDiagnosticDevices :many
|
||||||
|
SELECT
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
max(de.app_version)::text AS app_version,
|
||||||
|
max(de.os_version)::text AS os_version,
|
||||||
|
max(de.occurred_at)::timestamptz AS last_seen,
|
||||||
|
count(*) AS event_count
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE ($1::uuid IS NULL OR de.user_id = $1::uuid)
|
||||||
|
GROUP BY de.client_id, de.user_id, u.username
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListDiagnosticDevicesRow struct {
|
||||||
|
ClientID string
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Username string
|
||||||
|
AppVersion string
|
||||||
|
OsVersion string
|
||||||
|
LastSeen pgtype.Timestamptz
|
||||||
|
EventCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct devices that have reported, for the admin device filter +
|
||||||
|
// "who is currently reporting" overview. Optionally scoped to one account.
|
||||||
|
func (q *Queries) ListDiagnosticDevices(ctx context.Context, userID pgtype.UUID) ([]ListDiagnosticDevicesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listDiagnosticDevices, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListDiagnosticDevicesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListDiagnosticDevicesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ClientID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.Username,
|
||||||
|
&i.AppVersion,
|
||||||
|
&i.OsVersion,
|
||||||
|
&i.LastSeen,
|
||||||
|
&i.EventCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -265,6 +265,18 @@ type CoverArtSourcesMetum struct {
|
|||||||
LastRegisteredProvidersHash string
|
LastRegisteredProvidersHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiagnosticEvent struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ClientID string
|
||||||
|
AppVersion *string
|
||||||
|
OsVersion *string
|
||||||
|
Kind string
|
||||||
|
Payload []byte
|
||||||
|
OccurredAt pgtype.Timestamptz
|
||||||
|
ReceivedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type GeneralLike struct {
|
type GeneralLike struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
@@ -552,6 +564,7 @@ type User struct {
|
|||||||
Email *string
|
Email *string
|
||||||
Timezone string
|
Timezone string
|
||||||
TimezoneUpdatedAt pgtype.Timestamptz
|
TimezoneUpdatedAt pgtype.Timestamptz
|
||||||
|
DebugModeEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserInvite struct {
|
type UserInvite struct {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
|||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserParams struct {
|
type CreateUserParams struct {
|
||||||
@@ -89,6 +89,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
const createUserAdmin = `-- name: CreateUserAdmin :one
|
const createUserAdmin = `-- name: CreateUserAdmin :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserAdminParams struct {
|
type CreateUserAdminParams struct {
|
||||||
@@ -134,6 +135,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -145,7 +147,7 @@ VALUES (
|
|||||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||||
$4
|
$4
|
||||||
)
|
)
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateUserFirstAdminRaceParams struct {
|
type CreateUserFirstAdminRaceParams struct {
|
||||||
@@ -191,6 +193,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -237,7 +240,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE api_token = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
|
func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) {
|
||||||
@@ -258,12 +261,13 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User,
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
const getUserByEmail = `-- name: GetUserByEmail :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1)
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE lower(email) = lower($1)
|
||||||
`
|
`
|
||||||
|
|
||||||
// Used by forgot-password lookup. Lowercase comparison both sides
|
// Used by forgot-password lookup. Lowercase comparison both sides
|
||||||
@@ -287,12 +291,13 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
const getUserByID = `-- name: GetUserByID :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) {
|
||||||
@@ -313,12 +318,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error)
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||||
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1
|
SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled FROM users WHERE username = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) {
|
||||||
@@ -339,6 +345,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -382,7 +389,8 @@ func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiv
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listUsers = `-- name: ListUsers :many
|
const listUsers = `-- name: ListUsers :many
|
||||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
SELECT id, username, display_name, is_admin, auto_approve_requests,
|
||||||
|
debug_mode_enabled, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
`
|
`
|
||||||
@@ -393,6 +401,7 @@ type ListUsersRow struct {
|
|||||||
DisplayName *string
|
DisplayName *string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
AutoApproveRequests bool
|
AutoApproveRequests bool
|
||||||
|
DebugModeEnabled bool
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +421,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.IsAdmin,
|
&i.IsAdmin,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -426,7 +436,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
|
|
||||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||||
UPDATE users SET api_token = $2 WHERE id = $1
|
UPDATE users SET api_token = $2 WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type RegenerateApiTokenParams struct {
|
type RegenerateApiTokenParams struct {
|
||||||
@@ -454,6 +464,7 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -478,6 +489,44 @@ func (q *Queries) ResetUserPassword(ctx context.Context, arg ResetUserPasswordPa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setDebugMode = `-- name: SetDebugMode :one
|
||||||
|
UPDATE users
|
||||||
|
SET debug_mode_enabled = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetDebugModeParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
DebugModeEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
|
||||||
|
// from /admin (flip remotely while a bug is live) or self-driven OFF from
|
||||||
|
// the client. Returns the updated row so the handler can echo it.
|
||||||
|
func (q *Queries) SetDebugMode(ctx context.Context, arg SetDebugModeParams) (User, error) {
|
||||||
|
row := q.db.QueryRow(ctx, setDebugMode, arg.ID, arg.DebugModeEnabled)
|
||||||
|
var i User
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Username,
|
||||||
|
&i.PasswordHash,
|
||||||
|
&i.ApiToken,
|
||||||
|
&i.IsAdmin,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.SubsonicPassword,
|
||||||
|
&i.ListenbrainzToken,
|
||||||
|
&i.ListenbrainzEnabled,
|
||||||
|
&i.DisplayName,
|
||||||
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
|
const setListenBrainzEnabled = `-- name: SetListenBrainzEnabled :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET listenbrainz_enabled = $2
|
SET listenbrainz_enabled = $2
|
||||||
@@ -531,7 +580,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_admin = $2
|
SET is_admin = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserAdminParams struct {
|
type UpdateUserAdminParams struct {
|
||||||
@@ -559,6 +608,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -567,7 +617,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET auto_approve_requests = $2
|
SET auto_approve_requests = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserAutoApproveParams struct {
|
type UpdateUserAutoApproveParams struct {
|
||||||
@@ -594,6 +644,7 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -603,7 +654,7 @@ UPDATE users
|
|||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
email = $3
|
email = $3
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at
|
RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateUserProfileParams struct {
|
type UpdateUserProfileParams struct {
|
||||||
@@ -632,6 +683,7 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||||||
&i.Email,
|
&i.Email,
|
||||||
&i.Timezone,
|
&i.Timezone,
|
||||||
&i.TimezoneUpdatedAt,
|
&i.TimezoneUpdatedAt,
|
||||||
|
&i.DebugModeEnabled,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN IF EXISTS debug_mode_enabled;
|
||||||
|
DROP TABLE IF EXISTS diagnostic_events;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- Device diagnostics / debug-reporting subsystem (M9).
|
||||||
|
--
|
||||||
|
-- When an account has users.debug_mode_enabled = true, that account's
|
||||||
|
-- client(s) stream a timeseries of device state here: connectivity +
|
||||||
|
-- server-health transitions (roaming / poor-data recovery), UPnP/Sonos
|
||||||
|
-- sync events (locked-phone desync), and power/lifecycle state (Doze /
|
||||||
|
-- battery-optimization, the prime suspect for the desync). The admin
|
||||||
|
-- /admin/diagnostics view filters by account + device + time window and
|
||||||
|
-- exports the slice as JSON for offline analysis.
|
||||||
|
--
|
||||||
|
-- `kind` is a COARSE category gated by a CHECK whitelist; the finer
|
||||||
|
-- event discriminator + all event-specific fields live in the `payload`
|
||||||
|
-- jsonb. This keeps the enum small and stable so new event variants
|
||||||
|
-- don't require a migration (per the enum-CHECK-whitelist rule, only a
|
||||||
|
-- new *category* would). If you add a category here, mirror it in the
|
||||||
|
-- handler whitelist in internal/api/diagnostics.go.
|
||||||
|
--
|
||||||
|
-- Two timestamps on purpose: occurred_at is the client device clock
|
||||||
|
-- (when the event happened on the phone — may be skewed/wrong, e.g. in
|
||||||
|
-- a dead zone), received_at is the server clock at ingest. Retention
|
||||||
|
-- pruning is keyed on received_at so a bad client clock can't keep rows
|
||||||
|
-- alive forever.
|
||||||
|
|
||||||
|
CREATE TABLE diagnostic_events (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
client_id text NOT NULL,
|
||||||
|
app_version text,
|
||||||
|
os_version text,
|
||||||
|
kind text NOT NULL,
|
||||||
|
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT diagnostic_events_kind_check
|
||||||
|
CHECK (kind IN (
|
||||||
|
'connectivity', 'upnp_sync', 'power', 'lifecycle',
|
||||||
|
'heartbeat', 'http'
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Admin reads filter by account then device, ordered newest-first.
|
||||||
|
CREATE INDEX idx_diagnostic_events_user
|
||||||
|
ON diagnostic_events (user_id, occurred_at DESC);
|
||||||
|
CREATE INDEX idx_diagnostic_events_client
|
||||||
|
ON diagnostic_events (client_id, occurred_at DESC);
|
||||||
|
-- Retention pruner deletes by server-clock age.
|
||||||
|
CREATE INDEX idx_diagnostic_events_received
|
||||||
|
ON diagnostic_events (received_at);
|
||||||
|
|
||||||
|
-- Per-account opt-in for diagnostics reporting. Admin-set primarily
|
||||||
|
-- (flip remotely while a bug is live); the client also exposes a local
|
||||||
|
-- OFF switch. Default false: no account reports until explicitly enabled.
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS debug_mode_enabled boolean NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- name: InsertDiagnosticEvent :one
|
||||||
|
-- Records one client-reported diagnostic event. The writer validates the
|
||||||
|
-- `kind` category against its whitelist before this runs; the CHECK
|
||||||
|
-- constraint is the belt-and-braces guard. payload carries the finer
|
||||||
|
-- event discriminator + all event-specific fields. Returns received_at
|
||||||
|
-- so the caller can confirm ingest.
|
||||||
|
INSERT INTO diagnostic_events (
|
||||||
|
user_id, client_id, app_version, os_version, kind, payload, occurred_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, received_at;
|
||||||
|
|
||||||
|
-- name: ListAdminDiagnostics :many
|
||||||
|
-- Admin timeline query. All filters are optional (NULL = no filter):
|
||||||
|
-- account (user_id), device (client_id), category (kind), and the
|
||||||
|
-- occurred_at window (from_ts/to_ts). Newest-first; the SPA reverses for
|
||||||
|
-- a chronological timeline / export. Joined with users for the username
|
||||||
|
-- so the view renders without a second round-trip.
|
||||||
|
SELECT
|
||||||
|
de.id AS id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.app_version AS app_version,
|
||||||
|
de.os_version AS os_version,
|
||||||
|
de.kind AS kind,
|
||||||
|
de.payload AS payload,
|
||||||
|
de.occurred_at AS occurred_at,
|
||||||
|
de.received_at AS received_at
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
||||||
|
AND (sqlc.narg(client_id)::text IS NULL OR de.client_id = sqlc.narg(client_id)::text)
|
||||||
|
AND (sqlc.narg(kind)::text IS NULL OR de.kind = sqlc.narg(kind)::text)
|
||||||
|
AND (sqlc.narg(from_ts)::timestamptz IS NULL
|
||||||
|
OR de.occurred_at >= sqlc.narg(from_ts)::timestamptz)
|
||||||
|
AND (sqlc.narg(to_ts)::timestamptz IS NULL
|
||||||
|
OR de.occurred_at <= sqlc.narg(to_ts)::timestamptz)
|
||||||
|
ORDER BY de.occurred_at DESC
|
||||||
|
LIMIT sqlc.arg(lim)::int OFFSET sqlc.arg(off)::int;
|
||||||
|
|
||||||
|
-- name: ListDiagnosticDevices :many
|
||||||
|
-- Distinct devices that have reported, for the admin device filter +
|
||||||
|
-- "who is currently reporting" overview. Optionally scoped to one account.
|
||||||
|
SELECT
|
||||||
|
de.client_id AS client_id,
|
||||||
|
de.user_id AS user_id,
|
||||||
|
u.username AS username,
|
||||||
|
max(de.app_version)::text AS app_version,
|
||||||
|
max(de.os_version)::text AS os_version,
|
||||||
|
max(de.occurred_at)::timestamptz AS last_seen,
|
||||||
|
count(*) AS event_count
|
||||||
|
FROM diagnostic_events de
|
||||||
|
JOIN users u ON u.id = de.user_id
|
||||||
|
WHERE (sqlc.narg(user_id)::uuid IS NULL OR de.user_id = sqlc.narg(user_id)::uuid)
|
||||||
|
GROUP BY de.client_id, de.user_id, u.username
|
||||||
|
ORDER BY last_seen DESC;
|
||||||
|
|
||||||
|
-- name: GcPruneDiagnostics :execrows
|
||||||
|
-- Retention sweep for the gc worker. Deletes diagnostic_events older
|
||||||
|
-- than 30 days by server clock (received_at) so a skewed client clock
|
||||||
|
-- can't keep rows alive. Matches the other gc lifecycle sweeps'
|
||||||
|
-- hardcoded-threshold style; diagnostics are debug telemetry, not user
|
||||||
|
-- data, and 30 days is ample to investigate a live incident after the
|
||||||
|
-- fact. Returns the affected row count for the sweep log line.
|
||||||
|
DELETE FROM diagnostic_events WHERE received_at < now() - INTERVAL '30 days';
|
||||||
@@ -55,7 +55,8 @@ WHERE id = $1;
|
|||||||
|
|
||||||
-- name: ListUsers :many
|
-- name: ListUsers :many
|
||||||
-- Admin user-management list. Sort newest-first.
|
-- Admin user-management list. Sort newest-first.
|
||||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
SELECT id, username, display_name, is_admin, auto_approve_requests,
|
||||||
|
debug_mode_enabled, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
@@ -115,6 +116,15 @@ UPDATE users
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: SetDebugMode :one
|
||||||
|
-- Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
|
||||||
|
-- from /admin (flip remotely while a bug is live) or self-driven OFF from
|
||||||
|
-- the client. Returns the updated row so the handler can echo it.
|
||||||
|
UPDATE users
|
||||||
|
SET debug_mode_enabled = $2
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: ChangeUserPassword :exec
|
-- name: ChangeUserPassword :exec
|
||||||
-- Self-service password change. Caller (HTTP handler) verifies the
|
-- Self-service password change. Caller (HTTP handler) verifies the
|
||||||
-- current password before calling this. Distinct from
|
-- current password before calling this. Distinct from
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
// - GcExpireScrobbleQueueFailedRows (#567)
|
// - GcExpireScrobbleQueueFailedRows (#567)
|
||||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||||
// - GcDeleteExpiredPasswordResets (#575)
|
// - GcDeleteExpiredPasswordResets (#575)
|
||||||
|
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
||||||
package gc
|
package gc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -82,6 +83,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
|
|||||||
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
|
w.runSweep(ctx, "expire_scrobble_failed", q.GcExpireScrobbleQueueFailedRows)
|
||||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||||
|
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSweep is a small adapter so each sweep call site is a one-liner
|
// runSweep is a small adapter so each sweep call site is a one-liner
|
||||||
|
|||||||
Reference in New Issue
Block a user