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