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
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:
@@ -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}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 < neutral < 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user