From a5ad301c940aef13e5901d933ff46054fd11734b Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 11 Oct 2026 11:52:51 -0400 Subject: [PATCH] feat: Users, Diagnostics and Tuning sit on the admin layout like the rest: debug mode is a row action on Users, every admin load error has Try again, and figures carry their scale (M503 #5608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Users: a one-line count of accounts, admins and open invites; Turn on/off debug mode in each row's actions, and a "debug on" badge that opens that account's events - Diagnostics: no title or paragraph of its own; devices named by account, OS, app version and last seen, not client id; opens on ?account= - Tuning: each profile, the taste build and Discover are row groups with one-line hints; the sparkline has a 0–100% axis - Requests, Duplicates, Missing files and Suspect sources: load errors offer Try again - Library: cover art reads "N of M albums have art" Co-Authored-By: Claude Opus 5.5 --- web/src/routes/admin/diagnostics/+page.svelte | 96 ++-- web/src/routes/admin/duplicates/+page.svelte | 10 +- web/src/routes/admin/library/+page.svelte | 2 +- web/src/routes/admin/library/library.test.ts | 2 +- .../routes/admin/missing-files/+page.svelte | 5 +- .../admin/missing-files/missing-files.test.ts | 2 +- web/src/routes/admin/requests/+page.svelte | 5 +- .../routes/admin/suspect-sources/+page.svelte | 5 +- web/src/routes/admin/tuning/+page.svelte | 497 +++++++----------- web/src/routes/admin/users/+page.svelte | 69 ++- web/src/routes/admin/users/users.test.ts | 32 +- 11 files changed, 359 insertions(+), 366 deletions(-) diff --git a/web/src/routes/admin/diagnostics/+page.svelte b/web/src/routes/admin/diagnostics/+page.svelte index 3a3319db..afad091a 100644 --- a/web/src/routes/admin/diagnostics/+page.svelte +++ b/web/src/routes/admin/diagnostics/+page.svelte @@ -1,5 +1,7 @@ - - {pageTitle('Tuning')} - +{pageTitle('Admin · Tuning')} -
-
-

Recommendation tuning

-

- The defaults-discovery lab. Changes apply live — radio on the next request, daily mixes on - the next rebuild — and every change is recorded so the metrics page can tie outcome shifts - to knob turns. Found-good values get baked into shipped defaults; a dot marks knobs that - currently deviate from them. -

-
+

Tuning

+ +{#snippet knobs(scope: TuningScope, title: string, hint: string, fields: { key: string; label: string; hint: string }[], step: (key: string) => string, shipped: (key: string) => number)} + + {#each fields as f (f.key)} + + {#if deviates(scope, f.key)} + Changed from the default, {shipped(f.key)} + {/if} + + + {/each} +
+ + +
+
+{/snippet} + +
+

+ Scoring knobs that apply live, each change recorded against the trends below. A dot marks one + changed from the shipped default. +

{#if loadFailed} -

Couldn't load tuning settings.

+

+ Couldn't load the tuning settings. + +

{:else if !snapshot}

Loading…

{:else} -
+ {@const snap = snapshot} +
{#each profileScopes as p (p.scope)} -
-
-

{p.label}

-

{p.blurb}

-
-
- {#each weightFields as f (f.key)} -
- - -
- {/each} -
-
- - -
-
+ {@render knobs(p.scope, p.label, p.blurb, weightFields, () => '0.1', (k) => snap.shipped.profiles[p.scope][k as keyof WeightProfile])} {/each} -
- -
-
-

Taste profile build

-

- How plays become the learned taste profile: the influence half-life and the - completion→engagement curve (must stay ordered: hard-skip < neutral < full). -

-
-
- {#each tasteFields as f (f.key)} -
- - -
- {/each} -
-
- - -
-
- - -
-
-

Discover requests

-

- How the Discover suggestion deck ranks out-of-library artists. Tag coverage for - artists you don't own is partial by nature, so an untagged candidate keeps its - similarity score rather than being pushed down. -

-
-
- {#each discoverFields as f (f.key)} -
- - -
- {/each} -
-
- - -
-
+ {@render knobs( + 'taste', + 'Taste profile build', + 'How plays become the taste profile. Keep dislike < neither < like.', + tasteFields, + (k) => (k === 'half_life_days' ? '1' : '0.05'), + (k) => snap.shipped.taste[k as keyof TasteTuning] + )} + {@render knobs( + 'discover', + 'Discover requests', + "How Discover ranks artists you don't have.", + discoverFields, + (k) => (k === 'snooze_days' ? '1' : '0.1'), + (k) => snap.shipped.discover[k as keyof DiscoverTuning] + )} +
{/if} - -
-
-

Weekly trends

-

- Skip rate per surface over the last {trends?.weeks ?? 12} weeks (lower is better; all - users aggregated, rates only). Dashed ticks mark tuning changes. Taste hit is the share - of plays whose artist fits the current taste profile. - The skip and completion columns show the most recent week - alone, not the whole window — the figure after the skip rate is that week's - play count, so a rate drawn from a handful of listens reads as what it is. -

-
- {#if trendsFailed} -

Couldn't load trends.

- {:else if !trends} -

Loading…

- {:else if trends.series.length === 0} -

- No plays recorded yet — trends appear once listening accumulates. -

- {:else} - - - - - - - - - - - - - {#each trends.series as s (s.key)} - - - - - - - + + +
+ {#if trendsFailed} +

+ Couldn't load trends. + +

+ {:else if !trends} +

Loading…

+ {:else if trends.series.length === 0} +

No plays recorded yet; trends appear once listening accumulates.

+ {:else} + {@const t = trends} +
SurfaceSkip rate by weekPlays (window)Skip (last wk)Completion (last wk)Taste hit (window)
{s.label} - - - {#each trends.markers as m, i (i)} - - {markerSummary(m)} - - {/each} - {#if s.points.length > 1} - - {:else if s.points.length === 1} - - {/if} - - {s.plays} - {pct(latest(s).skip)} - /{latest(s).plays} - {pct(latest(s).completion)}{pct(windowTasteHitRate(s))}
+ + + + + + + + - {/each} - -
SurfaceSkip rate, last {t.weeks} weeksPlays (window)Skip (last wk)Completion (last wk)Taste hit (window)
- {#if trends.markers.length > 0} -
-

Tuning changes in this window

-
    - {#each trends.markers as m, i (i)} -
  • {markerSummary(m)}
  • + + + {#each t.series as s (s.key)} + + {s.label} + + + 100% + 0% + + + + {#each t.markers as m, i (i)} + + {markerSummary(m)} + + {/each} + {#if s.points.length > 1} + + {:else if s.points.length === 1} + + {/if} + + + + {s.plays} + + {pct(latest(s).skip)} + /{latest(s).plays} + + {pct(latest(s).completion)} + {pct(windowTasteHitRate(s))} + {/each} -
-
+ + + {#if t.markers.length > 0} +
+

Tuning changes in this window

+
    + {#each t.markers as m, i (i)} +
  • {markerSummary(m)}
  • + {/each} +
+
+ {/if} {/if} - {/if} -
+
+ diff --git a/web/src/routes/admin/users/+page.svelte b/web/src/routes/admin/users/+page.svelte index f1271e6b..cbe683a5 100644 --- a/web/src/routes/admin/users/+page.svelte +++ b/web/src/routes/admin/users/+page.svelte @@ -10,17 +10,18 @@ deleteUser, resetUserPassword, updateUserAutoApprove, + updateUserDebugMode, createAdminUsersQuery, createAdminInvitesQuery, type AdminUser, type AdminInvite, type CreateUserInput } from '#lib/api/admin.js'; - import { errCode } from '#lib/api/errors.js'; + import { errCode, errMessage } from '#lib/api/errors.js'; import { pushToast } from '#lib/stores/toast.svelte.js'; import Modal from '#lib/components/Modal.svelte'; import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte'; - import { Shield, ShieldOff, CheckCircle2, XCircle, KeyRound, Trash2, Copy } from 'lucide-svelte'; + import { Shield, ShieldOff, CheckCircle2, XCircle, KeyRound, Trash2, Copy, Bug, BugOff } from 'lucide-svelte'; const client = useQueryClient(); @@ -163,6 +164,21 @@ } } + // Debug mode makes the account's devices report diagnostics (#5608): a + // row action here, with the badge linking to that account's events. + async function onToggleDebug(u: AdminUser) { + saving = true; + try { + await updateUserDebugMode(u.id, !u.debug_mode_enabled); + await client.invalidateQueries({ queryKey: qk.adminUsers() }); + pushToast(`Debug mode ${u.debug_mode_enabled ? 'off' : 'on'} for ${u.username}.`); + } catch (e: unknown) { + pushToast(`Debug mode not changed: ${errMessage(e)}`, 'error'); + } finally { + saving = false; + } + } + async function onGenerateInvite() { saving = true; try { @@ -205,18 +221,22 @@ {pageTitle('Admin · Users')} +

Users

+
-
-

Users

-

- Manage user accounts and invites for this Minstrel instance. + {#if usersQuery.data} + {@const admins = users.filter((u) => u.is_admin).length} +

+ {users.length === 1 ? '1 account' : `${users.length} accounts`}, {admins === 1 ? '1 admin' : `${admins} admins`}{invitesQuery.data + ? ` · ${invites.length === 1 ? '1 invite open' : `${invites.length} invites open`}` + : ''}.

-
+ {/if}
-

Accounts

+

Accounts

+

{:else if users.length === 0}

No users yet.

{:else} @@ -252,6 +275,15 @@ onclick: () => onToggleAutoApprove(u), disabled: saving }, + { + icon: u.debug_mode_enabled ? BugOff : Bug, + label: u.debug_mode_enabled ? 'Turn off debug mode' : 'Turn on debug mode', + ariaLabel: u.debug_mode_enabled + ? `Turn off debug mode for ${u.username}` + : `Turn on debug mode for ${u.username}`, + onclick: () => onToggleDebug(u), + disabled: saving + }, { icon: KeyRound, label: 'Reset password', @@ -278,6 +310,13 @@ data-testid="admin-badge" >admin {/if} + {#if u.debug_mode_enabled} + debug on + {/if} {#if u.auto_approve_requests}
-

Invites

+

Invites

-

- Invites expire after 24 hours and can be used once. Share the token with the - person you want to invite — they enter it on the registration page. -

+

Each works once within 24 hours, entered on the sign-up page.

{#if invitesQuery.isPending}

Reading invites…

{:else if invitesQuery.isError} -

Couldn't load invites.

+

+ Couldn't load the invites. + +

{:else if invites.length === 0}

No active invites.

{:else} diff --git a/web/src/routes/admin/users/users.test.ts b/web/src/routes/admin/users/users.test.ts index 719bb081..a6d6f578 100644 --- a/web/src/routes/admin/users/users.test.ts +++ b/web/src/routes/admin/users/users.test.ts @@ -16,7 +16,8 @@ vi.mock('#lib/api/admin.js', () => ({ createUser: vi.fn(), deleteUser: vi.fn(), resetUserPassword: vi.fn(), - updateUserAutoApprove: vi.fn() + updateUserAutoApprove: vi.fn(), + updateUserDebugMode: vi.fn() })); import AdminUsersPage from './+page.svelte'; @@ -29,7 +30,8 @@ import { createUser, deleteUser, resetUserPassword, - updateUserAutoApprove + updateUserAutoApprove, + updateUserDebugMode } from '#lib/api/admin.js'; const alice: AdminUser = { @@ -207,4 +209,30 @@ describe('/admin/users', () => { await fireEvent.click(screen.getAllByRole('button', { name: /Enable auto-approve/i })[0]); expect(updateUserAutoApprove).toHaveBeenCalledWith(expect.any(String), true); }); + + test('debug mode is a row action, and an account with it on links to its events', async () => { + (updateUserDebugMode as ReturnType).mockResolvedValue({}); + setup([alice, { ...bob, debug_mode_enabled: true }]); + await fireEvent.click(screen.getAllByRole('button', { name: /turn on debug mode for alice/i })[0]); + expect(updateUserDebugMode).toHaveBeenCalledWith('u1', true); + expect(screen.getByTestId('debug-badge').getAttribute('href')).toBe('/admin/diagnostics?account=u2'); + }); + + test('the page opens with how many accounts, admins and open invites there are', () => { + setup([alice, bob], [sampleInvite]); + expect(screen.getByTestId('users-summary').textContent?.replace(/\s+/g, ' ').trim()).toBe( + '2 accounts, 1 admin · 1 invite open.' + ); + }); + + test('accounts that fail to load say so, with Try again', async () => { + const refetch = vi.fn(); + (createAdminUsersQuery as ReturnType).mockReturnValue( + mockQuery({ isError: true, error: new Error('x'), refetch }) + ); + (createAdminInvitesQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); + render(AdminUsersPage); + await fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(refetch).toHaveBeenCalled(); + }); });