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">
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,24 +185,21 @@
<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.
Turn on debug mode for an account and its devices report connectivity, sync and power events
here, to copy or download.
</p>
</header>
<!-- Account debug-mode control -->
<section class="rounded-md border border-border p-3">
<div class="flex flex-wrap items-end gap-3">
<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 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>
{#each users as u (u.id)}
@@ -201,23 +207,20 @@
{/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 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'}"
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 ? 'Disable debug mode' : 'Enable debug mode'}
{selectedUser.debug_mode_enabled ? 'Turn off' : 'Turn on'}
</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>
<!-- 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" />
+136 -231
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,215 +260,109 @@
}
</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>
<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>
{@render knobs(p.scope, p.label, p.blurb, weightFields, () => '0.1', (k) => snap.shipped.profiles[p.scope][k as keyof WeightProfile])}
{/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 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}
<!-- 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>
<!-- 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-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}
<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>
<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 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">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>
@@ -465,7 +370,7 @@
</tr>
</thead>
<tbody>
{#each trends.series as s (s.key)}
{#each t.series as s (s.key)}
<tr
class="border-t border-border"
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">
<svg
width={SPARK_W}
width={SPARK_W + GUTTER}
height={SPARK_H}
viewBox="0 0 {SPARK_W} {SPARK_H}"
viewBox="0 0 {SPARK_W + GUTTER} {SPARK_H}"
role="img"
aria-label="{s.label} weekly skip rate"
aria-label="{s.label} weekly skip rate, 0 to 100%"
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)}
<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"
@@ -494,21 +402,17 @@
</line>
{/each}
{#if s.points.length > 1}
<polyline
class="text-accent-fg"
points={sparkPoints(s)}
fill="none"
stroke="currentColor"
stroke-width="1.5"
/>
<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"
r="2"
fill="currentColor"
/>
{/if}
</g>
</svg>
</td>
<td class="py-1.5 text-right tabular-nums">{s.plays}</td>
@@ -522,16 +426,17 @@
{/each}
</tbody>
</table>
{#if trends.markers.length > 0}
<div class="space-y-1">
{#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 trends.markers as m, i (i)}
{#each t.markers as m, i (i)}
<li>{markerSummary(m)}</li>
{/each}
</ul>
</div>
{/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();
});
});