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)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/quality-profiles", h.handleListQualityProfiles)
|
||||||
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
|
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
|
||||||
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
|
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.Get("/requests", h.handleListAdminRequests)
|
||||||
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
||||||
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
ResolvedDuplicatesResponse,
|
ResolvedDuplicatesResponse,
|
||||||
AdminPlaybackError,
|
AdminPlaybackError,
|
||||||
AdminQuarantineRow,
|
AdminQuarantineRow,
|
||||||
|
AdminSummary,
|
||||||
LidarrConfig,
|
LidarrConfig,
|
||||||
LidarrMetadataProfile,
|
LidarrMetadataProfile,
|
||||||
LidarrQualityProfile,
|
LidarrQualityProfile,
|
||||||
@@ -23,6 +24,23 @@ import type {
|
|||||||
PlaybackErrorResolution
|
PlaybackErrorResolution
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
// Admin summary (#5605) ---------------------------------------------------
|
||||||
|
|
||||||
|
export async function getAdminSummary(): Promise<AdminSummary> {
|
||||||
|
return api.get<AdminSummary>('/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 -----------------------------------------------------
|
// Admin Lidarr config -----------------------------------------------------
|
||||||
|
|
||||||
export async function getLidarrConfig(): Promise<LidarrConfig> {
|
export async function getLidarrConfig(): Promise<LidarrConfig> {
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const qk = {
|
|||||||
adminPlaybackErrors: (resolved?: boolean) =>
|
adminPlaybackErrors: (resolved?: boolean) =>
|
||||||
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
||||||
scanStatus: () => ['scanStatus'] as const,
|
scanStatus: () => ['scanStatus'] as const,
|
||||||
|
adminSummary: () => ['adminSummary'] as const,
|
||||||
coverage: () => ['coverage'] as const,
|
coverage: () => ['coverage'] as const,
|
||||||
fingerprintCoverage: () => ['fingerprintCoverage'] as const,
|
fingerprintCoverage: () => ['fingerprintCoverage'] as const,
|
||||||
coverProviders: () => ['coverProviders'] as const,
|
coverProviders: () => ['coverProviders'] as const,
|
||||||
|
|||||||
@@ -900,3 +900,15 @@ export type ReleaseProfilesResponse = {
|
|||||||
primary_types: string[];
|
primary_types: string[];
|
||||||
secondary_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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
|
|
||||||
type Item = { href: string; label: string };
|
|
||||||
|
|
||||||
const items: Item[] = [
|
|
||||||
{ href: '/admin', label: 'Overview' },
|
|
||||||
{ href: '/admin/integrations', label: 'Integrations' },
|
|
||||||
{ href: '/admin/requests', label: 'Requests' },
|
|
||||||
{ href: '/admin/quarantine', label: 'Quarantine' },
|
|
||||||
{ href: '/admin/missing-files', label: 'Missing files' },
|
|
||||||
{ href: '/admin/duplicates', label: 'Duplicates' },
|
|
||||||
{ href: '/admin/suspect-sources', label: 'Suspect sources' },
|
|
||||||
{ href: '/admin/playback-errors', label: 'Playback errors' },
|
|
||||||
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
|
||||||
{ href: '/admin/tuning', label: 'Tuning' },
|
|
||||||
{ href: '/admin/users', label: 'Users' }
|
|
||||||
];
|
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
|
||||||
if (href === '/admin') return page.url.pathname === '/admin';
|
|
||||||
return page.url.pathname.startsWith(href);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav aria-label="Admin sections" class="sticky top-0 z-10 border-b border-border bg-background">
|
|
||||||
<ul class="flex gap-2">
|
|
||||||
{#each items as item (item.href)}
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href={item.href}
|
|
||||||
aria-current={isActive(item.href) ? 'page' : undefined}
|
|
||||||
class="block border-b-2 px-3 py-2 text-sm transition-colors {isActive(item.href)
|
|
||||||
? 'border-accent text-text-primary'
|
|
||||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
@@ -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'
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" generics="Id extends string">
|
<script lang="ts" generics="Id extends string">
|
||||||
// In-page tabs: one strip of buttons switching what a single page shows
|
// In-page tabs: one strip of buttons switching what a single page shows
|
||||||
// (#5416). The same look as AdminTabs and DiscoverTabs, which are route
|
// (#5416). The same look as DiscoverTabs, which is route
|
||||||
// navigation and so stay links; this is for state inside one page, such as
|
// navigation and so stays links; this is for state inside one page, such as
|
||||||
// Liked's Artists | Albums | Tracks or a queue's status filter.
|
// Liked's Artists | Albums | Tracks or a queue's status filter.
|
||||||
type Item = {
|
type Item = {
|
||||||
id: Id;
|
id: Id;
|
||||||
|
|||||||
@@ -1,10 +1,55 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdminTabs from '#lib/components/AdminTabs.svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import SectionRail, { type RailItem } from '#lib/components/SectionRail.svelte';
|
||||||
|
import { createAdminSummaryQuery } from '#lib/api/admin.js';
|
||||||
|
import { countOf } from '#lib/admin/queues.js';
|
||||||
|
|
||||||
let { children } = $props<{ children: import('svelte').Snippet }>();
|
let { children } = $props<{ children: import('svelte').Snippet }>();
|
||||||
|
|
||||||
|
// Admin (#5605): one page per section down a left rail, as Settings and
|
||||||
|
// the Collection are, so the sections sit in the same place everywhere
|
||||||
|
// and fit a phone. Each queue carries how many wait on you, from one
|
||||||
|
// summary read that is refreshed whenever you move between admin pages.
|
||||||
|
const summaryStore = createAdminSummaryQuery();
|
||||||
|
const summary = $derived($summaryStore.data);
|
||||||
|
|
||||||
|
// Refetch when the admin moves to another admin page, where they may have
|
||||||
|
// just cleared something. Not on first mount (the query is fetching
|
||||||
|
// already), and untracked, so the refetch's own result doesn't loop.
|
||||||
|
let lastPath = page.url.pathname;
|
||||||
|
$effect(() => {
|
||||||
|
const path = page.url.pathname;
|
||||||
|
if (path === lastPath) return;
|
||||||
|
lastPath = path;
|
||||||
|
untrack(() => $summaryStore.refetch?.());
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: RailItem[] = $derived([
|
||||||
|
{ href: '/admin', label: 'Overview', exact: true },
|
||||||
|
{ href: '/admin/requests', label: 'Requests', attention: countOf(summary, 'requests') },
|
||||||
|
{ href: '/admin/quarantine', label: 'Quarantine', attention: countOf(summary, 'quarantined') },
|
||||||
|
{ href: '/admin/playback-errors', label: 'Playback errors', attention: countOf(summary, 'playback_errors') },
|
||||||
|
{ href: '/admin/duplicates', label: 'Duplicates', attention: countOf(summary, 'duplicates') },
|
||||||
|
{ href: '/admin/suspect-sources', label: 'Suspect sources', attention: countOf(summary, 'suspect') },
|
||||||
|
{ href: '/admin/missing-files', label: 'Missing files', attention: countOf(summary, 'missing') },
|
||||||
|
{ href: '/admin/integrations', label: 'Integrations' },
|
||||||
|
{ href: '/admin/users', label: 'Users' },
|
||||||
|
{ href: '/admin/diagnostics', label: 'Diagnostics' },
|
||||||
|
{ href: '/admin/tuning', label: 'Tuning' }
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-4">
|
||||||
<AdminTabs />
|
<h1 class="font-display text-2xl font-medium text-text-primary">Admin</h1>
|
||||||
{@render children?.()}
|
<div class="md:grid md:grid-cols-[11rem_minmax(0,1fr)] md:gap-6">
|
||||||
|
<div class="mb-4 md:mb-0">
|
||||||
|
<div class="md:sticky md:top-4">
|
||||||
|
<SectionRail label="Admin sections" {items} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 space-y-6">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { pageTitle } from '#lib/branding.js';
|
import { pageTitle } from '#lib/branding.js';
|
||||||
import { Disc3, Album, Music2, Check, X, RotateCcw, Trash2, Cloud, ChevronRight } from 'lucide-svelte';
|
|
||||||
import StageBadge from '#lib/components/StageBadge.svelte';
|
import StageBadge from '#lib/components/StageBadge.svelte';
|
||||||
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
|
import LoudnessSettingsCard from '#lib/components/LoudnessSettingsCard.svelte';
|
||||||
import { stageState } from './stage-state';
|
import { stageState } from './stage-state';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
createAdminRequestsQuery,
|
createAdminSummaryQuery,
|
||||||
createLidarrConfigQuery,
|
|
||||||
createAdminQuarantineQuery,
|
|
||||||
createScanStatusQuery,
|
createScanStatusQuery,
|
||||||
createCoverageQuery,
|
createCoverageQuery,
|
||||||
createFingerprintCoverageQuery,
|
createFingerprintCoverageQuery,
|
||||||
approveRequest,
|
|
||||||
rejectRequest,
|
|
||||||
resolveQuarantine,
|
|
||||||
deleteQuarantineFile,
|
|
||||||
deleteQuarantineViaLidarr,
|
|
||||||
refetchMissingCovers,
|
refetchMissingCovers,
|
||||||
triggerScan,
|
triggerScan,
|
||||||
researchMissingArt
|
researchMissingArt
|
||||||
@@ -24,158 +16,22 @@
|
|||||||
import { qk } from '#lib/api/queries.js';
|
import { qk } from '#lib/api/queries.js';
|
||||||
import { errCode } from '#lib/api/errors.js';
|
import { errCode } from '#lib/api/errors.js';
|
||||||
import { pushToast } from '#lib/stores/toast.svelte.js';
|
import { pushToast } from '#lib/stores/toast.svelte.js';
|
||||||
import { runMutation } from '#lib/util/runMutation.js';
|
import { relativeTime } from '#lib/utils/relativeTime.js';
|
||||||
import type {
|
import { QUEUES, countOf, queueLabel } from '#lib/admin/queues.js';
|
||||||
AdminQuarantineRow,
|
|
||||||
MusicRequest,
|
|
||||||
RequestKind,
|
|
||||||
LidarrQuarantineReason
|
|
||||||
} from '#lib/api/types.js';
|
|
||||||
|
|
||||||
// Top-of-admin overview. Shows the connection card + the most recent
|
// The admin Overview (#5605) answers "does anything need me?" first: a
|
||||||
// 5 of each actionable queue (requests, quarantine) with inline
|
// verdict, then each queue that waits on you with how long its oldest has
|
||||||
// handlers for the common case. The dedicated pages keep the full
|
// waited, then the empty ones on one line (#189). The queues themselves
|
||||||
// modal flows (override, typed-confirm); the overview offers a faster
|
// are worked on their own pages; the Overview no longer repeats them.
|
||||||
// path for the common-case actions, with a two-click confirm on
|
|
||||||
// destructive quarantine actions to avoid modal duplication.
|
|
||||||
|
|
||||||
const PREVIEW_LIMIT = 5;
|
|
||||||
|
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
|
|
||||||
const requestsStore = createAdminRequestsQuery('pending');
|
const summaryStore = createAdminSummaryQuery();
|
||||||
const requests = $derived($requestsStore);
|
const summaryQ = $derived($summaryStore);
|
||||||
|
const summary = $derived(summaryQ.data);
|
||||||
|
|
||||||
const configStore = createLidarrConfigQuery();
|
const waiting = $derived(QUEUES.filter((d) => countOf(summary, d.key) > 0));
|
||||||
const config = $derived($configStore);
|
const clear = $derived(QUEUES.filter((d) => countOf(summary, d.key) === 0));
|
||||||
|
|
||||||
const quarantineStore = createAdminQuarantineQuery();
|
|
||||||
const quarantine = $derived($quarantineStore);
|
|
||||||
|
|
||||||
const pendingCount = $derived(requests.data?.length ?? 0);
|
|
||||||
const quarantineCount = $derived(quarantine.data?.length ?? 0);
|
|
||||||
const lidarrConnected = $derived(config.data?.enabled === true);
|
|
||||||
|
|
||||||
// A tile whose query failed shows a dash, not 0: a failed load must not
|
|
||||||
// read as an all-clear (#5604).
|
|
||||||
function tile(q: { isPending: boolean; isError: boolean }, n: number): string {
|
|
||||||
return q.isPending || q.isError ? '—' : String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestPreview = $derived(
|
|
||||||
(requests.data ?? []).slice(0, PREVIEW_LIMIT)
|
|
||||||
);
|
|
||||||
const quarantinePreview = $derived(
|
|
||||||
(quarantine.data ?? []).slice(0, PREVIEW_LIMIT)
|
|
||||||
);
|
|
||||||
|
|
||||||
// ---- Two-click destructive confirm for quarantine actions ----
|
|
||||||
// Map key is `${trackId}:${actionKey}`. First click sets a key; second
|
|
||||||
// click within 3s fires the action; otherwise the key clears.
|
|
||||||
let confirmingKey = $state<string | null>(null);
|
|
||||||
let confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
function confirmKey(trackID: string, action: 'delete-file' | 'delete-lidarr'): string {
|
|
||||||
return `${trackID}:${action}`;
|
|
||||||
}
|
|
||||||
function armConfirm(trackID: string, action: 'delete-file' | 'delete-lidarr') {
|
|
||||||
confirmingKey = confirmKey(trackID, action);
|
|
||||||
if (confirmTimer) clearTimeout(confirmTimer);
|
|
||||||
confirmTimer = setTimeout(() => { confirmingKey = null; }, 3000);
|
|
||||||
}
|
|
||||||
function clearConfirm() {
|
|
||||||
confirmingKey = null;
|
|
||||||
if (confirmTimer) clearTimeout(confirmTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
|
||||||
function fallbackIcon(kind: RequestKind) {
|
|
||||||
if (kind === 'artist') return Disc3;
|
|
||||||
if (kind === 'album') return Album;
|
|
||||||
return Music2;
|
|
||||||
}
|
|
||||||
function rowTitle(r: MusicRequest): string {
|
|
||||||
if (r.kind === 'artist') return r.artist_name;
|
|
||||||
if (r.kind === 'album') return r.album_title ?? '—';
|
|
||||||
return r.track_title ?? '—';
|
|
||||||
}
|
|
||||||
function rowSubtitle(r: MusicRequest): string {
|
|
||||||
if (r.kind === 'artist') return 'Artist';
|
|
||||||
if (r.kind === 'album') return `Album · ${r.artist_name}`;
|
|
||||||
return `Track · ${r.artist_name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const REASON_LABELS: Record<LidarrQuarantineReason, string> = {
|
|
||||||
bad_rip: 'Bad rip',
|
|
||||||
wrong_file: 'Wrong file',
|
|
||||||
wrong_tags: 'Wrong tags',
|
|
||||||
duplicate: 'Duplicate',
|
|
||||||
other: 'Other'
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Action handlers ----
|
|
||||||
async function invalidateRequests() {
|
|
||||||
await Promise.all([
|
|
||||||
// An approved request becomes a want, so the Collection's lists and
|
|
||||||
// counts change too (#5604).
|
|
||||||
client.invalidateQueries({ queryKey: ['adminWants'] }),
|
|
||||||
client.invalidateQueries({ queryKey: qk.adminRequests('pending') }),
|
|
||||||
client.invalidateQueries({ queryKey: qk.adminRequests('approved') }),
|
|
||||||
client.invalidateQueries({ queryKey: qk.adminRequests('rejected') }),
|
|
||||||
client.invalidateQueries({ queryKey: qk.adminRequests('completed') })
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onApprove(r: MusicRequest) {
|
|
||||||
await runMutation(async () => {
|
|
||||||
await approveRequest(r.id);
|
|
||||||
await invalidateRequests();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function onReject(r: MusicRequest) {
|
|
||||||
await runMutation(async () => {
|
|
||||||
await rejectRequest(r.id);
|
|
||||||
await invalidateRequests();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function invalidateQuarantine() {
|
|
||||||
await Promise.all([
|
|
||||||
client.invalidateQueries({ queryKey: qk.adminQuarantine() }),
|
|
||||||
client.invalidateQueries({ queryKey: qk.adminQuarantineActions() })
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onResolve(row: AdminQuarantineRow) {
|
|
||||||
await runMutation(async () => {
|
|
||||||
await resolveQuarantine(row.track_id);
|
|
||||||
await invalidateQuarantine();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function onDeleteFile(row: AdminQuarantineRow) {
|
|
||||||
const key = confirmKey(row.track_id, 'delete-file');
|
|
||||||
if (confirmingKey !== key) {
|
|
||||||
armConfirm(row.track_id, 'delete-file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearConfirm();
|
|
||||||
await runMutation(async () => {
|
|
||||||
await deleteQuarantineFile(row.track_id);
|
|
||||||
await invalidateQuarantine();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function onDeleteLidarr(row: AdminQuarantineRow) {
|
|
||||||
const key = confirmKey(row.track_id, 'delete-lidarr');
|
|
||||||
if (confirmingKey !== key) {
|
|
||||||
armConfirm(row.track_id, 'delete-lidarr');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearConfirm();
|
|
||||||
await runMutation(async () => {
|
|
||||||
await deleteQuarantineViaLidarr(row.track_id);
|
|
||||||
await invalidateQuarantine();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Library scan ----
|
// ---- Library scan ----
|
||||||
const scanStatusStore = $derived(createScanStatusQuery());
|
const scanStatusStore = $derived(createScanStatusQuery());
|
||||||
@@ -263,84 +119,54 @@
|
|||||||
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
<svelte:head><title>{pageTitle('Admin · Overview')}</title></svelte:head>
|
||||||
|
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<header>
|
<!-- The verdict, then what needs you, then the healthy rest packed tight. -->
|
||||||
<h2 class="font-display text-2xl font-medium text-text-primary">Overview</h2>
|
<section class="space-y-3" data-testid="overview-verdict">
|
||||||
</header>
|
<h2 class="sr-only">What needs you</h2>
|
||||||
|
{#if summaryQ.isPending}
|
||||||
<!-- Top stats -->
|
<p class="text-sm text-text-secondary">Reading the queues…</p>
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
{:else if summaryQ.isError}
|
||||||
<a
|
<p class="text-sm text-error-fg" data-testid="summary-error">
|
||||||
href="/admin/requests"
|
Couldn't read the queues, so this page can't say whether anything needs you.
|
||||||
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
<button type="button" class="underline hover:no-underline" onclick={() => summaryQ.refetch()}>Try again</button>
|
||||||
>
|
|
||||||
<div class="text-sm text-text-secondary">Pending requests</div>
|
|
||||||
<div class="font-display mt-1 text-3xl font-medium text-text-primary" data-testid="tile-requests">{tile(requests, pendingCount)}</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/quarantine"
|
|
||||||
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
|
||||||
>
|
|
||||||
<div class="text-sm text-text-secondary">Quarantined tracks</div>
|
|
||||||
<div class="font-display mt-1 text-3xl font-medium text-text-primary" data-testid="tile-quarantine">{tile(quarantine, quarantineCount)}</div>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/admin/integrations"
|
|
||||||
class="block rounded-xl border border-border bg-surface p-5 transition-colors hover:bg-surface-hover"
|
|
||||||
>
|
|
||||||
<div class="text-sm text-text-secondary">Lidarr</div>
|
|
||||||
<div class="font-display mt-1 text-2xl font-medium text-text-primary">
|
|
||||||
{config.isError ? '—' : lidarrConnected ? 'Connected' : 'Unset'}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pending requests preview -->
|
|
||||||
<section class="space-y-3">
|
|
||||||
<header class="flex items-baseline justify-between">
|
|
||||||
<h3 class="font-display text-xl font-medium text-text-primary">Pending requests</h3>
|
|
||||||
{#if pendingCount > PREVIEW_LIMIT}
|
|
||||||
<a href="/admin/requests" class="text-sm text-accent-fg hover:underline">View all {pendingCount} →</a>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if requests.isPending}
|
|
||||||
<p class="text-sm text-text-secondary">Loading…</p>
|
|
||||||
{:else if requests.isError}
|
|
||||||
<p class="text-sm text-error-fg" data-testid="requests-error">
|
|
||||||
Couldn't load the requests.
|
|
||||||
<button type="button" class="underline hover:no-underline" onclick={() => requests.refetch()}>Try again</button>
|
|
||||||
</p>
|
</p>
|
||||||
{:else if requestPreview.length === 0}
|
|
||||||
<p class="text-sm text-text-secondary">No pending requests.</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
|
{@const problems = waiting.length + (scan?.error_message ? 1 : 0)}
|
||||||
{#each requestPreview as r (r.id)}
|
<p class="text-base {problems ? 'text-text-primary' : 'text-text-secondary'}" data-testid="verdict">
|
||||||
{@const Icon = fallbackIcon(r.kind)}
|
{#if problems === 0}
|
||||||
<li class="flex items-center gap-3 p-3">
|
Nothing needs you.
|
||||||
<Icon size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
|
{:else}
|
||||||
<div class="min-w-0 flex-1">
|
{problems === 1 ? '1 thing needs you.' : `${problems} things need you.`}
|
||||||
<div class="truncate text-sm font-medium text-text-primary">{rowTitle(r)}</div>
|
{/if}
|
||||||
<div class="truncate text-xs text-text-secondary">{rowSubtitle(r)}</div>
|
</p>
|
||||||
</div>
|
{#if problems > 0}
|
||||||
<button
|
<ul class="divide-y divide-border rounded-lg border border-border bg-surface">
|
||||||
type="button"
|
{#if scan?.error_message}
|
||||||
onclick={() => onApprove(r)}
|
<li class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3" data-testid="needs-you">
|
||||||
aria-label={`Approve ${rowTitle(r)}`}
|
<span class="text-sm text-error-fg">The last library scan failed: {scan.error_message}</span>
|
||||||
class="flex h-8 items-center gap-1 rounded-md bg-action-primary px-3 text-sm text-action-fg hover:opacity-90"
|
{#if scan.finished_at}
|
||||||
>
|
<span class="text-xs text-text-muted">{relativeTime(scan.finished_at)}</span>
|
||||||
<Check size={14} strokeWidth={1.5} /> Approve
|
{/if}
|
||||||
</button>
|
</li>
|
||||||
<button
|
{/if}
|
||||||
type="button"
|
{#each waiting as d (d.key)}
|
||||||
onclick={() => onReject(r)}
|
{@const q = summary?.[d.key]}
|
||||||
aria-label={`Reject ${rowTitle(r)}`}
|
<li data-testid="needs-you">
|
||||||
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
|
<a href={d.href} class="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 hover:bg-surface-hover">
|
||||||
>
|
<span class="text-sm text-text-primary">{queueLabel(d, countOf(summary, d.key))}</span>
|
||||||
<X size={14} strokeWidth={1.5} /> Reject
|
{#if q?.since}
|
||||||
</button>
|
<span class="text-xs text-text-muted">oldest {relativeTime(q.since)}</span>
|
||||||
</li>
|
{/if}
|
||||||
{/each}
|
<span class="ml-auto text-sm text-accent-fg">Open</span>
|
||||||
</ul>
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
{#if clear.length > 0}
|
||||||
|
<p class="text-xs text-text-muted" data-testid="all-clear">
|
||||||
|
No {clear.map((d) => d.none).join(', ')}.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -532,86 +358,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Quarantine preview -->
|
|
||||||
<section class="space-y-3">
|
|
||||||
<header class="flex items-baseline justify-between">
|
|
||||||
<h3 class="font-display text-xl font-medium text-text-primary">Quarantine</h3>
|
|
||||||
{#if quarantineCount > PREVIEW_LIMIT}
|
|
||||||
<a href="/admin/quarantine" class="text-sm text-accent-fg hover:underline">View all {quarantineCount} →</a>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if quarantine.isPending}
|
|
||||||
<p class="text-sm text-text-secondary">Loading…</p>
|
|
||||||
{:else if quarantine.isError}
|
|
||||||
<p class="text-sm text-error-fg" data-testid="quarantine-error">
|
|
||||||
Couldn't load the quarantine.
|
|
||||||
<button type="button" class="underline hover:no-underline" onclick={() => quarantine.refetch()}>Try again</button>
|
|
||||||
</p>
|
|
||||||
{:else if quarantinePreview.length === 0}
|
|
||||||
<p class="text-sm text-text-secondary">No quarantined tracks.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="divide-y divide-border rounded-xl border border-border bg-surface">
|
|
||||||
{#each quarantinePreview as row (row.track_id)}
|
|
||||||
{@const fileKey = confirmKey(row.track_id, 'delete-file')}
|
|
||||||
{@const lidarrKey = confirmKey(row.track_id, 'delete-lidarr')}
|
|
||||||
{@const fileArmed = confirmingKey === fileKey}
|
|
||||||
{@const lidarrArmed = confirmingKey === lidarrKey}
|
|
||||||
<li class="flex items-center gap-3 p-3">
|
|
||||||
<Music2 size={20} strokeWidth={1.5} class="shrink-0 text-text-muted" />
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="truncate text-sm font-medium text-text-primary">{row.track_title}</div>
|
|
||||||
<div class="truncate text-xs text-text-secondary">
|
|
||||||
{row.artist_name} · {row.album_title}
|
|
||||||
· {row.report_count} {row.report_count === 1 ? 'report' : 'reports'}
|
|
||||||
{#if row.reports[0]}
|
|
||||||
· {REASON_LABELS[row.reports[0].reason] ?? row.reports[0].reason}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => onResolve(row)}
|
|
||||||
aria-label={`Resolve ${row.track_title}`}
|
|
||||||
class="flex h-8 items-center gap-1 rounded-md border border-border px-3 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover"
|
|
||||||
>
|
|
||||||
<RotateCcw size={14} strokeWidth={1.5} /> Resolve
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => onDeleteFile(row)}
|
|
||||||
aria-label={fileArmed ? `Confirm delete file ${row.track_title}` : `Delete file ${row.track_title}`}
|
|
||||||
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {fileArmed
|
|
||||||
? 'bg-action-secondary text-action-fg'
|
|
||||||
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} strokeWidth={1.5} /> {fileArmed ? 'Confirm?' : 'Delete file'}
|
|
||||||
</button>
|
|
||||||
<!-- Only for an album Lidarr holds, while Lidarr is connected; a
|
|
||||||
local-only track has nothing for Lidarr to remove (#5604). -->
|
|
||||||
{#if lidarrConnected && row.lidarr_album_mbid}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => onDeleteLidarr(row)}
|
|
||||||
aria-label={lidarrArmed ? `Confirm delete via Lidarr ${row.track_title}` : `Delete via Lidarr ${row.track_title}`}
|
|
||||||
class="flex h-8 items-center gap-1 rounded-md px-3 text-sm transition-colors {lidarrArmed
|
|
||||||
? 'bg-action-destructive text-action-fg'
|
|
||||||
: 'border border-border text-text-secondary hover:text-text-primary hover:bg-surface-hover'}"
|
|
||||||
>
|
|
||||||
<Cloud size={14} strokeWidth={1.5} /> {lidarrArmed ? 'Confirm?' : 'Delete via Lidarr'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<a
|
|
||||||
href="/admin/quarantine"
|
|
||||||
aria-label="Open in quarantine queue"
|
|
||||||
class="text-text-secondary hover:text-text-primary"
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} strokeWidth={1.5} />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ vi.mock('$app/state', () => ({
|
|||||||
vi.mock('#lib/api/admin.js', async () => {
|
vi.mock('#lib/api/admin.js', async () => {
|
||||||
const { readable } = await import('svelte/store');
|
const { readable } = await import('svelte/store');
|
||||||
return {
|
return {
|
||||||
createAdminRequestsQuery: vi.fn(),
|
createAdminSummaryQuery: vi.fn(),
|
||||||
createLidarrConfigQuery: vi.fn(),
|
|
||||||
createAdminQuarantineQuery: vi.fn(),
|
|
||||||
createScanStatusQuery: vi.fn(() =>
|
createScanStatusQuery: vi.fn(() =>
|
||||||
readable({
|
readable({
|
||||||
data: { id: '', started_at: '', finished_at: null, in_flight: false },
|
data: { id: '', started_at: '', finished_at: null, in_flight: false },
|
||||||
@@ -41,11 +39,6 @@ vi.mock('#lib/api/admin.js', async () => {
|
|||||||
isPending: false,
|
isPending: false,
|
||||||
isError: false
|
isError: false
|
||||||
}),
|
}),
|
||||||
approveRequest: vi.fn().mockResolvedValue({}),
|
|
||||||
rejectRequest: vi.fn().mockResolvedValue({}),
|
|
||||||
resolveQuarantine: vi.fn().mockResolvedValue({}),
|
|
||||||
deleteQuarantineFile: vi.fn().mockResolvedValue({}),
|
|
||||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
|
||||||
triggerScan: vi.fn().mockResolvedValue({}),
|
triggerScan: vi.fn().mockResolvedValue({}),
|
||||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||||
@@ -96,203 +89,90 @@ describe('admin/+layout.ts load gate', () => {
|
|||||||
// Importing at module load means the mocks attach to the same handles the
|
// Importing at module load means the mocks attach to the same handles the
|
||||||
// page binds against.
|
// page binds against.
|
||||||
import {
|
import {
|
||||||
createAdminRequestsQuery,
|
createAdminSummaryQuery,
|
||||||
createLidarrConfigQuery,
|
|
||||||
createAdminQuarantineQuery,
|
|
||||||
createScanStatusQuery,
|
createScanStatusQuery,
|
||||||
approveRequest,
|
|
||||||
rejectRequest,
|
|
||||||
resolveQuarantine,
|
|
||||||
deleteQuarantineFile,
|
|
||||||
deleteQuarantineViaLidarr,
|
|
||||||
researchMissingArt
|
researchMissingArt
|
||||||
} from '#lib/api/admin.js';
|
} from '#lib/api/admin.js';
|
||||||
|
import type { AdminSummary } from '#lib/api/types.js';
|
||||||
import OverviewPage from './+page.svelte';
|
import OverviewPage from './+page.svelte';
|
||||||
|
import AdminLayout from './+layout.svelte';
|
||||||
|
import { createRawSnippet } from 'svelte';
|
||||||
|
|
||||||
function setupOverview(opts: {
|
const DAY = 24 * 3_600_000;
|
||||||
requests?: unknown[];
|
const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
|
||||||
quarantine?: unknown[];
|
const empty = { count: 0, since: null };
|
||||||
lidarr?: { enabled: boolean };
|
|
||||||
} = {}) {
|
function summary(over: Partial<AdminSummary> = {}): AdminSummary {
|
||||||
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
return {
|
||||||
mockQuery({ data: opts.requests ?? [] })
|
requests: empty,
|
||||||
);
|
quarantined: empty,
|
||||||
(createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
playback_errors: empty,
|
||||||
mockQuery({ data: opts.quarantine ?? [] })
|
duplicates: empty,
|
||||||
);
|
suspect: empty,
|
||||||
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
missing: empty,
|
||||||
mockQuery({ data: opts.lidarr ?? { enabled: false } })
|
...over
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupOverview(data: AdminSummary | null = summary(), refetch = vi.fn()) {
|
||||||
|
(createAdminSummaryQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
|
data
|
||||||
|
? mockQuery({ data, refetch })
|
||||||
|
: mockQuery({ isError: true, error: new Error('boom'), refetch })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('admin Overview page', () => {
|
describe('admin Overview page', () => {
|
||||||
test('renders top stat cards (requests, quarantine, lidarr)', async () => {
|
test('with every queue empty, the verdict says nothing needs you, and names what is clear', () => {
|
||||||
setupOverview({
|
|
||||||
requests: [{ id: '1' }, { id: '2' }],
|
|
||||||
quarantine: [{ track_id: 't1', report_count: 2, reports: [] }],
|
|
||||||
lidarr: { enabled: true }
|
|
||||||
});
|
|
||||||
render(OverviewPage);
|
|
||||||
// "Pending requests" appears as both stat-card label and section
|
|
||||||
// header — assert via the link role to disambiguate to the stat card.
|
|
||||||
const reqLink = screen.getByRole('link', { name: /pending requests/i });
|
|
||||||
expect(reqLink).toHaveTextContent('2');
|
|
||||||
const quarLink = screen.getByRole('link', { name: /quarantined tracks/i });
|
|
||||||
expect(quarLink).toHaveTextContent('1');
|
|
||||||
expect(screen.getByText(/connected/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('shows empty-state copy when both queues are empty', async () => {
|
|
||||||
setupOverview({
|
|
||||||
requests: [],
|
|
||||||
quarantine: [],
|
|
||||||
lidarr: { enabled: false }
|
|
||||||
});
|
|
||||||
render(OverviewPage);
|
|
||||||
expect(screen.getByText(/no pending requests/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/no quarantined tracks/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/unset/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('top stat cards link to their sub-pages', async () => {
|
|
||||||
setupOverview();
|
setupOverview();
|
||||||
render(OverviewPage);
|
render(OverviewPage);
|
||||||
expect(screen.getByRole('link', { name: /pending requests/i }))
|
expect(screen.getByTestId('verdict').textContent).toMatch(/nothing needs you/i);
|
||||||
.toHaveAttribute('href', '/admin/requests');
|
expect(screen.queryAllByTestId('needs-you')).toHaveLength(0);
|
||||||
expect(screen.getByRole('link', { name: /quarantined tracks/i }))
|
expect(screen.getByTestId('all-clear').textContent).toMatch(/no requests, reports, playback errors, duplicates, suspect sources, missing files/i);
|
||||||
.toHaveAttribute('href', '/admin/quarantine');
|
|
||||||
expect(screen.getByRole('link', { name: /^lidarr/i }))
|
|
||||||
.toHaveAttribute('href', '/admin/integrations');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking Approve on a request row calls approveRequest', async () => {
|
test('each queue that waits on you is listed with how long its oldest has waited, and opens its page', () => {
|
||||||
setupOverview({
|
setupOverview(summary({
|
||||||
requests: [
|
requests: { count: 2, since: ago(3) },
|
||||||
{ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' }
|
missing: { count: 1, since: ago(10) }
|
||||||
]
|
|
||||||
});
|
|
||||||
render(OverviewPage);
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /approve boards of canada/i }));
|
|
||||||
expect(approveRequest).toHaveBeenCalledWith('r1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clicking Reject on a request row calls rejectRequest', async () => {
|
|
||||||
setupOverview({
|
|
||||||
requests: [
|
|
||||||
{ id: 'r1', kind: 'artist', artist_name: 'Boards of Canada', status: 'pending' }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
render(OverviewPage);
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /reject boards of canada/i }));
|
|
||||||
expect(rejectRequest).toHaveBeenCalledWith('r1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Resolve fires immediately on a quarantine row', async () => {
|
|
||||||
setupOverview({
|
|
||||||
quarantine: [
|
|
||||||
{ track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
|
|
||||||
album_title: 'Geogaddi', report_count: 1, reports: [] }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
render(OverviewPage);
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /resolve roygbiv/i }));
|
|
||||||
expect(resolveQuarantine).toHaveBeenCalledWith('t1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Delete file requires two clicks (inline confirm)', async () => {
|
|
||||||
setupOverview({
|
|
||||||
quarantine: [
|
|
||||||
{ track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
|
|
||||||
album_title: 'Geogaddi', report_count: 1, reports: [] }
|
|
||||||
]
|
|
||||||
});
|
|
||||||
render(OverviewPage);
|
|
||||||
// First click: arms the confirm; no API call yet.
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^delete file roygbiv$/i }));
|
|
||||||
expect(deleteQuarantineFile).not.toHaveBeenCalled();
|
|
||||||
// Second click on the now-armed button fires the action.
|
|
||||||
await fireEvent.click(
|
|
||||||
screen.getByRole('button', { name: /confirm delete file roygbiv/i })
|
|
||||||
);
|
|
||||||
expect(deleteQuarantineFile).toHaveBeenCalledWith('t1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Delete via Lidarr requires two clicks (inline confirm)', async () => {
|
|
||||||
setupOverview({
|
|
||||||
quarantine: [
|
|
||||||
{ track_id: 't1', track_title: 'Roygbiv', artist_name: 'BoC',
|
|
||||||
album_title: 'Geogaddi', lidarr_album_mbid: 'rg-1', report_count: 1, reports: [] }
|
|
||||||
],
|
|
||||||
lidarr: { enabled: true }
|
|
||||||
});
|
|
||||||
render(OverviewPage);
|
|
||||||
await fireEvent.click(
|
|
||||||
screen.getByRole('button', { name: /^delete via lidarr roygbiv$/i })
|
|
||||||
);
|
|
||||||
expect(deleteQuarantineViaLidarr).not.toHaveBeenCalled();
|
|
||||||
await fireEvent.click(
|
|
||||||
screen.getByRole('button', { name: /confirm delete via lidarr roygbiv/i })
|
|
||||||
);
|
|
||||||
expect(deleteQuarantineViaLidarr).toHaveBeenCalledWith('t1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Delete via Lidarr is offered only for an album Lidarr holds, while Lidarr is connected', async () => {
|
|
||||||
const local = { track_id: 't1', track_title: 'Dial Tone', artist_name: 'Annadale',
|
|
||||||
album_title: 'Dial Tone', lidarr_album_mbid: null, report_count: 1, reports: [] };
|
|
||||||
const held = { track_id: 't2', track_title: 'Roygbiv', artist_name: 'BoC',
|
|
||||||
album_title: 'Geogaddi', lidarr_album_mbid: 'rg-1', report_count: 1, reports: [] };
|
|
||||||
setupOverview({ quarantine: [local, held], lidarr: { enabled: false } });
|
|
||||||
render(OverviewPage);
|
|
||||||
expect(screen.queryByRole('button', { name: /delete via lidarr/i })).toBeNull();
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
setupOverview({ quarantine: [local, held], lidarr: { enabled: true } });
|
|
||||||
render(OverviewPage);
|
|
||||||
expect(screen.queryByRole('button', { name: /delete via lidarr dial tone/i })).toBeNull();
|
|
||||||
expect(screen.getByRole('button', { name: /delete via lidarr roygbiv/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a queue that fails to load says so, with Try again, and its tile shows a dash, not 0', async () => {
|
|
||||||
const refetch = vi.fn();
|
|
||||||
(createAdminRequestsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
||||||
mockQuery({ isError: true, error: new Error('boom'), refetch })
|
|
||||||
);
|
|
||||||
(createAdminQuarantineQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
||||||
mockQuery({ isError: true, error: new Error('boom') })
|
|
||||||
);
|
|
||||||
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
|
||||||
mockQuery({ data: { enabled: false } })
|
|
||||||
);
|
|
||||||
render(OverviewPage);
|
|
||||||
expect(screen.queryByText(/no pending requests/i)).toBeNull();
|
|
||||||
expect(screen.queryByText(/no quarantined tracks/i)).toBeNull();
|
|
||||||
expect(screen.getByTestId('tile-requests').textContent).toBe('—');
|
|
||||||
expect(screen.getByTestId('tile-quarantine').textContent).toBe('—');
|
|
||||||
expect(screen.getByTestId('requests-error').textContent).toMatch(/couldn't load the requests/i);
|
|
||||||
await fireEvent.click(screen.getAllByRole('button', { name: /try again/i })[0]);
|
|
||||||
expect(refetch).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('"View all" link surfaces only when count exceeds the preview limit', async () => {
|
|
||||||
// 6 requests > limit of 5; quarantine empty.
|
|
||||||
const six = Array.from({ length: 6 }, (_, i) => ({
|
|
||||||
id: `r${i}`,
|
|
||||||
kind: 'artist' as const,
|
|
||||||
artist_name: `Artist ${i}`,
|
|
||||||
status: 'pending' as const
|
|
||||||
}));
|
}));
|
||||||
setupOverview({ requests: six });
|
|
||||||
render(OverviewPage);
|
render(OverviewPage);
|
||||||
expect(screen.getByRole('link', { name: /view all 6/i })).toHaveAttribute(
|
expect(screen.getByTestId('verdict').textContent).toMatch(/2 things need you/);
|
||||||
'href', '/admin/requests'
|
const rows = screen.getAllByTestId('needs-you').map((r) => r.textContent?.replace(/\s+/g, ' ').trim());
|
||||||
|
expect(rows).toEqual(['2 requests to decide oldest 3d ago Open', '1 missing file oldest 10d ago Open']);
|
||||||
|
expect(screen.getByRole('link', { name: /2 requests to decide/i }).getAttribute('href')).toBe('/admin/requests');
|
||||||
|
expect(screen.getByTestId('all-clear').textContent).not.toMatch(/requests/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed scan needs you too', async () => {
|
||||||
|
const { readable } = await import('svelte/store');
|
||||||
|
(createScanStatusQuery as ReturnType<typeof vi.fn>).mockReturnValueOnce(
|
||||||
|
readable({
|
||||||
|
data: { id: 's1', started_at: ago(1), finished_at: ago(1), in_flight: false, error_message: 'library root not mounted' },
|
||||||
|
isPending: false,
|
||||||
|
isError: false
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
setupOverview();
|
||||||
|
render(OverviewPage);
|
||||||
|
expect(screen.getByTestId('verdict').textContent).toMatch(/1 thing needs you/);
|
||||||
|
expect(screen.getByText(/the last library scan failed: library root not mounted/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when the queues fail to load it says so, with Try again, rather than reading as all-clear', async () => {
|
||||||
|
const refetch = vi.fn();
|
||||||
|
setupOverview(null, refetch);
|
||||||
|
render(OverviewPage);
|
||||||
|
expect(screen.queryByTestId('verdict')).toBeNull();
|
||||||
|
expect(screen.getByTestId('summary-error').textContent).toMatch(/can't say whether anything needs you/i);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
|
||||||
|
expect(refetch).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders Re-search missing art button and invokes on click', async () => {
|
test('renders Re-search missing art button and invokes on click', async () => {
|
||||||
setupOverview();
|
setupOverview();
|
||||||
render(OverviewPage);
|
render(OverviewPage);
|
||||||
const btn = screen.getByRole('button', { name: /re-search missing art/i });
|
const btn = screen.getByRole('button', { name: /re-search missing art/i });
|
||||||
expect(btn).toBeInTheDocument();
|
|
||||||
await fireEvent.click(btn);
|
await fireEvent.click(btn);
|
||||||
expect(researchMissingArt).toHaveBeenCalled();
|
expect(researchMissingArt).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -318,3 +198,32 @@ describe('admin Overview page', () => {
|
|||||||
expect(await screen.findByText('Processing')).toBeInTheDocument();
|
expect(await screen.findByText('Processing')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('admin layout', () => {
|
||||||
|
test('the sections run down a rail, each queue carrying how many wait on you', () => {
|
||||||
|
setupOverview(summary({ requests: { count: 2, since: ago(1) }, duplicates: { count: 5, since: ago(4) } }));
|
||||||
|
const children = createRawSnippet(() => ({ render: () => '<p data-testid="admin-body">body</p>' }));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
render(AdminLayout, { children } as any);
|
||||||
|
const nav = screen.getByRole('navigation', { name: /admin sections/i });
|
||||||
|
const links = Array.from(nav.querySelectorAll('a')).map((a) => [
|
||||||
|
a.getAttribute('href'),
|
||||||
|
a.textContent?.replace(/\s+/g, ' ').trim()
|
||||||
|
]);
|
||||||
|
expect(links).toEqual([
|
||||||
|
['/admin', 'Overview'],
|
||||||
|
['/admin/requests', 'Requests 2'],
|
||||||
|
['/admin/quarantine', 'Quarantine'],
|
||||||
|
['/admin/playback-errors', 'Playback errors'],
|
||||||
|
['/admin/duplicates', 'Duplicates 5'],
|
||||||
|
['/admin/suspect-sources', 'Suspect sources'],
|
||||||
|
['/admin/missing-files', 'Missing files'],
|
||||||
|
['/admin/integrations', 'Integrations'],
|
||||||
|
['/admin/users', 'Users'],
|
||||||
|
['/admin/diagnostics', 'Diagnostics'],
|
||||||
|
['/admin/tuning', 'Tuning']
|
||||||
|
]);
|
||||||
|
expect(screen.getByRole('link', { name: /^overview/i })).toHaveAttribute('aria-current', 'page');
|
||||||
|
expect(screen.getByTestId('admin-body')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user