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
+51 -45
View File
@@ -1,5 +1,7 @@
<script lang="ts">
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 { useQueryClient } from '@tanstack/svelte-query';
import {
@@ -49,8 +51,9 @@
// start/end window is an Advanced affordance.
const DEFAULT_LIMIT = 500;
// Filter state.
let accountId = $state('');
// Filter state. Users links here with ?account=<id> to open on that
// account's events (#5608).
let accountId = $state(page.url.searchParams.get('account') ?? '');
let clientId = $state('');
let kind = $state('');
let fromLocal = $state('');
@@ -108,6 +111,12 @@
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 {
const s = JSON.stringify(p);
return s.length > 120 ? s.slice(0, 120) + '…' : s;
@@ -122,10 +131,10 @@
await updateUserDebugMode(selectedUser.id, !selectedUser.debug_mode_enabled);
await client.invalidateQueries({ queryKey: qk.adminUsers() });
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) {
pushToast(`Toggle failed: ${errMessage(e)}`, 'error');
pushToast(`Debug mode not changed: ${errMessage(e)}`, 'error');
} finally {
toggling = false;
}
@@ -176,48 +185,42 @@
<svelte:head><title>{pageTitle('Admin · Diagnostics')}</title></svelte:head>
<h2 class="sr-only">Diagnostics</h2>
<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">
Enable debug mode on an account to have its device(s) stream a timeseries
of connectivity, UPnP-sync, and power/Doze events here. Filter to the
window you care about, then copy or download the slice for analysis.
</p>
</header>
<p class="text-sm text-text-secondary">
Turn on debug mode for an account and its devices report connectivity, sync and power events
here, to copy or download.
</p>
<!-- Account debug-mode control -->
<section class="rounded-md border border-border p-3">
<div class="flex flex-wrap items-end gap-3">
<label class="block text-xs text-text-secondary">
Account
<select
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"
>
<option value="">All accounts</option>
{#each users as u (u.id)}
<option value={u.id}>{u.username}{u.debug_mode_enabled ? ' · debug on' : ''}</option>
{/each}
</select>
</label>
{#if selectedUser}
<button
type="button"
onclick={toggleDebug}
disabled={toggling}
class="rounded px-3 py-2 text-sm text-action-fg hover:opacity-90 disabled:opacity-50
{selectedUser.debug_mode_enabled ? 'bg-action-destructive' : 'bg-action-primary'}"
>
{selectedUser.debug_mode_enabled ? 'Disable debug mode' : 'Enable debug mode'}
</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}
</div>
<section class="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-surface px-4 py-3">
<label class="block text-xs text-text-secondary">
Account
<select
bind:value={accountId}
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>
{#each users as u (u.id)}
<option value={u.id}>{u.username}{u.debug_mode_enabled ? ' · debug on' : ''}</option>
{/each}
</select>
</label>
{#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
type="button"
onclick={toggleDebug}
disabled={toggling}
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 ? 'Turn off' : 'Turn on'}
</button>
{/if}
</section>
<!-- Filters. Default view = most recent {DEFAULT_LIMIT} events, no time
@@ -232,7 +235,7 @@
>
<option value="">All devices</option>
{#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}
</select>
</label>
@@ -325,7 +328,10 @@
<!-- Timeline -->
{#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}
<p class="text-text-secondary">Loading…</p>
{:else if rows.length === 0}
+8 -2
View File
@@ -246,7 +246,10 @@
{#if resolved.isPending}
<p class="text-text-secondary">Loading…</p>
{: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}
<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" />
@@ -296,7 +299,10 @@
{:else if query.isPending}
<p class="text-text-secondary">Loading duplicates…</p>
{: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}
<!-- Several states all show zero groups and mean different things. Telling
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
? coverage.total === 0
? '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}>
<button type="button" onclick={onRefetchCovers} disabled={refetching} class={button}>
{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 () => {
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 waitFor(() => expect(refetchMissingCovers).toHaveBeenCalled());
await fireEvent.click(screen.getByRole('button', { name: /^look again$/i }));
@@ -87,7 +87,10 @@
{#if query.isPending}
<p class="text-text-secondary">Checking what's missing…</p>
{: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}
<!-- 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
@@ -123,7 +123,7 @@ describe('admin missing files', () => {
test('shows an error line when the query fails', () => {
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;
+4 -1
View File
@@ -164,7 +164,10 @@
{#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p>
{: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}
<p class="text-text-secondary">Nothing here.</p>
{:else}
@@ -110,7 +110,10 @@
{#if query.isPending}
<p class="text-text-secondary">Reading file names…</p>
{: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}
<div class="rounded-lg border border-border bg-surface p-6 text-center">
<FileCheck size={28} strokeWidth={1} class="mx-auto text-text-muted" />
+201 -296
View File
@@ -17,6 +17,8 @@
} from '#lib/api/tuning.js';
import { errMessage } from '#lib/api/errors.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
// profiles + taste-build knobs with live effect. This card exists to
@@ -26,38 +28,37 @@
const weightFields: { key: keyof WeightProfile; label: string; hint: string }[] = [
{ 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: 'recency_weight', label: 'Recency weight', hint: 'Rewards tracks not played recently (0–30d ramp).' },
{ key: 'skip_penalty', label: 'Skip penalty', hint: 'Subtracts skips/plays ratio.' },
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' },
{ key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' },
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' },
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' },
{ 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: 'recency_weight', label: 'Recency weight', hint: 'Rewards tracks not played in the last 30 days.' },
{ key: 'skip_penalty', label: 'Skip penalty', hint: 'Subtracts the share of plays skipped.' },
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random shuffle among near-ties.' },
{ key: 'context_weight', label: 'Context weight', hint: 'Fit with the current listening session.' },
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Likeness to the seed.' },
{ key: 'taste_weight', label: 'Taste weight', hint: 'Fit with the learned taste profile.' },
{ 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 }[] = [
{ 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_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' },
{ 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: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' },
{ 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: '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: 'Played this little or less reads as a dislike.' },
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Played this far reads as neither.' },
{ 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: 'Style and mood tags against the file genre. 0 is genre only.' },
{ key: 'era_scale', label: 'Era weight', hint: 'How much the decade counts. 0 ignores it.' },
{ key: 'mood_scale', label: 'Mood weight', hint: 'How much the mood counts. 0 ignores it.' }
];
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: '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: '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" hides a suggestion.' }
];
const profileScopes: { scope: WeightProfileScope; label: string; blurb: string }[] = [
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, the discovery mixes, and "You might like".' },
{ 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: 'songs_like',
label: 'Songs like…',
blurb:
'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.'
blurb: 'Everything should sound like the seed; likeness leads on purpose.'
}
];
@@ -83,7 +84,8 @@
form = f;
}
$effect(() => {
function loadTuning() {
loadFailed = false;
getTuning()
.then((snap) => {
fillForm(snap);
@@ -92,7 +94,8 @@
.catch(() => {
loadFailed = true;
});
});
}
$effect(loadTuning);
// A knob deviates when its CURRENT SAVED value differs from shipped;
// the dot marks where this install has drifted from defaults.
@@ -166,7 +169,8 @@
let trends = $state<TrendsResponse | null>(null);
let trendsFailed = $state(false);
$effect(() => {
function loadTrends() {
trendsFailed = false;
getTrends()
.then((t) => {
trends = t;
@@ -174,10 +178,13 @@
.catch(() => {
trendsFailed = true;
});
});
}
$effect(loadTrends);
const SPARK_W = 220;
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;
// The week axis is the sorted union of every series' buckets, so all
@@ -237,6 +244,10 @@
: { 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 {
return `${(v * 100).toFixed(0)}%`;
}
@@ -249,289 +260,183 @@
}
</script>
<svelte:head>
<title>{pageTitle('Tuning')}</title>
</svelte:head>
<svelte:head><title>{pageTitle('Admin · Tuning')}</title></svelte:head>
<div class="space-y-6 p-4">
<div>
<h1 class="text-xl font-semibold">Recommendation tuning</h1>
<p class="mt-1 max-w-3xl text-sm text-text-secondary">
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.
</p>
</div>
<h2 class="sr-only">Tuning</h2>
{#snippet knobs(scope: TuningScope, title: string, hint: string, fields: { key: string; label: string; hint: string }[], step: (key: string) => string, shipped: (key: string) => number)}
<SettingsGroup {title} {hint}>
{#each fields as f (f.key)}
<SettingRow label={f.label} for="{scope}-{f.key}" hint={f.hint}>
{#if deviates(scope, f.key)}
<span
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>
</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}
<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}
<p class="text-sm text-text-secondary">Loading…</p>
{: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)}
<div class="space-y-3 rounded border border-border bg-surface p-4">
<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}
</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>
{@render knobs(p.scope, p.label, p.blurb, weightFields, () => '0.1', (k) => snap.shipped.profiles[p.scope][k as keyof WeightProfile])}
{/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>
{@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>
{/if}
<!-- Weekly trends (#1251): the verify half. Sparklines share one
week axis; dashed ticks mark knob turns so cause→effect reads
off the chart. -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<div>
<h2 class="text-lg font-semibold">Weekly trends</h2>
<p class="text-xs text-text-secondary">
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.
<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}
<p class="text-sm text-text-secondary">Couldn't load trends.</p>
{:else if !trends}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if trends.series.length === 0}
<p class="text-sm text-text-secondary">
No plays recorded yet — trends appear once listening accumulates.
</p>
{:else}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</th>
<th class="py-1 font-medium">Skip rate by week</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">Completion<span class="font-normal text-xs"> (last wk)</span></th>
<th class="py-1 text-right font-medium">Taste hit<span class="font-normal text-xs"> (window)</span></th>
</tr>
</thead>
<tbody>
{#each trends.series as s (s.key)}
<tr
class="border-t border-border"
class:opacity-60={s.plays < TREND_LOW_VOLUME}
title={s.plays < TREND_LOW_VOLUME
? 'Fewer than 20 plays in the window — treat as anecdote, not signal.'
: undefined}
>
<td class="py-1.5 pr-2">{s.label}</td>
<td class="py-1.5">
<svg
width={SPARK_W}
height={SPARK_H}
viewBox="0 0 {SPARK_W} {SPARK_H}"
role="img"
aria-label="{s.label} weekly skip rate"
data-testid="sparkline-{s.key}"
>
<line x1="0" y1={SPARK_H - 0.5} x2={SPARK_W} y2={SPARK_H - 0.5}
stroke="currentColor" opacity="0.15" />
{#each trends.markers as m, i (i)}
<line
x1={markerX(m)} y1="0" x2={markerX(m)} y2={SPARK_H}
stroke="currentColor" opacity="0.35" stroke-dasharray="2,2"
>
<title>{markerSummary(m)}</title>
</line>
{/each}
{#if s.points.length > 1}
<polyline
class="text-accent-fg"
points={sparkPoints(s)}
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
{:else if s.points.length === 1}
<circle
class="text-accent-fg"
cx={xFor(s.points[0].week_start)}
cy={SPARK_H - s.points[0].skip_rate * SPARK_H}
r="2" fill="currentColor"
/>
{/if}
</svg>
</td>
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
<td class="py-1.5 text-right tabular-nums">
{pct(latest(s).skip)}
<span class="text-xs text-text-secondary">/{latest(s).plays}</span>
</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).completion)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(windowTasteHitRate(s))}</td>
<!-- Weekly trends (#1251): the verify half. Sparklines share one week
axis and a fixed 0–100% skip-rate axis; dashed ticks mark knob turns,
so cause and effect read off the chart. -->
<SettingsGroup
title="Weekly trends"
hint="Skip rate per surface, all listeners. Last-week columns carry that week's play count."
>
<div class="overflow-x-auto px-4 py-3">
{#if trendsFailed}
<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}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if trends.series.length === 0}
<p class="text-sm text-text-secondary">No plays recorded yet; trends appear once listening accumulates.</p>
{:else}
{@const t = trends}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</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">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">Taste hit<span class="font-normal text-xs"> (window)</span></th>
</tr>
{/each}
</tbody>
</table>
{#if trends.markers.length > 0}
<div class="space-y-1">
<h3 class="text-sm font-medium">Tuning changes in this window</h3>
<ul class="space-y-0.5 text-xs text-text-secondary">
{#each trends.markers as m, i (i)}
<li>{markerSummary(m)}</li>
</thead>
<tbody>
{#each t.series as s (s.key)}
<tr
class="border-t border-border"
class:opacity-60={s.plays < TREND_LOW_VOLUME}
title={s.plays < TREND_LOW_VOLUME
? 'Fewer than 20 plays in the window — treat as anecdote, not signal.'
: undefined}
>
<td class="py-1.5 pr-2">{s.label}</td>
<td class="py-1.5">
<svg
width={SPARK_W + GUTTER}
height={SPARK_H}
viewBox="0 0 {SPARK_W + GUTTER} {SPARK_H}"
role="img"
aria-label="{s.label} weekly skip rate, 0 to 100%"
data-testid="sparkline-{s.key}"
>
<text x={GUTTER - 4} y="7" text-anchor="end" font-size="8" fill="currentColor" opacity="0.6">100%</text>
<text x={GUTTER - 4} y={SPARK_H - 1} text-anchor="end" font-size="8" fill="currentColor" opacity="0.6">0%</text>
<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
x1={markerX(m)} y1="0" x2={markerX(m)} y2={SPARK_H}
stroke="currentColor" opacity="0.35" stroke-dasharray="2,2"
>
<title>{markerSummary(m)}</title>
</line>
{/each}
{#if s.points.length > 1}
<polyline class="text-accent-fg" points={sparkPoints(s)} fill="none" stroke="currentColor" stroke-width="1.5" />
{:else if s.points.length === 1}
<circle
class="text-accent-fg"
cx={xFor(s.points[0].week_start)}
cy={SPARK_H - s.points[0].skip_rate * SPARK_H}
r="2"
fill="currentColor"
/>
{/if}
</g>
</svg>
</td>
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
<td class="py-1.5 text-right tabular-nums">
{pct(latest(s).skip)}
<span class="text-xs text-text-secondary">/{latest(s).plays}</span>
</td>
<td class="py-1.5 text-right tabular-nums">{pct(latest(s).completion)}</td>
<td class="py-1.5 text-right tabular-nums">{pct(windowTasteHitRate(s))}</td>
</tr>
{/each}
</ul>
</div>
</tbody>
</table>
{#if t.markers.length > 0}
<div class="mt-3 space-y-1">
<h3 class="text-sm font-medium">Tuning changes in this window</h3>
<ul class="space-y-0.5 text-xs text-text-secondary">
{#each t.markers as m, i (i)}
<li>{markerSummary(m)}</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</section>
</div>
</SettingsGroup>
</div>
+54 -15
View File
@@ -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 @@
<svelte:head><title>{pageTitle('Admin · Users')}</title></svelte:head>
<h2 class="sr-only">Users</h2>
<div class="space-y-8">
<header class="space-y-1">
<h2 class="font-display text-2xl font-medium text-text-primary">Users</h2>
<p class="text-text-secondary">
Manage user accounts and invites for this Minstrel instance.
{#if usersQuery.data}
{@const admins = users.filter((u) => u.is_admin).length}
<p class="text-sm text-text-secondary" data-testid="users-summary">
{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>
</header>
{/if}
<!-- Accounts section -->
<section class="space-y-3">
<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
type="button"
disabled={saving}
@@ -229,7 +249,10 @@
{#if usersQuery.isPending}
<p class="text-text-secondary">Reading users…</p>
{: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}
<p class="text-text-secondary">No users yet.</p>
{: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</span>
{/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}
<span
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 -->
<section class="space-y-3">
<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
type="button"
disabled={saving}
@@ -311,14 +350,14 @@
Generate invite
</button>
</div>
<p class="text-sm text-text-secondary">
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>
<p class="text-sm text-text-secondary">Each works once within 24 hours, entered on the sign-up page.</p>
{#if invitesQuery.isPending}
<p class="text-text-secondary">Reading invites…</p>
{: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}
<p class="text-sm text-text-secondary">No active invites.</p>
{:else}
+30 -2
View File
@@ -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<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();
});
});