From ace7e22e6a19fbd33d7cc6c3e0d2f927ee649356 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 11 Oct 2026 09:58:13 -0400 Subject: [PATCH] feat: Admin is a left rail with how many wait on you in each queue, and the Overview opens with a verdict, the queues that need you and since when, then the clear ones on one line (M503 #5605) - GET /api/admin/summary: every admin queue (requests, reported tracks, playback errors, duplicates, suspect sources, missing files) with its count and the age of its oldest item, in one query. - The admin layout drops the 11-tab strip for SectionRail, as Settings and the Collection have, with each queue's count as its badge; the counts refresh on each admin navigation and every 30s. - The Overview leads with "Nothing needs you." or "N things need you.", lists each waiting queue with "oldest 3d ago" and a link to it, counts a failed library scan as a problem, and names the empty queues on one line. The request and quarantine previews and the stat tiles are gone; those queues are worked on their own pages. A failed summary says so with Try again instead of reading as all-clear. Co-Authored-By: Claude Opus 5.5 --- internal/api/admin_summary.go | 59 ++++ internal/api/admin_summary_test.go | 64 ++++ internal/api/api.go | 2 + internal/db/dbq/admin_summary.sql.go | 72 +++++ internal/db/queries/admin_summary.sql | 23 ++ web/src/lib/admin/queues.ts | 33 ++ web/src/lib/api/admin.ts | 18 ++ web/src/lib/api/queries.ts | 1 + web/src/lib/api/types.ts | 12 + web/src/lib/components/AdminTabs.svelte | 42 --- web/src/lib/components/AdminTabs.test.ts | 79 ----- web/src/lib/components/TabStrip.svelte | 4 +- web/src/routes/admin/+layout.svelte | 53 +++- web/src/routes/admin/+page.svelte | 371 ++++------------------- web/src/routes/admin/admin.test.ts | 275 ++++++----------- 15 files changed, 485 insertions(+), 623 deletions(-) create mode 100644 internal/api/admin_summary.go create mode 100644 internal/api/admin_summary_test.go create mode 100644 internal/db/dbq/admin_summary.sql.go create mode 100644 internal/db/queries/admin_summary.sql create mode 100644 web/src/lib/admin/queues.ts delete mode 100644 web/src/lib/components/AdminTabs.svelte delete mode 100644 web/src/lib/components/AdminTabs.test.ts diff --git a/internal/api/admin_summary.go b/internal/api/admin_summary.go new file mode 100644 index 00000000..a3d0f790 --- /dev/null +++ b/internal/api/admin_summary.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// adminQueueView is one queue that waits on a person: how many, and since +// when the oldest of them has waited (null when the queue is empty). +type adminQueueView struct { + Count int64 `json:"count"` + Since *string `json:"since"` +} + +// adminSummaryView is what the admin rail and the Overview's verdict read +// (#5605): every queue an admin acts on, in one response, so each admin page +// pays one cheap read for its rail. +type adminSummaryView struct { + Requests adminQueueView `json:"requests"` + Quarantined adminQueueView `json:"quarantined"` + PlaybackErrors adminQueueView `json:"playback_errors"` + Duplicates adminQueueView `json:"duplicates"` + Suspect adminQueueView `json:"suspect"` + Missing adminQueueView `json:"missing"` +} + +func queueView(count int64, since pgtype.Timestamptz) adminQueueView { + v := adminQueueView{Count: count} + if count > 0 && since.Valid { + s := formatTimestamp(since) + v.Since = &s + } + return v +} + +func adminSummaryFrom(row dbq.AdminSummaryRow) adminSummaryView { + return adminSummaryView{ + Requests: queueView(row.Requests, row.RequestsSince), + Quarantined: queueView(row.Quarantined, row.QuarantinedSince), + PlaybackErrors: queueView(row.PlaybackErrors, row.PlaybackErrorsSince), + Duplicates: queueView(row.Duplicates, row.DuplicatesSince), + Suspect: queueView(row.Suspect, row.SuspectSince), + Missing: queueView(row.Missing, row.MissingSince), + } +} + +// handleGetAdminSummary implements GET /api/admin/summary. +func (h *handlers) handleGetAdminSummary(w http.ResponseWriter, r *http.Request) { + row, err := dbq.New(h.pool).AdminSummary(r.Context()) + if err != nil { + h.logger.Error("admin: summary", "err", err) + writeAdminJSONErr(w, http.StatusInternalServerError, "server_error") + return + } + writeJSON(w, http.StatusOK, adminSummaryFrom(row)) +} diff --git a/internal/api/admin_summary_test.go b/internal/api/admin_summary_test.go new file mode 100644 index 00000000..6e92ed28 --- /dev/null +++ b/internal/api/admin_summary_test.go @@ -0,0 +1,64 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummaryView) { + t.Helper() + r := chi.NewRouter() + r.Route("/api/admin", func(admin chi.Router) { + admin.Use(auth.RequireAdmin()) + admin.Get("/summary", h.handleGetAdminSummary) + }) + w := httptest.NewRecorder() + r.ServeHTTP(w, withUser(httptest.NewRequest(http.MethodGet, "/api/admin/summary", nil), user)) + var out adminSummaryView + if w.Code == http.StatusOK { + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + } + return w.Code, out +} + +// The summary counts each queue an admin acts on and says since when the +// oldest item has waited; an empty queue has no since (#5605). +func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) { + h, _ := testHandlersWithClientFn(t) + admin := seedUser(t, h.pool, "admin", "pw", true) + listener := seedUser(t, h.pool, "listener", "pw", false) + ctx := context.Background() + + code, empty := getAdminSummary(t, h, admin) + if code != http.StatusOK { + t.Fatalf("status = %d", code) + } + if empty.Requests.Count != 0 || empty.Requests.Since != nil || empty.Missing.Count != 0 { + t.Errorf("empty summary = %+v", empty) + } + + if _, err := dbq.New(h.pool).CreateRequest(ctx, dbq.CreateRequestParams{ + UserID: listener.ID, Kind: dbq.RequestKindArtist, ArtistMbid: "a0000000-0000-4000-8000-000000000001", + ArtistName: "Annadale", + }); err != nil { + t.Fatal(err) + } + _, got := getAdminSummary(t, h, admin) + if got.Requests.Count != 1 || got.Requests.Since == nil { + t.Errorf("requests = %+v, want 1 with a since", got.Requests) + } + + if code, _ := getAdminSummary(t, h, listener); code != http.StatusForbidden { + t.Errorf("a listener got %d, want 403", code) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 2e958a51..13b2f0de 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -244,6 +244,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles) admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles) admin.Get("/lidarr/root-folders", h.handleListRootFolders) + // The queues that wait on an admin, for the rail and the Overview (#5605). + admin.Get("/summary", h.handleGetAdminSummary) admin.Get("/requests", h.handleListAdminRequests) admin.Post("/requests/{id}/approve", h.handleApproveRequest) admin.Post("/requests/{id}/reject", h.handleRejectRequest) diff --git a/internal/db/dbq/admin_summary.sql.go b/internal/db/dbq/admin_summary.sql.go new file mode 100644 index 00000000..69dc1722 --- /dev/null +++ b/internal/db/dbq/admin_summary.sql.go @@ -0,0 +1,72 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: admin_summary.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const adminSummary = `-- name: AdminSummary :one +SELECT + (SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests, + (SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since, + (SELECT count(DISTINCT track_id) FROM lidarr_quarantine)::bigint AS quarantined, + (SELECT min(created_at) FROM lidarr_quarantine)::timestamptz AS quarantined_since, + (SELECT count(*) FROM playback_errors WHERE resolved_at IS NULL)::bigint AS playback_errors, + (SELECT min(occurred_at) FROM playback_errors WHERE resolved_at IS NULL)::timestamptz AS playback_errors_since, + (SELECT count(*) FROM duplicate_groups g + WHERE g.status = 'pending' + AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2)::bigint AS duplicates, + (SELECT min(g.detected_at) FROM duplicate_groups g + WHERE g.status = 'pending' + AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2)::timestamptz AS duplicates_since, + (SELECT count(*) FROM tracks WHERE source_verdict = 'suspect' AND missing_since IS NULL)::bigint AS suspect, + (SELECT min(source_verdict_at) FROM tracks WHERE source_verdict = 'suspect' AND missing_since IS NULL)::timestamptz AS suspect_since, + (SELECT count(*) FROM tracks WHERE missing_since IS NOT NULL)::bigint AS missing, + (SELECT min(missing_since) FROM tracks WHERE missing_since IS NOT NULL)::timestamptz AS missing_since +` + +type AdminSummaryRow struct { + Requests int64 + RequestsSince pgtype.Timestamptz + Quarantined int64 + QuarantinedSince pgtype.Timestamptz + PlaybackErrors int64 + PlaybackErrorsSince pgtype.Timestamptz + Duplicates int64 + DuplicatesSince pgtype.Timestamptz + Suspect int64 + SuspectSince pgtype.Timestamptz + Missing int64 + MissingSince pgtype.Timestamptz +} + +// What the admin rail and the Overview's verdict read (#5605): each queue +// that waits on a person, with how many and since when. One round trip, so +// the rail costs one cheap read on every admin page. The "since" is the +// oldest item still waiting: how long the queue has been left, which is what +// tells the operator a count of 2 is fine today and wrong after a month. +func (q *Queries) AdminSummary(ctx context.Context) (AdminSummaryRow, error) { + row := q.db.QueryRow(ctx, adminSummary) + var i AdminSummaryRow + err := row.Scan( + &i.Requests, + &i.RequestsSince, + &i.Quarantined, + &i.QuarantinedSince, + &i.PlaybackErrors, + &i.PlaybackErrorsSince, + &i.Duplicates, + &i.DuplicatesSince, + &i.Suspect, + &i.SuspectSince, + &i.Missing, + &i.MissingSince, + ) + return i, err +} diff --git a/internal/db/queries/admin_summary.sql b/internal/db/queries/admin_summary.sql new file mode 100644 index 00000000..0998ee3e --- /dev/null +++ b/internal/db/queries/admin_summary.sql @@ -0,0 +1,23 @@ +-- name: AdminSummary :one +-- What the admin rail and the Overview's verdict read (#5605): each queue +-- that waits on a person, with how many and since when. One round trip, so +-- the rail costs one cheap read on every admin page. The "since" is the +-- oldest item still waiting: how long the queue has been left, which is what +-- tells the operator a count of 2 is fine today and wrong after a month. +SELECT + (SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests, + (SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since, + (SELECT count(DISTINCT track_id) FROM lidarr_quarantine)::bigint AS quarantined, + (SELECT min(created_at) FROM lidarr_quarantine)::timestamptz AS quarantined_since, + (SELECT count(*) FROM playback_errors WHERE resolved_at IS NULL)::bigint AS playback_errors, + (SELECT min(occurred_at) FROM playback_errors WHERE resolved_at IS NULL)::timestamptz AS playback_errors_since, + (SELECT count(*) FROM duplicate_groups g + WHERE g.status = 'pending' + AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2)::bigint AS duplicates, + (SELECT min(g.detected_at) FROM duplicate_groups g + WHERE g.status = 'pending' + AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2)::timestamptz AS duplicates_since, + (SELECT count(*) FROM tracks WHERE source_verdict = 'suspect' AND missing_since IS NULL)::bigint AS suspect, + (SELECT min(source_verdict_at) FROM tracks WHERE source_verdict = 'suspect' AND missing_since IS NULL)::timestamptz AS suspect_since, + (SELECT count(*) FROM tracks WHERE missing_since IS NOT NULL)::bigint AS missing, + (SELECT min(missing_since) FROM tracks WHERE missing_since IS NOT NULL)::timestamptz AS missing_since; diff --git a/web/src/lib/admin/queues.ts b/web/src/lib/admin/queues.ts new file mode 100644 index 00000000..408d9b4b --- /dev/null +++ b/web/src/lib/admin/queues.ts @@ -0,0 +1,33 @@ +import type { AdminQueue, AdminSummary } from '#lib/api/types.js'; + +// The queues that wait on an admin (#5605), in the order the Overview lists +// them. The rail and the Overview read the same list, so a queue that +// needs you is counted the same in both places. +export type QueueKey = keyof AdminSummary; + +export type QueueDef = { + key: QueueKey; + href: string; + // How the Overview names a count of them: "2 requests to decide". + one: string; + many: string; + // How the Overview's healthy line names an empty one: "no requests". + none: string; +}; + +export const QUEUES: QueueDef[] = [ + { key: 'requests', href: '/admin/requests', one: 'request to decide', many: 'requests to decide', none: 'requests' }, + { key: 'quarantined', href: '/admin/quarantine', one: 'reported track', many: 'reported tracks', none: 'reports' }, + { key: 'playback_errors', href: '/admin/playback-errors', one: 'playback error', many: 'playback errors', none: 'playback errors' }, + { key: 'duplicates', href: '/admin/duplicates', one: 'set of duplicates to review', many: 'sets of duplicates to review', none: 'duplicates' }, + { key: 'suspect', href: '/admin/suspect-sources', one: 'track from a suspect source', many: 'tracks from suspect sources', none: 'suspect sources' }, + { key: 'missing', href: '/admin/missing-files', one: 'missing file', many: 'missing files', none: 'missing files' } +]; + +export function countOf(summary: AdminSummary | undefined, key: QueueKey): number { + return (summary?.[key] as AdminQueue | undefined)?.count ?? 0; +} + +export function queueLabel(def: QueueDef, n: number): string { + return `${n} ${n === 1 ? def.one : def.many}`; +} diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 966dd94b..c1b04ca2 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -12,6 +12,7 @@ import type { ResolvedDuplicatesResponse, AdminPlaybackError, AdminQuarantineRow, + AdminSummary, LidarrConfig, LidarrMetadataProfile, LidarrQualityProfile, @@ -23,6 +24,23 @@ import type { PlaybackErrorResolution } from './types'; +// Admin summary (#5605) --------------------------------------------------- + +export async function getAdminSummary(): Promise { + return api.get('/api/admin/summary'); +} + +// The admin rail's counts and the Overview's verdict. Refetched on every +// admin navigation by the layout and every 30s while an admin page is open, +// so a queue someone just cleared doesn't keep its count for long. +export function createAdminSummaryQuery() { + return createQuery({ + queryKey: qk.adminSummary(), + queryFn: getAdminSummary, + refetchInterval: 30_000 + }); +} + // Admin Lidarr config ----------------------------------------------------- export async function getLidarrConfig(): Promise { diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index 0859f3c5..9a0c1278 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -63,6 +63,7 @@ export const qk = { adminPlaybackErrors: (resolved?: boolean) => ['adminPlaybackErrors', { resolved: resolved ?? false }] as const, scanStatus: () => ['scanStatus'] as const, + adminSummary: () => ['adminSummary'] as const, coverage: () => ['coverage'] as const, fingerprintCoverage: () => ['fingerprintCoverage'] as const, coverProviders: () => ['coverProviders'] as const, diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 1b29cb5a..5f501626 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -900,3 +900,15 @@ export type ReleaseProfilesResponse = { primary_types: string[]; secondary_types: string[]; }; + +// What GET /api/admin/summary returns (#5605): each queue that waits on an +// admin, with how many and since when the oldest has waited (null when empty). +export type AdminQueue = { count: number; since: string | null }; +export type AdminSummary = { + requests: AdminQueue; + quarantined: AdminQueue; + playback_errors: AdminQueue; + duplicates: AdminQueue; + suspect: AdminQueue; + missing: AdminQueue; +}; diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte deleted file mode 100644 index e75b3e54..00000000 --- a/web/src/lib/components/AdminTabs.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/web/src/lib/components/AdminTabs.test.ts b/web/src/lib/components/AdminTabs.test.ts deleted file mode 100644 index 205a257a..00000000 --- a/web/src/lib/components/AdminTabs.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; -import { pageUrlModule } from '#test-utils/mocks/appState.js'; - -const state = vi.hoisted(() => ({ - pageUrl: new URL('http://localhost/admin') -})); - -vi.mock('$app/state', () => pageUrlModule(state)); - -import AdminTabs from './AdminTabs.svelte'; - -describe('AdminTabs', () => { - test('Overview tab is current when on /admin', () => { - state.pageUrl = new URL('http://localhost/admin'); - render(AdminTabs); - const overview = screen.getByRole('link', { name: /overview/i }); - expect(overview).toHaveAttribute('aria-current', 'page'); - }); - - test('Integrations tab is current when on /admin/integrations', () => { - state.pageUrl = new URL('http://localhost/admin/integrations'); - render(AdminTabs); - expect(screen.getByRole('link', { name: /integrations/i })).toHaveAttribute( - 'aria-current', - 'page' - ); - expect(screen.getByRole('link', { name: /overview/i })).not.toHaveAttribute('aria-current'); - }); - - test('Requests tab is current when on /admin/requests', () => { - state.pageUrl = new URL('http://localhost/admin/requests'); - render(AdminTabs); - expect(screen.getByRole('link', { name: /requests/i })).toHaveAttribute('aria-current', 'page'); - }); - - test('Quarantine tab is current when on /admin/quarantine', () => { - state.pageUrl = new URL('http://localhost/admin/quarantine'); - render(AdminTabs); - expect(screen.getByRole('link', { name: /quarantine/i })).toHaveAttribute( - 'aria-current', - 'page' - ); - }); - - test('Users tab is current when on /admin/users', () => { - state.pageUrl = new URL('http://localhost/admin/users'); - render(AdminTabs); - expect(screen.getByRole('link', { name: /users/i })).toHaveAttribute( - 'aria-current', - 'page' - ); - }); - - test('renders all eleven tabs in order', () => { - state.pageUrl = new URL('http://localhost/admin'); - render(AdminTabs); - const links = screen.getAllByRole('link'); - expect(links.map((l) => l.textContent?.trim())).toEqual([ - 'Overview', - 'Integrations', - 'Requests', - 'Quarantine', - // Missing files sits with Quarantine and Playback errors: the three - // surfaces that show tracks needing an operator's attention. - 'Missing files', - // Duplicates follows Missing files: both are library-health reports on - // what the library holds, rather than a queue of user reports. - 'Duplicates', - // Suspect sources is the third library-health report: files whose - // names say they were ripped from a video. - 'Suspect sources', - 'Playback errors', - 'Diagnostics', - 'Tuning', - 'Users' - ]); - }); -}); diff --git a/web/src/lib/components/TabStrip.svelte b/web/src/lib/components/TabStrip.svelte index 5f329e03..adc067c6 100644 --- a/web/src/lib/components/TabStrip.svelte +++ b/web/src/lib/components/TabStrip.svelte @@ -1,7 +1,7 @@ -
- - {@render children?.()} +
+

Admin

+
+
+
+ +
+
+
+ {@render children?.()} +
+
diff --git a/web/src/routes/admin/+page.svelte b/web/src/routes/admin/+page.svelte index acec5dec..b88e8b86 100644 --- a/web/src/routes/admin/+page.svelte +++ b/web/src/routes/admin/+page.svelte @@ -1,22 +1,14 @@