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)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m34s
release / integration (push) Successful in 5m19s
release / android (push) Successful in 5m24s
release / Build signed APK (releases and dev) (push) Successful in 5m26s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 11:52:51 -04:00
co-authored by Claude Opus 5.5
parent 0049b64ff1
commit a5ad301c94
11 changed files with 359 additions and 366 deletions
+30 -24
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { pageTitle } from '#lib/branding.js'; import { pageTitle } from '#lib/branding.js';
import { page } from '$app/state';
import { relativeTime } from '#lib/utils/relativeTime.js';
import { Copy, Download, Activity } from 'lucide-svelte'; import { Copy, Download, Activity } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import { import {
@@ -49,8 +51,9 @@
// start/end window is an Advanced affordance. // start/end window is an Advanced affordance.
const DEFAULT_LIMIT = 500; const DEFAULT_LIMIT = 500;
// Filter state. // Filter state. Users links here with ?account=<id> to open on that
let accountId = $state(''); // account's events (#5608).
let accountId = $state(page.url.searchParams.get('account') ?? '');
let clientId = $state(''); let clientId = $state('');
let kind = $state(''); let kind = $state('');
let fromLocal = $state(''); let fromLocal = $state('');
@@ -108,6 +111,12 @@
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString(); return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
} }
// A device by what the operator knows it as, not its client id.
function deviceLabel(d: { username: string; os_version: string; app_version: string; last_seen: string; event_count: number }): string {
const parts = [d.username, d.os_version, d.app_version && `app ${d.app_version}`, `seen ${relativeTime(d.last_seen)}`];
return `${parts.filter(Boolean).join(' · ')} (${d.event_count} events)`;
}
function payloadPreview(p: Record<string, unknown>): string { function payloadPreview(p: Record<string, unknown>): string {
const s = JSON.stringify(p); const s = JSON.stringify(p);
return s.length > 120 ? s.slice(0, 120) + '…' : s; return s.length > 120 ? s.slice(0, 120) + '…' : s;
@@ -122,10 +131,10 @@
await updateUserDebugMode(selectedUser.id, !selectedUser.debug_mode_enabled); await updateUserDebugMode(selectedUser.id, !selectedUser.debug_mode_enabled);
await client.invalidateQueries({ queryKey: qk.adminUsers() }); await client.invalidateQueries({ queryKey: qk.adminUsers() });
pushToast( pushToast(
`Debug mode ${selectedUser.debug_mode_enabled ? 'disabled' : 'enabled'} for ${selectedUser.username}` `Debug mode ${selectedUser.debug_mode_enabled ? 'off' : 'on'} for ${selectedUser.username}.`
); );
} catch (e: unknown) { } catch (e: unknown) {
pushToast(`Toggle failed: ${errMessage(e)}`, 'error'); pushToast(`Debug mode not changed: ${errMessage(e)}`, 'error');
} finally { } finally {
toggling = false; toggling = false;
} }
@@ -176,24 +185,21 @@
<svelte:head><title>{pageTitle('Admin · Diagnostics')}</title></svelte:head> <svelte:head><title>{pageTitle('Admin · Diagnostics')}</title></svelte:head>
<h2 class="sr-only">Diagnostics</h2>
<div class="space-y-4"> <div class="space-y-4">
<header>
<h1 class="font-display text-2xl font-medium text-text-primary">Device diagnostics</h1>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
Enable debug mode on an account to have its device(s) stream a timeseries Turn on debug mode for an account and its devices report connectivity, sync and power events
of connectivity, UPnP-sync, and power/Doze events here. Filter to the here, to copy or download.
window you care about, then copy or download the slice for analysis.
</p> </p>
</header>
<!-- Account debug-mode control --> <!-- Account debug-mode control -->
<section class="rounded-md border border-border p-3"> <section class="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-surface px-4 py-3">
<div class="flex flex-wrap items-end gap-3">
<label class="block text-xs text-text-secondary"> <label class="block text-xs text-text-secondary">
Account Account
<select <select
bind:value={accountId} bind:value={accountId}
class="mt-1 block w-56 rounded border border-border bg-surface px-2 py-1.5 text-sm text-text-primary" class="mt-1 block w-56 rounded-md border border-border bg-background px-2 py-1.5 text-sm text-text-primary"
> >
<option value="">All accounts</option> <option value="">All accounts</option>
{#each users as u (u.id)} {#each users as u (u.id)}
@@ -201,23 +207,20 @@
{/each} {/each}
</select> </select>
</label> </label>
{#if selectedUser} {#if selectedUser}
<span class="inline-flex items-center gap-1 text-sm text-text-secondary" data-testid="debug-state">
<Activity size={14} aria-hidden="true" />
{selectedUser.debug_mode_enabled ? 'Debug mode on: reporting' : 'Debug mode off'}
</span>
<button <button
type="button" type="button"
onclick={toggleDebug} onclick={toggleDebug}
disabled={toggling} disabled={toggling}
class="rounded px-3 py-2 text-sm text-action-fg hover:opacity-90 disabled:opacity-50 class="rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50"
{selectedUser.debug_mode_enabled ? 'bg-action-destructive' : 'bg-action-primary'}"
> >
{selectedUser.debug_mode_enabled ? 'Disable debug mode' : 'Enable debug mode'} {selectedUser.debug_mode_enabled ? 'Turn off' : 'Turn on'}
</button> </button>
<span class="inline-flex items-center gap-1 text-xs text-text-muted">
<Activity size={14} />
{selectedUser.debug_mode_enabled ? 'Reporting active' : 'Reporting off'}
</span>
{/if} {/if}
</div>
</section> </section>
<!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time <!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time
@@ -232,7 +235,7 @@
> >
<option value="">All devices</option> <option value="">All devices</option>
{#each devices as d (d.client_id)} {#each devices as d (d.client_id)}
<option value={d.client_id}>{d.client_id.slice(0, 12)} · {d.event_count}</option> <option value={d.client_id}>{deviceLabel(d)}</option>
{/each} {/each}
</select> </select>
</label> </label>
@@ -325,7 +328,10 @@
<!-- Timeline --> <!-- Timeline -->
{#if diagQuery.isError} {#if diagQuery.isError}
<p class="text-error-fg">Couldn't load: {errMessage(diagQuery.error)}</p> <p class="text-sm text-error-fg" data-testid="diagnostics-error">
Couldn't load the events: {errMessage(diagQuery.error)}
<button type="button" class="underline hover:no-underline" onclick={() => diagQuery.refetch()}>Try again</button>
</p>
{:else if diagQuery.isPending} {:else if diagQuery.isPending}
<p class="text-text-secondary">Loading…</p> <p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0} {:else if rows.length === 0}
+8 -2
View File
@@ -246,7 +246,10 @@
{#if resolved.isPending} {#if resolved.isPending}
<p class="text-text-secondary">Loading…</p> <p class="text-text-secondary">Loading…</p>
{:else if resolved.isError && !resolved.isFetchNextPageError} {:else if resolved.isError && !resolved.isFetchNextPageError}
<p class="text-error-fg">Couldn't load what was resolved.</p> <p class="text-sm text-error-fg">
Couldn't load what was resolved.
<button type="button" class="underline hover:no-underline" onclick={() => resolved.refetch()}>Try again</button>
</p>
{:else if activity.length === 0} {:else if activity.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center" data-testid="resolved-empty"> <div class="rounded-lg border border-border bg-surface p-6 text-center" data-testid="resolved-empty">
<Copy size={28} strokeWidth={1} class="mx-auto text-text-muted" /> <Copy size={28} strokeWidth={1} class="mx-auto text-text-muted" />
@@ -296,7 +299,10 @@
{:else if query.isPending} {:else if query.isPending}
<p class="text-text-secondary">Loading duplicates…</p> <p class="text-text-secondary">Loading duplicates…</p>
{:else if query.isError && !query.isFetchNextPageError} {:else if query.isError && !query.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the duplicates report.</p> <p class="text-sm text-error-fg">
Couldn't load the duplicates report.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</p>
{:else if groups.length === 0} {:else if groups.length === 0}
<!-- Several states all show zero groups and mean different things. Telling <!-- Several states all show zero groups and mean different things. Telling
them apart is what stops an empty page reading as a broken feature. --> them apart is what stops an empty page reading as a broken feature. -->
+1 -1
View File
@@ -175,7 +175,7 @@
<SettingRow label="Covers" hint={coverage <SettingRow label="Covers" hint={coverage
? coverage.total === 0 ? coverage.total === 0
? 'The library is empty.' ? 'The library is empty.'
: `${n(coverage.with_art)} with art · ${n(coverage.pending)} still to look for · ${n(coverage.settled)} no source has` : `${n(coverage.with_art)} of ${n(coverage.total)} albums have art · ${n(coverage.pending)} still to look for · ${n(coverage.settled)} no source has`
: undefined}> : undefined}>
<button type="button" onclick={onRefetchCovers} disabled={refetching} class={button}> <button type="button" onclick={onRefetchCovers} disabled={refetching} class={button}>
{refetching ? 'Starting…' : 'Look for missing covers'} {refetching ? 'Starting…' : 'Look for missing covers'}
+1 -1
View File
@@ -97,7 +97,7 @@ describe('Admin › Library', () => {
test('cover art says how many have art, and can look for the missing and look again', async () => { test('cover art says how many have art, and can look for the missing and look again', async () => {
setup(); setup();
expect(screen.getByText('90 with art · 6 still to look for · 4 no source has')).toBeInTheDocument(); expect(screen.getByText('90 of 100 albums have art · 6 still to look for · 4 no source has')).toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /look for missing covers/i })); await fireEvent.click(screen.getByRole('button', { name: /look for missing covers/i }));
await waitFor(() => expect(refetchMissingCovers).toHaveBeenCalled()); await waitFor(() => expect(refetchMissingCovers).toHaveBeenCalled());
await fireEvent.click(screen.getByRole('button', { name: /^look again$/i })); await fireEvent.click(screen.getByRole('button', { name: /^look again$/i }));
@@ -87,7 +87,10 @@
{#if query.isPending} {#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p> <p class="text-text-secondary">Checking what's missing…</p>
{:else if query.isError} {:else if query.isError}
<p class="text-error-fg">Couldn't load the missing-files list.</p> <p class="text-sm text-error-fg">
Couldn't load the missing files.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</p>
{:else if groups.length === 0} {:else if groups.length === 0}
<!-- Empty state explains the feature, not just the emptiness: an operator <!-- Empty state explains the feature, not just the emptiness: an operator
who has never seen this page shouldn't have to guess what would put a who has never seen this page shouldn't have to guess what would put a
@@ -123,7 +123,7 @@ describe('admin missing files', () => {
test('shows an error line when the query fails', () => { test('shows an error line when the query fails', () => {
renderWith(undefined, { isError: true }); renderWith(undefined, { isError: true });
expect(screen.getByText(/couldn't load the missing-files list/i)).toBeTruthy(); expect(screen.getByText(/couldn't load the missing files/i)).toBeTruthy();
}); });
// Not wanted yet is the common case for a folder that just went missing; // Not wanted yet is the common case for a folder that just went missing;
+4 -1
View File
@@ -164,7 +164,10 @@
{#if query.isPending} {#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p> <p class="text-text-secondary">Reading the queue…</p>
{:else if query.isError} {:else if query.isError}
<p class="text-error-fg">Couldn't load requests.</p> <p class="text-sm text-error-fg">
Couldn't load the requests.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</p>
{:else if rows.length === 0} {:else if rows.length === 0}
<p class="text-text-secondary">Nothing here.</p> <p class="text-text-secondary">Nothing here.</p>
{:else} {:else}
@@ -110,7 +110,10 @@
{#if query.isPending} {#if query.isPending}
<p class="text-text-secondary">Reading file names…</p> <p class="text-text-secondary">Reading file names…</p>
{:else if query.isError && !query.isFetchNextPageError} {:else if query.isError && !query.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the suspect-sources list.</p> <p class="text-sm text-error-fg">
Couldn't load the suspect sources.
<button type="button" class="underline hover:no-underline" onclick={() => query.refetch()}>Try again</button>
</p>
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center"> <div class="rounded-lg border border-border bg-surface p-6 text-center">
<FileCheck size={28} strokeWidth={1} class="mx-auto text-text-muted" /> <FileCheck size={28} strokeWidth={1} class="mx-auto text-text-muted" />
+136 -231
View File
@@ -17,6 +17,8 @@
} from '#lib/api/tuning.js'; } from '#lib/api/tuning.js';
import { errMessage } from '#lib/api/errors.js'; import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js'; import { pushToast } from '#lib/stores/toast.svelte.js';
import SettingsGroup from '#lib/components/settings/SettingsGroup.svelte';
import SettingRow from '#lib/components/settings/SettingRow.svelte';
// The defaults-discovery lab (#1250): DB-backed scoring-weight // The defaults-discovery lab (#1250): DB-backed scoring-weight
// profiles + taste-build knobs with live effect. This card exists to // profiles + taste-build knobs with live effect. This card exists to
@@ -26,38 +28,37 @@
const weightFields: { key: keyof WeightProfile; label: string; hint: string }[] = [ const weightFields: { key: keyof WeightProfile; label: string; hint: string }[] = [
{ key: 'base_weight', label: 'Base weight', hint: 'Floor score every candidate starts from.' }, { key: 'base_weight', label: 'Base weight', hint: 'Floor score every candidate starts from.' },
{ key: 'like_boost', label: 'Like boost', hint: 'Added when the track is liked.' }, { key: 'like_boost', label: 'Like boost', hint: 'Added when the track is liked.' },
{ key: 'recency_weight', label: 'Recency weight', hint: 'Rewards tracks not played recently (0–30d ramp).' }, { key: 'recency_weight', label: 'Recency weight', hint: 'Rewards tracks not played in the last 30 days.' },
{ key: 'skip_penalty', label: 'Skip penalty', hint: 'Subtracts skips/plays ratio.' }, { key: 'skip_penalty', label: 'Skip penalty', hint: 'Subtracts the share of plays skipped.' },
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' }, { key: 'jitter_magnitude', label: 'Jitter', hint: 'Random shuffle among near-ties.' },
{ key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' }, { key: 'context_weight', label: 'Context weight', hint: 'Fit with the current listening session.' },
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' }, { key: 'similarity_weight', label: 'Similarity weight', hint: 'Likeness to the seed.' },
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }, { key: 'taste_weight', label: 'Taste weight', hint: 'Fit with the learned taste profile.' },
{ key: 'context_time_weight', label: 'Time-of-day weight', hint: "Artist's time-of-day/weekday affinity for the current context, in [-1, +1]. 0 = ignore when you listen." } { key: 'context_time_weight', label: 'Time-of-day weight', hint: "Fit with when you usually play the artist. 0 ignores it." }
]; ];
const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [ const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [
{ key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." }, { key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this many days." },
{ key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads −1.' }, { key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Played this little or less reads as a dislike.' },
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' }, { key: 'engagement_neutral', label: 'Neutral point', hint: 'Played this far reads as neither.' },
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }, { key: 'engagement_full', label: 'Full point', hint: 'Played this far or more reads as a like.' },
{ key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' }, { key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'Style and mood tags against the file genre. 0 is genre only.' },
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' }, { key: 'era_scale', label: 'Era weight', hint: 'How much the decade counts. 0 ignores it.' },
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' } { key: 'mood_scale', label: 'Mood weight', hint: 'How much the mood counts. 0 ignores it.' }
]; ];
const discoverFields: { key: keyof DiscoverTuning; label: string; hint: string }[] = [ const discoverFields: { key: keyof DiscoverTuning; label: string; hint: string }[] = [
{ key: 'tag_overlap_weight', label: 'Taste-tag weight', hint: "How strongly a candidate's tags matching your taste boosts it. score x (1 + w x overlap), so 0 turns the tag term off and ranks on similarity alone. An artist with no cached tags is never penalised." }, { key: 'tag_overlap_weight', label: 'Taste-tag weight', hint: 'Boost for a suggestion whose tags fit your taste. 0 ranks on likeness alone.' },
{ key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' } { key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" hides a suggestion.' }
]; ];
const profileScopes: { scope: WeightProfileScope; label: string; blurb: string }[] = [ const profileScopes: { scope: WeightProfileScope; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' }, { scope: 'radio', label: 'Radio', blurb: 'Listening that starts from a seed.' },
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, the discovery mixes, and "You might like".' }, { scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, the discovery mixes and "You might like".' },
{ {
scope: 'songs_like', scope: 'songs_like',
label: 'Songs like…', label: 'Songs like…',
blurb: blurb: 'Everything should sound like the seed; likeness leads on purpose.'
'The tightest surface: everything here should sound like the seed track. Similarity dominates on purpose — raising like/taste/recency here is what makes these mixes wander.'
} }
]; ];
@@ -83,7 +84,8 @@
form = f; form = f;
} }
$effect(() => { function loadTuning() {
loadFailed = false;
getTuning() getTuning()
.then((snap) => { .then((snap) => {
fillForm(snap); fillForm(snap);
@@ -92,7 +94,8 @@
.catch(() => { .catch(() => {
loadFailed = true; loadFailed = true;
}); });
}); }
$effect(loadTuning);
// A knob deviates when its CURRENT SAVED value differs from shipped; // A knob deviates when its CURRENT SAVED value differs from shipped;
// the dot marks where this install has drifted from defaults. // the dot marks where this install has drifted from defaults.
@@ -166,7 +169,8 @@
let trends = $state<TrendsResponse | null>(null); let trends = $state<TrendsResponse | null>(null);
let trendsFailed = $state(false); let trendsFailed = $state(false);
$effect(() => { function loadTrends() {
trendsFailed = false;
getTrends() getTrends()
.then((t) => { .then((t) => {
trends = t; trends = t;
@@ -174,10 +178,13 @@
.catch(() => { .catch(() => {
trendsFailed = true; trendsFailed = true;
}); });
}); }
$effect(loadTrends);
const SPARK_W = 220; const SPARK_W = 220;
const SPARK_H = 36; const SPARK_H = 36;
// Room left of the plot for the y axis's 0% and 100%.
const GUTTER = 30;
const TREND_LOW_VOLUME = 20; const TREND_LOW_VOLUME = 20;
// The week axis is the sorted union of every series' buckets, so all // The week axis is the sorted union of every series' buckets, so all
@@ -237,6 +244,10 @@
: { skip: 0, completion: 0, plays: 0 }; : { skip: 0, completion: 0, plays: 0 };
} }
const button =
'rounded-md border border-border px-3 py-1.5 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary disabled:opacity-50';
const primary = 'rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50';
function pct(v: number): string { function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`; return `${(v * 100).toFixed(0)}%`;
} }
@@ -249,215 +260,109 @@
} }
</script> </script>
<svelte:head> <svelte:head><title>{pageTitle('Admin · Tuning')}</title></svelte:head>
<title>{pageTitle('Tuning')}</title>
</svelte:head>
<div class="space-y-6 p-4"> <h2 class="sr-only">Tuning</h2>
<div>
<h1 class="text-xl font-semibold">Recommendation tuning</h1> {#snippet knobs(scope: TuningScope, title: string, hint: string, fields: { key: string; label: string; hint: string }[], step: (key: string) => string, shipped: (key: string) => number)}
<p class="mt-1 max-w-3xl text-sm text-text-secondary"> <SettingsGroup {title} {hint}>
The defaults-discovery lab. Changes apply live — radio on the next request, daily mixes on {#each fields as f (f.key)}
the next rebuild — and every change is recorded so the metrics page can tie outcome shifts <SettingRow label={f.label} for="{scope}-{f.key}" hint={f.hint}>
to knob turns. Found-good values get baked into shipped defaults; a dot marks knobs that {#if deviates(scope, f.key)}
currently deviate from them. <span
</p> class="inline-block h-1.5 w-1.5 rounded-full bg-accent"
title="Deviates from the shipped default ({shipped(f.key)})"
><span class="sr-only">Changed from the default, {shipped(f.key)}</span></span>
{/if}
<input
id="{scope}-{f.key}"
type="number"
step={step(f.key)}
min={scope === 'discover' ? '0' : undefined}
bind:value={form[scope][f.key]}
class="w-24 rounded-md border border-border bg-background px-2 py-1.5 text-right text-sm tabular-nums"
/>
</SettingRow>
{/each}
<div class="flex flex-wrap gap-2 px-4 py-3">
<button type="button" disabled={saving !== null} onclick={() => save(scope)} class={primary}>
Save {title.toLowerCase()}
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset(scope)}
aria-label="Reset {title} to defaults"
class={button}
>
Reset to defaults
</button>
</div> </div>
</SettingsGroup>
{/snippet}
<div class="space-y-6">
<p class="text-sm text-text-secondary">
Scoring knobs that apply live, each change recorded against the trends below. A dot marks one
changed from the shipped default.
</p>
{#if loadFailed} {#if loadFailed}
<p class="text-sm text-text-secondary">Couldn't load tuning settings.</p> <p class="text-sm text-error-fg" data-testid="tuning-error">
Couldn't load the tuning settings.
<button type="button" class="underline hover:no-underline" onclick={loadTuning}>Try again</button>
</p>
{:else if !snapshot} {:else if !snapshot}
<p class="text-sm text-text-secondary">Loading…</p> <p class="text-sm text-text-secondary">Loading…</p>
{:else} {:else}
<section class="grid gap-4 lg:grid-cols-2"> {@const snap = snapshot}
<div class="grid items-start gap-6 xl:grid-cols-2">
{#each profileScopes as p (p.scope)} {#each profileScopes as p (p.scope)}
<div class="space-y-3 rounded border border-border bg-surface p-4"> {@render knobs(p.scope, p.label, p.blurb, weightFields, () => '0.1', (k) => snap.shipped.profiles[p.scope][k as keyof WeightProfile])}
<div>
<h2 class="text-lg font-semibold">{p.label}</h2>
<p class="text-xs text-text-secondary">{p.blurb}</p>
</div>
<div class="space-y-2">
{#each weightFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="{p.scope}-{f.key}" title={f.hint}>
{f.label}
{#if deviates(p.scope, f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.profiles[p.scope][f.key]})"
></span>
{/if}
</label>
<input
id="{p.scope}-{f.key}"
type="number"
step="0.1"
bind:value={form[p.scope][f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-hidden focus:border-accent"
/>
</div>
{/each} {/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]
)}
</div> </div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save(p.scope)}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save {p.label.toLowerCase()}
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset(p.scope)}
aria-label="Reset {p.label} to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</div>
{/each}
</section>
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
<div>
<h2 class="text-lg font-semibold">Taste profile build</h2>
<p class="text-xs text-text-secondary">
How plays become the learned taste profile: the influence half-life and the
completion→engagement curve (must stay ordered: hard-skip &lt; neutral &lt; full).
</p>
</div>
<div class="space-y-2">
{#each tasteFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="taste-{f.key}" title={f.hint}>
{f.label}
{#if deviates('taste', f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.taste[f.key]})"
></span>
{/if}
</label>
<input
id="taste-{f.key}"
type="number"
step={f.key === 'half_life_days' ? '1' : '0.05'}
bind:value={form.taste[f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-hidden focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save('taste')}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save taste
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset('taste')}
aria-label="Reset taste profile build to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</section>
<!-- Discover request surface (#2377). Its own scope, not part of taste:
the snooze length lives here, and a snooze deliberately carries no
taste signal (#2374). -->
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
<div>
<h2 class="text-lg font-semibold">Discover requests</h2>
<p class="text-xs text-text-secondary">
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.
</p>
</div>
<div class="space-y-2">
{#each discoverFields as f (f.key)}
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
<label class="text-sm" for="discover-{f.key}" title={f.hint}>
{f.label}
{#if deviates('discover', f.key)}
<span
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
title="Deviates from the shipped default ({snapshot.shipped.discover[f.key]})"
></span>
{/if}
</label>
<input
id="discover-{f.key}"
type="number"
step={f.key === 'snooze_days' ? '1' : '0.1'}
min="0"
bind:value={form.discover[f.key]}
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-hidden focus:border-accent"
/>
</div>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
disabled={saving !== null}
onclick={() => save('discover')}
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
>
Save discover
</button>
<button
type="button"
disabled={saving !== null}
onclick={() => reset('discover')}
aria-label="Reset discover requests to defaults"
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
>
Reset to defaults
</button>
</div>
</section>
{/if} {/if}
<!-- Weekly trends (#1251): the verify half. Sparklines share one <!-- Weekly trends (#1251): the verify half. Sparklines share one week
week axis; dashed ticks mark knob turns so cause→effect reads axis and a fixed 0–100% skip-rate axis; dashed ticks mark knob turns,
off the chart. --> so cause and effect read off the chart. -->
<section class="space-y-3 rounded border border-border bg-surface p-4"> <SettingsGroup
<div> title="Weekly trends"
<h2 class="text-lg font-semibold">Weekly trends</h2> hint="Skip rate per surface, all listeners. Last-week columns carry that week's play count."
<p class="text-xs text-text-secondary"> >
Skip rate per surface over the last {trends?.weeks ?? 12} weeks (lower is better; all <div class="overflow-x-auto px-4 py-3">
users aggregated, rates only). Dashed ticks mark tuning changes. Taste hit is the share
of plays whose artist fits the current taste profile.
<span class="font-medium">The skip and completion columns show the most recent week
alone</span>, 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.
</p>
</div>
{#if trendsFailed} {#if trendsFailed}
<p class="text-sm text-text-secondary">Couldn't load trends.</p> <p class="text-sm text-error-fg" data-testid="trends-error">
Couldn't load trends.
<button type="button" class="underline hover:no-underline" onclick={loadTrends}>Try again</button>
</p>
{:else if !trends} {:else if !trends}
<p class="text-sm text-text-secondary">Loading…</p> <p class="text-sm text-text-secondary">Loading…</p>
{:else if trends.series.length === 0} {:else if trends.series.length === 0}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">No plays recorded yet; trends appear once listening accumulates.</p>
No plays recorded yet — trends appear once listening accumulates.
</p>
{:else} {:else}
{@const t = trends}
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead> <thead>
<tr class="text-left text-text-secondary"> <tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</th> <th class="py-1 font-medium">Surface</th>
<th class="py-1 font-medium">Skip rate by week</th> <th class="py-1 font-medium">Skip rate, last {t.weeks} weeks</th>
<th class="py-1 text-right font-medium">Plays<span class="font-normal text-xs"> (window)</span></th> <th class="py-1 text-right font-medium">Plays<span class="font-normal text-xs"> (window)</span></th>
<th class="py-1 text-right font-medium">Skip<span class="font-normal text-xs"> (last wk)</span></th> <th class="py-1 text-right font-medium">Skip<span class="font-normal text-xs"> (last wk)</span></th>
<th class="py-1 text-right font-medium">Completion<span class="font-normal text-xs"> (last wk)</span></th> <th class="py-1 text-right font-medium">Completion<span class="font-normal text-xs"> (last wk)</span></th>
@@ -465,7 +370,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each trends.series as s (s.key)} {#each t.series as s (s.key)}
<tr <tr
class="border-t border-border" class="border-t border-border"
class:opacity-60={s.plays < TREND_LOW_VOLUME} class:opacity-60={s.plays < TREND_LOW_VOLUME}
@@ -476,16 +381,19 @@
<td class="py-1.5 pr-2">{s.label}</td> <td class="py-1.5 pr-2">{s.label}</td>
<td class="py-1.5"> <td class="py-1.5">
<svg <svg
width={SPARK_W} width={SPARK_W + GUTTER}
height={SPARK_H} height={SPARK_H}
viewBox="0 0 {SPARK_W} {SPARK_H}" viewBox="0 0 {SPARK_W + GUTTER} {SPARK_H}"
role="img" role="img"
aria-label="{s.label} weekly skip rate" aria-label="{s.label} weekly skip rate, 0 to 100%"
data-testid="sparkline-{s.key}" data-testid="sparkline-{s.key}"
> >
<line x1="0" y1={SPARK_H - 0.5} x2={SPARK_W} y2={SPARK_H - 0.5} <text x={GUTTER - 4} y="7" text-anchor="end" font-size="8" fill="currentColor" opacity="0.6">100%</text>
stroke="currentColor" opacity="0.15" /> <text x={GUTTER - 4} y={SPARK_H - 1} text-anchor="end" font-size="8" fill="currentColor" opacity="0.6">0%</text>
{#each trends.markers as m, i (i)} <g transform="translate({GUTTER},0)">
<line x1="0" y1={SPARK_H - 0.5} x2={SPARK_W} y2={SPARK_H - 0.5} stroke="currentColor" opacity="0.15" />
<line x1="0" y1="0.5" x2={SPARK_W} y2="0.5" stroke="currentColor" opacity="0.08" />
{#each t.markers as m, i (i)}
<line <line
x1={markerX(m)} y1="0" x2={markerX(m)} y2={SPARK_H} x1={markerX(m)} y1="0" x2={markerX(m)} y2={SPARK_H}
stroke="currentColor" opacity="0.35" stroke-dasharray="2,2" stroke="currentColor" opacity="0.35" stroke-dasharray="2,2"
@@ -494,21 +402,17 @@
</line> </line>
{/each} {/each}
{#if s.points.length > 1} {#if s.points.length > 1}
<polyline <polyline class="text-accent-fg" points={sparkPoints(s)} fill="none" stroke="currentColor" stroke-width="1.5" />
class="text-accent-fg"
points={sparkPoints(s)}
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
{:else if s.points.length === 1} {:else if s.points.length === 1}
<circle <circle
class="text-accent-fg" class="text-accent-fg"
cx={xFor(s.points[0].week_start)} cx={xFor(s.points[0].week_start)}
cy={SPARK_H - s.points[0].skip_rate * SPARK_H} cy={SPARK_H - s.points[0].skip_rate * SPARK_H}
r="2" fill="currentColor" r="2"
fill="currentColor"
/> />
{/if} {/if}
</g>
</svg> </svg>
</td> </td>
<td class="py-1.5 text-right tabular-nums">{s.plays}</td> <td class="py-1.5 text-right tabular-nums">{s.plays}</td>
@@ -522,16 +426,17 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
{#if trends.markers.length > 0} {#if t.markers.length > 0}
<div class="space-y-1"> <div class="mt-3 space-y-1">
<h3 class="text-sm font-medium">Tuning changes in this window</h3> <h3 class="text-sm font-medium">Tuning changes in this window</h3>
<ul class="space-y-0.5 text-xs text-text-secondary"> <ul class="space-y-0.5 text-xs text-text-secondary">
{#each trends.markers as m, i (i)} {#each t.markers as m, i (i)}
<li>{markerSummary(m)}</li> <li>{markerSummary(m)}</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{/if} {/if}
{/if} {/if}
</section> </div>
</SettingsGroup>
</div> </div>
+54 -15
View File
@@ -10,17 +10,18 @@
deleteUser, deleteUser,
resetUserPassword, resetUserPassword,
updateUserAutoApprove, updateUserAutoApprove,
updateUserDebugMode,
createAdminUsersQuery, createAdminUsersQuery,
createAdminInvitesQuery, createAdminInvitesQuery,
type AdminUser, type AdminUser,
type AdminInvite, type AdminInvite,
type CreateUserInput type CreateUserInput
} from '#lib/api/admin.js'; } 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 { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from '#lib/components/Modal.svelte'; import Modal from '#lib/components/Modal.svelte';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.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(); 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() { async function onGenerateInvite() {
saving = true; saving = true;
try { try {
@@ -205,18 +221,22 @@
<svelte:head><title>{pageTitle('Admin · Users')}</title></svelte:head> <svelte:head><title>{pageTitle('Admin · Users')}</title></svelte:head>
<h2 class="sr-only">Users</h2>
<div class="space-y-8"> <div class="space-y-8">
<header class="space-y-1"> {#if usersQuery.data}
<h2 class="font-display text-2xl font-medium text-text-primary">Users</h2> {@const admins = users.filter((u) => u.is_admin).length}
<p class="text-text-secondary"> <p class="text-sm text-text-secondary" data-testid="users-summary">
Manage user accounts and invites for this Minstrel instance. {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`}`
: ''}.
</p> </p>
</header> {/if}
<!-- Accounts section --> <!-- Accounts section -->
<section class="space-y-3"> <section class="space-y-3">
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Accounts</h3> <h3 class="text-base font-medium text-text-primary">Accounts</h3>
<button <button
type="button" type="button"
disabled={saving} disabled={saving}
@@ -229,7 +249,10 @@
{#if usersQuery.isPending} {#if usersQuery.isPending}
<p class="text-text-secondary">Reading users…</p> <p class="text-text-secondary">Reading users…</p>
{:else if usersQuery.isError} {:else if usersQuery.isError}
<p class="text-error-fg">Couldn't load users.</p> <p class="text-sm text-error-fg" data-testid="users-error">
Couldn't load the accounts.
<button type="button" class="underline hover:no-underline" onclick={() => usersQuery.refetch()}>Try again</button>
</p>
{:else if users.length === 0} {:else if users.length === 0}
<p class="text-text-secondary">No users yet.</p> <p class="text-text-secondary">No users yet.</p>
{:else} {:else}
@@ -252,6 +275,15 @@
onclick: () => onToggleAutoApprove(u), onclick: () => onToggleAutoApprove(u),
disabled: saving 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, icon: KeyRound,
label: 'Reset password', label: 'Reset password',
@@ -278,6 +310,13 @@
data-testid="admin-badge" data-testid="admin-badge"
>admin</span> >admin</span>
{/if} {/if}
{#if u.debug_mode_enabled}
<a
href="/admin/diagnostics?account={u.id}"
class="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary hover:text-text-primary"
data-testid="debug-badge"
>debug on</a>
{/if}
{#if u.auto_approve_requests} {#if u.auto_approve_requests}
<span <span
class="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary" class="inline-flex items-center rounded-full border border-border px-2 py-0.5 text-xs text-text-secondary"
@@ -301,7 +340,7 @@
<!-- Invites section --> <!-- Invites section -->
<section class="space-y-3"> <section class="space-y-3">
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<h3 class="font-display text-xl font-medium text-text-primary">Invites</h3> <h3 class="text-base font-medium text-text-primary">Invites</h3>
<button <button
type="button" type="button"
disabled={saving} disabled={saving}
@@ -311,14 +350,14 @@
Generate invite Generate invite
</button> </button>
</div> </div>
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">Each works once within 24 hours, entered on the sign-up page.</p>
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.
</p>
{#if invitesQuery.isPending} {#if invitesQuery.isPending}
<p class="text-text-secondary">Reading invites…</p> <p class="text-text-secondary">Reading invites…</p>
{:else if invitesQuery.isError} {:else if invitesQuery.isError}
<p class="text-error-fg">Couldn't load invites.</p> <p class="text-sm text-error-fg" data-testid="invites-error">
Couldn't load the invites.
<button type="button" class="underline hover:no-underline" onclick={() => invitesQuery.refetch()}>Try again</button>
</p>
{:else if invites.length === 0} {:else if invites.length === 0}
<p class="text-sm text-text-secondary">No active invites.</p> <p class="text-sm text-text-secondary">No active invites.</p>
{:else} {:else}
+30 -2
View File
@@ -16,7 +16,8 @@ vi.mock('#lib/api/admin.js', () => ({
createUser: vi.fn(), createUser: vi.fn(),
deleteUser: vi.fn(), deleteUser: vi.fn(),
resetUserPassword: vi.fn(), resetUserPassword: vi.fn(),
updateUserAutoApprove: vi.fn() updateUserAutoApprove: vi.fn(),
updateUserDebugMode: vi.fn()
})); }));
import AdminUsersPage from './+page.svelte'; import AdminUsersPage from './+page.svelte';
@@ -29,7 +30,8 @@ import {
createUser, createUser,
deleteUser, deleteUser,
resetUserPassword, resetUserPassword,
updateUserAutoApprove updateUserAutoApprove,
updateUserDebugMode
} from '#lib/api/admin.js'; } from '#lib/api/admin.js';
const alice: AdminUser = { const alice: AdminUser = {
@@ -207,4 +209,30 @@ describe('/admin/users', () => {
await fireEvent.click(screen.getAllByRole('button', { name: /Enable auto-approve/i })[0]); await fireEvent.click(screen.getAllByRole('button', { name: /Enable auto-approve/i })[0]);
expect(updateUserAutoApprove).toHaveBeenCalledWith(expect.any(String), true); 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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(
mockQuery({ isError: true, error: new Error('x'), refetch })
);
(createAdminInvitesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
render(AdminUsersPage);
await fireEvent.click(screen.getByRole('button', { name: /try again/i }));
expect(refetch).toHaveBeenCalled();
});
}); });