feat(jobs): surface background jobs with progress, logs and controls

Add a central job registry that library scans and search index builds
report into, so background work is visible instead of buried in the
settings page.

- backend/jobs: registry with per-job ring-buffer logs, capability-driven
  controls, and one coalesced JobsChanged snapshot at 4Hz
- pause survives restart via a job_state table; a paused scan is adopted
  back on launch and skipped by the soft scan
- top-bar indicator, popover, details drawer and a Jobs page replacing
  the config page's scan UI; per-library start/stop retained
- scan timing breakdown moves into the job log, Full rescan to the Jobs
  page; delete the orphaned library-manager component

Also add cmd/indexbuild and cmd/indexexport so the explore index can be
built once centrally rather than by every install, which today streams
~205GB from the ListenBrainz spark dump on first run. indexbuild picks
build/refresh/rebuild from index state; the Gitea workflow runs it on
push, weekly, or manually and publishes only when content changed.

fresh-install no longer defaults YJ_HOME under /tmp: it is tmpfs on most
distros, and the import needs ~6GB of real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:42:22 -04:00
co-authored by Claude Opus 5
parent aead8eaef4
commit 01bc5f2094
48 changed files with 6656 additions and 2271 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,272 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import {
GetLibraryDirectory,
SetLibraryDirectory,
} from '@go/config/Config';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
/**
* First-run setup wizard.
*
* On startup it checks whether a library directory has already been
* configured. If one exists the wizard stays hidden and the app
* proceeds as normal. If none is set (fresh install), it presents a
* non-dismissable modal prompting the user to pick their music folder,
* saves it to the config, and dismisses itself. Saving the directory
* emits LibraryConfigChanged on the backend, which kicks off the
* initial scan automatically.
*/
@customElement('first-run-wizard')
export class FirstRunWizard extends LitElement {
@query('wa-dialog')
private dialog!: HTMLElement & { open: boolean };
/** Whether the wizard should be shown at all (no library configured). */
@state() private active = false;
/** Directory chosen in the picker, not yet saved. */
@state() private selectedDirectory = '';
/** True while SetLibraryDirectory is in flight. */
@state() private saving = false;
/** Error message from a failed pick/save, if any. */
@state() private errorMessage = '';
override async connectedCallback(): Promise<void> {
super.connectedCallback();
try {
const existing = await GetLibraryDirectory();
// A configured directory means setup is already complete.
if (existing) return;
} catch (err) {
console.error(
'First-run wizard: failed to read library directory:',
err,
);
return;
}
this.active = true;
await this.updateComplete;
if (this.dialog) this.dialog.open = true;
}
static override styles = css`
wa-dialog {
--width: 480px;
}
wa-dialog::part(dialog) {
background: var(--yj-bg-surface, #212529);
color: var(--yj-text-primary, #fff);
border: 1px solid var(--yj-border, #444);
border-radius: 8px;
}
wa-dialog::part(body) {
padding: 24px;
}
.welcome {
text-align: center;
margin-bottom: 20px;
}
.welcome wa-icon {
font-size: 40px;
color: var(--yj-accent, #f5c518);
margin-bottom: 8px;
}
.welcome h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 600;
}
.welcome p {
margin: 0;
font-size: 13px;
color: var(--yj-text-secondary, #b3b3b3);
}
.chosen {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 16px;
font-size: 13px;
background: var(--yj-bg-elevated, #2a2f34);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
word-break: break-all;
}
.chosen wa-icon {
color: var(--yj-accent, #f5c518);
flex-shrink: 0;
}
.placeholder {
color: var(--yj-text-tertiary, #888);
}
.error {
font-size: 13px;
color: var(--yj-danger, #e5484d);
margin-bottom: 16px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
background: transparent;
color: var(--yj-text-primary, #fff);
cursor: pointer;
}
.btn:hover:not(:disabled) {
background: var(--yj-bg-elevated, #2a2f34);
}
.btn-primary {
background: var(--yj-accent, #f5c518);
border-color: var(--yj-accent, #f5c518);
color: #000;
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.08);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
override render() {
if (!this.active) return nothing;
return html`
<wa-dialog
label="Welcome to YellowJacket"
without-header
@wa-hide=${this.preventClose}
>
<div class="welcome">
<wa-icon name="music"></wa-icon>
<h2>Welcome to YellowJacket</h2>
<p>
Choose the folder where your music lives to
get started.
</p>
</div>
<div class="chosen">
<wa-icon name="folder"></wa-icon>
${this.selectedDirectory
? html`<span>${this.selectedDirectory}</span>`
: html`<span class="placeholder"
>No folder selected yet</span
>`}
</div>
${this.errorMessage
? html`<div class="error">${this.errorMessage}</div>`
: nothing}
<div class="actions">
<button
class="btn"
?disabled=${this.saving}
@click=${this.handleChoose}
>
${this.selectedDirectory
? 'Change Folder'
: 'Choose Folder'}
</button>
<button
class="btn btn-primary"
?disabled=${!this.selectedDirectory || this.saving}
@click=${this.handleFinish}
>
${this.saving ? 'Saving…' : 'Get Started'}
</button>
</div>
</wa-dialog>
`;
}
/** Set once setup is finished so we allow the dialog to close. */
private finished = false;
/**
* Block every close attempt (Escape, programmatic, backdrop) until
* setup is finished, so the user can't skip picking a folder.
* wa-hide is cancelable via preventDefault().
*/
private preventClose = (e: Event): void => {
if (!this.finished) e.preventDefault();
};
private handleChoose = async (): Promise<void> => {
this.errorMessage = '';
try {
const dir = await DirectoryPicker();
if (dir) this.selectedDirectory = dir;
} catch (err) {
this.errorMessage = `Could not open folder picker: ${err}`;
console.error('First-run wizard: directory picker failed:', err);
}
};
private handleFinish = async (): Promise<void> => {
if (!this.selectedDirectory) return;
this.saving = true;
this.errorMessage = '';
try {
await SetLibraryDirectory(this.selectedDirectory);
this.finished = true;
if (this.dialog) this.dialog.open = false;
this.active = false;
} catch (err) {
this.errorMessage = `Could not save the folder: ${err}`;
console.error('First-run wizard: save failed:', err);
} finally {
this.saving = false;
}
};
}
declare global {
interface HTMLElementTagNameMap {
'first-run-wizard': FirstRunWizard;
}
}
@@ -0,0 +1,54 @@
import { jobStore } from '@store/job-store';
export type JobControlAction = 'pause' | 'resume' | 'cancel' | 'dismiss';
export interface JobControlDetail {
id: string;
action: JobControlAction;
}
/**
* Applies a `job-control` event emitted by a `<job-row>`.
*
* Shared by every host that renders job rows — the top-bar popover, the
* jobs page, and the details drawer — so a control behaves identically
* wherever it is pressed, and so no host can forget to wire one up.
*/
export async function applyJobControl(e: Event): Promise<void> {
const { id, action } = (e as CustomEvent).detail as JobControlDetail;
if (action === 'cancel' && !confirmCancel(id)) return;
switch (action) {
case 'pause':
await jobStore.pause(id);
break;
case 'resume':
await jobStore.resume(id);
break;
case 'cancel':
await jobStore.cancel(id);
break;
case 'dismiss':
await jobStore.dismiss(id);
break;
}
}
/**
* Stopping an index build feels like discarding hours of downloading,
* so it is worth a confirmation even though the checkpoint survives. A
* library scan is cheap to re-run — don't nag for that one.
*/
function confirmCancel(id: string): boolean {
const job = jobStore.getJob(id);
if (job?.kind !== 'index-build') return true;
return window.confirm(
'Stop building the search index?\n\n' +
'Progress is checkpointed, so you can resume later without ' +
're-downloading. Until it finishes, search results stay ' +
'limited to your own library.',
);
}
@@ -0,0 +1,361 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/drawer/drawer.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import './job-row';
import './job-log-view';
import { applyJobControl } from './job-controls';
import {
stateLabel,
stateTone,
formatElapsed,
jobStateStyles,
} from './job-format';
/** How often the open drawer re-fetches the job log. */
const LOG_POLL_MS = 1500;
/**
* Right-hand drawer showing everything known about one job: its
* progress row, stage breakdown, statistics, and log tail.
*
* A drawer rather than a route, because these jobs run *while* the user
* is doing something else — navigating away from their music to read a
* scan log would defeat the purpose.
*/
@customElement('job-details-drawer')
export class JobDetailsDrawer extends LitElement {
/** ID of the job to show. Empty string closes the drawer. */
@property({ type: String, attribute: 'job-id' })
jobId = '';
@property({ type: Boolean, reflect: true })
open = false;
@state()
private job: Job | null = null;
@state()
private logVersion = 0;
private unsubscribe: (() => void) | null = null;
private pollTimer: ReturnType<typeof setInterval> | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
wa-drawer {
--size: 34rem;
}
.content {
display: flex;
flex-direction: column;
gap: 1.1em;
height: 100%;
min-height: 0;
}
.section-title {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin-bottom: 0.5em;
}
.summary {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(7.5rem, 1fr)
);
gap: 0.6em 1em;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 0.15em;
}
.summary-label {
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #868e96);
}
.summary-value {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.summary-value.tone {
color: var(--job-tone);
}
.stages {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.stage {
display: grid;
grid-template-columns: 1.2em 1fr auto;
align-items: center;
gap: 0.6em;
font-size: var(--yj-text-md);
color: var(--yj-text-secondary, #adb5bd);
}
.stage-icon {
font-size: var(--yj-icon-sm);
}
.stage.running {
color: var(--yj-text-primary, #e9ecef);
}
.stage.running .stage-icon {
color: var(--yj-accent, #ffd43b);
}
.stage.complete .stage-icon {
color: #1db954;
}
.stage.error {
color: #ff6b6b;
}
.stage-count {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
font-variant-numeric: tabular-nums;
}
.stage-error {
grid-column: 2 / -1;
font-size: var(--yj-text-sm);
color: #ff6b6b;
overflow-wrap: anywhere;
}
.log-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 12rem;
}
job-log-view {
flex: 1;
min-height: 0;
}
.subtitle {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
overflow-wrap: anywhere;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => this.syncJob());
this.syncJob();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
this.stopPolling();
}
override updated(changed: Map<string, unknown>) {
if (changed.has('jobId') || changed.has('open')) {
this.syncJob();
if (this.open && this.jobId) {
void this.refreshLog();
this.startPolling();
} else {
this.stopPolling();
}
}
}
private syncJob() {
this.job = this.jobId ? (jobStore.getJob(this.jobId) ?? null) : null;
}
/**
* Logs are polled while the drawer is open rather than pushed with
* every snapshot — a scan can emit hundreds of warnings and only
* this panel ever renders them.
*/
private startPolling() {
this.stopPolling();
this.pollTimer = setInterval(() => void this.refreshLog(), LOG_POLL_MS);
}
private stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
private async refreshLog() {
if (!this.jobId) return;
await jobStore.loadLog(this.jobId);
this.logVersion += 1;
}
private onHide = () => {
this.open = false;
this.dispatchEvent(
new CustomEvent('drawer-closed', { bubbles: true, composed: true }),
);
};
private stageIcon(state: string): string {
switch (state) {
case 'complete':
return 'circle-check';
case 'running':
return 'arrows-rotate';
case 'error':
return 'triangle-exclamation';
case 'skipped':
return 'circle-minus';
default:
return 'circle-info';
}
}
private renderStages(job: Job) {
if (!job.stages?.length) return nothing;
return html`
<div>
<div class="section-title">Stages</div>
<div class="stages">
${job.stages.map(
(stage) => html`
<div class="stage ${stage.state}">
<wa-icon
class="stage-icon"
name=${this.stageIcon(stage.state)}
></wa-icon>
<span>${stage.name}</span>
<span class="stage-count">
${stage.total > 0
? `${stage.current.toLocaleString()} / ${stage.total.toLocaleString()}`
: stage.state}
</span>
${stage.error
? html`<div class="stage-error">
${stage.error}
</div>`
: nothing}
</div>
`,
)}
</div>
</div>
`;
}
private renderSummary(job: Job) {
const items = [
{ label: 'Status', value: stateLabel(job), tone: true },
{ label: 'Elapsed', value: formatElapsed(job), tone: false },
...(job.stats ?? []).map((s) => ({
label: s.label,
value: s.value,
tone: false,
})),
];
return html`
<div>
<div class="section-title">Summary</div>
<div class="summary">
${items.map(
(item) => html`
<div class="summary-item">
<span class="summary-label">${item.label}</span>
<span
class="summary-value ${item.tone
? 'tone'
: ''}"
>${item.value}</span
>
</div>
`,
)}
</div>
</div>
`;
}
override render() {
const job = this.job;
return html`
<wa-drawer
?open=${this.open}
label=${job?.title ?? 'Job details'}
@wa-hide=${this.onHide}
class="tone-${job ? stateTone(job) : 'muted'}"
>
${job
? html`
<div class="content">
${job.subtitle
? html`<div class="subtitle">
${job.subtitle}
</div>`
: nothing}
<job-row
.job=${job}
variant="full"
@job-control=${applyJobControl}
></job-row>
${this.renderSummary(job)}
${this.renderStages(job)}
<div class="log-section">
<div class="section-title">Output</div>
<job-log-view
.job=${job}
.entries=${jobStore.cachedLog(job.id)}
data-version=${this.logVersion}
></job-log-view>
</div>
</div>
`
: html`<p>This job is no longer available.</p>`}
</wa-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-details-drawer': JobDetailsDrawer;
}
}
+165
View File
@@ -0,0 +1,165 @@
import { css } from 'lit';
import type { Job, JobLogEntry } from '@store/job-store';
import { isIndeterminate, progressFraction } from '@store/job-store';
/** Icon for a job kind, used in the indicator and job rows. */
export function jobIcon(job: Job): string {
switch (job.kind) {
case 'library-scan':
return 'folder';
case 'index-build':
return 'database';
default:
return 'gear';
}
}
/** Human-readable label for a job state. */
export function stateLabel(job: Job): string {
switch (job.state) {
case 'queued':
return 'Queued';
case 'running':
return 'Running';
case 'pausing':
return 'Pausing…';
case 'paused':
return 'Paused';
case 'cancelling':
return 'Cancelling…';
case 'complete':
return 'Complete';
case 'cancelled':
return 'Stopped';
case 'error':
return 'Failed';
default:
return job.state;
}
}
/**
* Semantic colour name for a state, mapped to CSS custom properties by
* the `jobStateStyles` block below.
*/
export function stateTone(
job: Job,
): 'active' | 'paused' | 'danger' | 'success' | 'muted' {
switch (job.state) {
case 'running':
return 'active';
case 'pausing':
case 'paused':
return 'paused';
case 'error':
return 'danger';
case 'complete':
return 'success';
default:
return 'muted';
}
}
/** Compact "1,204 / 12,880" progress text, or null when indeterminate. */
export function progressText(job: Job): string | null {
if (isIndeterminate(job)) return null;
return `${formatCount(job.current)} / ${formatCount(job.total)}`;
}
/** Progress as a whole-number percentage, or null when indeterminate. */
export function progressPercent(job: Job): number | null {
const fraction = progressFraction(job);
return fraction === null ? null : Math.round(fraction * 100);
}
/**
* The one-line status shown under a job title: phase plus counts, with
* the state folded in when it is something other than plain running.
*/
export function statusLine(job: Job): string {
const parts: string[] = [];
const label = stateLabel(job);
if (job.state !== 'running' && job.state !== 'queued') {
parts.push(label);
}
// A paused job's phase is often just "Paused", which would render
// as "Paused · Paused" alongside the state label.
if (job.phase && job.phase !== label) parts.push(job.phase);
const progress = progressText(job);
if (progress && job.state !== 'complete') parts.push(progress);
if (parts.length === 0) parts.push(stateLabel(job));
return parts.join(' · ');
}
export function formatCount(n: number): string {
return n.toLocaleString();
}
/** Wall-clock duration of a job, as "1m 24s". */
export function formatElapsed(job: Job): string {
const end = job.endedAt && job.endedAt > 0 ? job.endedAt : Date.now();
const seconds = Math.max(0, Math.round((end - job.startedAt) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainder}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
/** Clock time for a log entry, e.g. "14:03:21". */
export function formatLogTime(entry: JobLogEntry): string {
return new Date(entry.time).toLocaleTimeString(undefined, {
hour12: false,
});
}
/** Renders a job log as plain text for the clipboard. */
export function logToText(job: Job, entries: JobLogEntry[]): string {
const header = `${job.title}${stateLabel(job)}`;
const lines = entries.map((entry) => {
const detail = entry.detail ? ` (${entry.detail})` : '';
return `${formatLogTime(entry)} [${entry.level}] ${entry.message}${detail}`;
});
return [header, ...lines].join('\n');
}
/**
* Shared state-tone colour variables. Include in a component's styles
* array so `.tone-active`, `.tone-paused` etc. resolve consistently
* wherever job state is rendered.
*/
export const jobStateStyles = css`
.tone-active {
--job-tone: var(--yj-accent, #ffd43b);
}
.tone-paused {
--job-tone: #f5a623;
}
.tone-danger {
--job-tone: #ff6b6b;
}
.tone-success {
--job-tone: #1db954;
}
.tone-muted {
--job-tone: var(--yj-text-tertiary, #868e96);
}
`;
@@ -0,0 +1,440 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import { designTokens } from '../../styles/tokens.css';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import { isIndeterminate, progressFraction } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import {
jobIcon,
stateTone,
stateLabel,
jobStateStyles,
} from './job-format';
/** Circumference of the progress ring at r=9. */
const RING_CIRCUMFERENCE = 2 * Math.PI * 9;
/**
* Persistent background-job indicator for the top bar.
*
* Hidden entirely when nothing is running, so it costs no attention in
* the common case. When work is in flight it shows a determinate ring
* for a single job, or a count badge for several. Clicking opens a
* popover with inline pause/stop controls; "Details" opens the drawer.
*/
@customElement('job-indicator')
export class JobIndicator extends LitElement {
@state()
private jobs: Job[] = [];
@state()
private popoverOpen = false;
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: inline-flex;
align-items: center;
position: relative;
}
:host([hidden]) {
display: none;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.3em 0.7em 0.3em 0.35em;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-size: var(--yj-text-sm);
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease;
}
.trigger:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.trigger:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
.ring-wrap {
position: relative;
width: 22px;
height: 22px;
flex-shrink: 0;
}
svg {
width: 22px;
height: 22px;
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: rgba(255, 255, 255, 0.14);
stroke-width: 2.5;
}
.ring-value {
fill: none;
stroke: var(--job-tone);
stroke-width: 2.5;
stroke-linecap: round;
transition: stroke-dashoffset 240ms ease;
}
/* Indeterminate work spins the whole ring instead of
* advancing it, so it never implies false precision. */
.ring-wrap.spin svg {
animation: spin 1.1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(270deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ring-wrap.spin svg {
animation-duration: 3s;
}
}
.ring-glyph {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
color: var(--job-tone);
font-variant-numeric: tabular-nums;
}
.label {
white-space: nowrap;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
}
.alert-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ff6b6b;
flex-shrink: 0;
}
.panel {
width: 24rem;
max-width: 92vw;
background: var(--yj-surface, #212529);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
padding: 0.4em;
max-height: 70vh;
overflow-y: auto;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4em 0.6em 0.5em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.panel-header button {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
text-transform: none;
letter-spacing: normal;
padding: 0.15em 0.4em;
border-radius: 5px;
}
.panel-header button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.job-entry {
border-radius: 8px;
}
.job-entry + .job-entry {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.details-link {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
color: var(--yj-accent, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0 0.75em 0.6em 3.2em;
}
.details-link:hover {
text-decoration: underline;
}
.empty {
padding: 0.8em;
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => this.syncJobs());
void jobStore.init();
this.syncJobs();
document.addEventListener('click', this.onDocumentClick);
document.addEventListener('keydown', this.onKeydown);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
document.removeEventListener('click', this.onDocumentClick);
document.removeEventListener('keydown', this.onKeydown);
}
private syncJobs() {
this.jobs = jobStore.jobs;
// The drawer stays mounted so it can animate closed; the pill
// itself disappears once nothing is happening.
this.hidden = !jobStore.shouldShowIndicator && !this.drawerOpen;
if (this.hidden) this.popoverOpen = false;
}
private onDocumentClick = (e: MouseEvent) => {
if (!this.popoverOpen) return;
if (e.composedPath().includes(this)) return;
this.popoverOpen = false;
};
private onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && this.popoverOpen) this.popoverOpen = false;
};
private onTriggerClick = (e: Event) => {
e.stopPropagation();
this.popoverOpen = !this.popoverOpen;
};
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
this.popoverOpen = false;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
this.syncJobs();
};
private async clearFinished(e: Event) {
e.stopPropagation();
await jobStore.clearFinished();
}
/** The job whose progress the ring represents. */
private get primaryJob(): Job | null {
const working = jobStore.workingJobs;
if (working.length > 0) return working[0] ?? null;
const active = jobStore.activeJobs;
return active[0] ?? null;
}
private renderRing(job: Job | null) {
const activeCount = jobStore.activeJobs.length;
const fraction = job ? progressFraction(job) : null;
const tone = job ? stateTone(job) : 'success';
// Only spin for work that is actually moving. A paused or
// queued job spinning would say "busy" when nothing is running.
const spin = Boolean(
job && job.state === 'running' && isIndeterminate(job),
);
const offset =
fraction === null
? RING_CIRCUMFERENCE * 0.72
: RING_CIRCUMFERENCE * (1 - fraction);
return html`
<div class="ring-wrap tone-${tone} ${spin ? 'spin' : ''}">
<svg viewBox="0 0 22 22" aria-hidden="true">
<circle class="ring-track" cx="11" cy="11" r="9"></circle>
<circle
class="ring-value"
cx="11"
cy="11"
r="9"
stroke-dasharray=${RING_CIRCUMFERENCE}
stroke-dashoffset=${offset}
></circle>
</svg>
<div class="ring-glyph">
${activeCount > 1
? activeCount
: html`<wa-icon
name=${job ? jobIcon(job) : 'check'}
></wa-icon>`}
</div>
</div>
`;
}
private renderTrigger() {
const job = this.primaryJob;
const activeCount = jobStore.activeJobs.length;
const hasFailure = jobStore.failedJobs.length > 0;
let label: string;
if (activeCount > 1) {
label = `${activeCount} background jobs`;
} else if (job && job.state === 'running') {
label = job.title;
} else if (job) {
// "Scanning Music" would be a lie for a job that is paused
// or queued, so lead with the state instead.
label = `${stateLabel(job)} · ${job.title}`;
} else {
label = 'Finished';
}
return html`
<button
class="trigger"
aria-haspopup="dialog"
aria-expanded=${this.popoverOpen}
title="Background jobs"
@click=${this.onTriggerClick}
>
${this.renderRing(job)}
<span class="label">${label}</span>
${hasFailure ? html`<span class="alert-dot"></span>` : nothing}
</button>
`;
}
private renderPanel() {
const finished = jobStore.finishedJobs;
return html`
<div class="panel" role="dialog" aria-label="Background jobs">
<div class="panel-header">
<span>Background jobs</span>
${finished.length > 0
? html`<button @click=${this.clearFinished}>
Clear finished
</button>`
: nothing}
</div>
${this.jobs.length === 0
? html`<div class="empty">Nothing running.</div>`
: this.jobs.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="compact"
open-on-click
@job-control=${applyJobControl}
@job-open=${() =>
this.openDetails(job.id)}
></job-row>
<button
class="details-link"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount} warning${job.warnCount === 1 ? '' : 's'}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
override render() {
return html`
<wa-popup
placement="bottom-end"
distance="8"
?active=${this.popoverOpen}
>
<span slot="anchor">${this.renderTrigger()}</span>
${this.popoverOpen ? this.renderPanel() : nothing}
</wa-popup>
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-indicator': JobIndicator;
}
}
@@ -0,0 +1,283 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import type { Job, JobLogEntry } from '@store/job-store';
import { formatLogTime, logToText } from './job-format';
type LevelFilter = 'all' | 'warn' | 'error';
/**
* Scrolling tail of a job's output log, with a severity filter and a
* copy-to-clipboard button.
*
* The buffer is bounded on the backend (500 entries), so this is a tail
* rather than a complete transcript — the header says so when entries
* have been dropped, rather than silently showing a partial log.
*/
@customElement('job-log-view')
export class JobLogView extends LitElement {
@property({ type: Object })
job!: Job;
@property({ type: Array })
entries: JobLogEntry[] = [];
@state()
private filter: LevelFilter = 'all';
@state()
private copied = false;
/** Set while the user has scrolled up, which suspends auto-follow. */
@state()
private following = true;
static override styles = [
designTokens,
css`
:host {
display: flex;
flex-direction: column;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.5em;
padding-bottom: 0.5em;
}
.filters {
display: flex;
gap: 0.2em;
}
.filters button {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
padding: 0.25em 0.6em;
border-radius: 6px;
cursor: pointer;
}
.filters button.active {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.filters button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 1px;
}
.spacer {
flex: 1;
}
.copy {
display: inline-flex;
align-items: center;
gap: 0.4em;
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
padding: 0.25em 0.5em;
border-radius: 6px;
cursor: pointer;
}
.copy:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
.log {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
background: rgba(0, 0, 0, 0.25);
border-radius: 8px;
padding: 0.6em 0.75em;
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: var(--yj-text-sm);
line-height: 1.55;
}
.entry {
display: flex;
gap: 0.7em;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.time {
color: var(--yj-text-tertiary, #868e96);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.message {
color: var(--yj-text-secondary, #adb5bd);
}
.entry.warn .message {
color: #f5a623;
}
.entry.error .message {
color: #ff6b6b;
}
.detail {
color: var(--yj-text-tertiary, #868e96);
padding-left: 1em;
overflow-wrap: anywhere;
}
.empty {
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
padding: 0.5em 0;
}
.truncation-note {
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
padding-bottom: 0.4em;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 0.4em;
}
`,
];
private get filtered(): JobLogEntry[] {
switch (this.filter) {
case 'warn':
return this.entries.filter(
(e) => e.level === 'warn' || e.level === 'error',
);
case 'error':
return this.entries.filter((e) => e.level === 'error');
default:
return this.entries;
}
}
/** Entries the backend ring buffer dropped before we fetched it. */
private get droppedCount(): number {
return Math.max(0, (this.job?.logCount ?? 0) - this.entries.length);
}
override updated() {
if (!this.following) return;
const log = this.renderRoot.querySelector('.log');
if (log) log.scrollTop = log.scrollHeight;
}
private onScroll = (e: Event) => {
const el = e.target as HTMLElement;
// Re-engage auto-follow when the user returns to the bottom.
this.following =
el.scrollHeight - el.scrollTop - el.clientHeight < 24;
};
private setFilter(filter: LevelFilter) {
this.filter = filter;
}
private async copyLog() {
try {
await navigator.clipboard.writeText(
logToText(this.job, this.entries),
);
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 1500);
} catch (err) {
console.error('Failed to copy job log:', err);
}
}
private renderFilterButton(value: LevelFilter, label: string) {
return html`
<button
class=${this.filter === value ? 'active' : ''}
@click=${() => this.setFilter(value)}
>
${label}
</button>
`;
}
override render() {
const entries = this.filtered;
const dropped = this.droppedCount;
return html`
<div class="toolbar">
<div class="filters">
${this.renderFilterButton('all', 'All')}
${this.renderFilterButton(
'warn',
`Warnings${this.job?.warnCount ? ` (${this.job.warnCount})` : ''}`,
)}
${this.renderFilterButton(
'error',
`Errors${this.job?.errorCount ? ` (${this.job.errorCount})` : ''}`,
)}
</div>
<div class="spacer"></div>
<button class="copy" @click=${this.copyLog}>
${this.copied
? html`<wa-icon name="check"></wa-icon>Copied`
: 'Copy'}
</button>
</div>
<div class="log" @scroll=${this.onScroll}>
${dropped > 0
? html`
<div class="truncation-note">
${dropped.toLocaleString()} earlier
${dropped === 1 ? 'entry' : 'entries'} dropped —
showing the most recent output
</div>
`
: nothing}
${entries.length === 0
? html`<div class="empty">No output yet.</div>`
: entries.map(
(entry) => html`
<div class="entry ${entry.level}">
<span class="time"
>${formatLogTime(entry)}</span
>
<span class="message">${entry.message}</span>
</div>
${entry.detail
? html`<div class="detail">
${entry.detail}
</div>`
: nothing}
`,
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-log-view': JobLogView;
}
}
+408
View File
@@ -0,0 +1,408 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/progress-bar/progress-bar.js';
import '@awesome.me/webawesome/dist/components/spinner/spinner.js';
import { designTokens } from '../../styles/tokens.css';
import type { Job } from '@store/job-store';
import { isTerminal, isIndeterminate } from '@store/job-store';
import {
jobIcon,
stateTone,
statusLine,
progressPercent,
formatElapsed,
jobStateStyles,
} from './job-format';
/**
* A single background job: icon, title, status line, progress bar, and
* whichever controls the job declares support for.
*
* Controls are rendered from `job.caps` rather than from the job kind,
* so a job that gains pause support on the backend needs no change
* here. The row emits `job-control` and `job-open`; the host decides
* what to do with them, which is what lets the same row serve both the
* top-bar popover and the full jobs page.
*/
@customElement('job-row')
export class JobRow extends LitElement {
@property({ type: Object })
job!: Job;
/**
* `compact` is the popover density — one line of status, small
* controls. `full` adds elapsed time and per-job statistics.
*/
@property({ type: String })
variant: 'compact' | 'full' = 'compact';
/** Whether clicking the row should emit `job-open`. */
@property({ type: Boolean, attribute: 'open-on-click' })
openOnClick = false;
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
}
.row {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75em;
align-items: start;
padding: 0.6em 0.75em;
border-radius: 8px;
transition: background-color 120ms ease;
}
.row.clickable {
cursor: pointer;
}
.row.clickable:hover {
background: rgba(255, 255, 255, 0.05);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
color: var(--job-tone);
font-size: var(--yj-icon-sm);
flex-shrink: 0;
}
.body {
min-width: 0;
}
.title {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #adb5bd);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.15em;
}
.status .tone {
color: var(--job-tone);
}
wa-progress-bar {
margin-top: 0.45em;
--height: 4px;
--indicator-color: var(--job-tone);
--track-color: rgba(255, 255, 255, 0.09);
}
.indeterminate {
margin-top: 0.5em;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
/* A slider that sweeps left to right, for work with no
* known denominator (the pre-walk file count, for one).
* Static unless the job is actually moving. */
/* Stopped: a dim full-width bar, which reads as "no progress
* information" rather than a partial fill implying a
* percentage the job never reported. */
.indeterminate::after {
content: '';
display: block;
width: 100%;
height: 100%;
border-radius: 2px;
background: var(--job-tone);
opacity: 0.3;
}
.indeterminate.moving::after {
width: 35%;
opacity: 1;
animation: sweep 1.4s ease-in-out infinite;
}
@keyframes sweep {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(320%);
}
}
@media (prefers-reduced-motion: reduce) {
.indeterminate.moving::after {
animation: none;
width: 100%;
opacity: 0.5;
}
}
.stats {
display: flex;
flex-wrap: wrap;
gap: 0.25em 1.1em;
margin-top: 0.5em;
font-size: var(--yj-text-sm);
}
.stat-label {
color: var(--yj-text-tertiary, #868e96);
}
.stat-value {
color: var(--yj-text-primary, #e9ecef);
font-variant-numeric: tabular-nums;
margin-left: 0.35em;
}
.error {
margin-top: 0.45em;
font-size: var(--yj-text-sm);
color: #ff6b6b;
overflow-wrap: anywhere;
}
.controls {
display: flex;
align-items: center;
gap: 0.15em;
flex-shrink: 0;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
cursor: pointer;
font-size: var(--yj-icon-sm);
transition:
background-color 120ms ease,
color 120ms ease;
}
button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
button.danger:hover:not(:disabled) {
color: #ff6b6b;
}
button:disabled {
opacity: 0.35;
cursor: default;
}
button:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 1px;
}
`,
];
private emitControl(action: 'pause' | 'resume' | 'cancel' | 'dismiss') {
this.dispatchEvent(
new CustomEvent('job-control', {
detail: { id: this.job.id, action },
bubbles: true,
composed: true,
}),
);
}
private emitOpen() {
this.dispatchEvent(
new CustomEvent('job-open', {
detail: { id: this.job.id },
bubbles: true,
composed: true,
}),
);
}
private renderProgress() {
const job = this.job;
if (isTerminal(job)) return nothing;
if (isIndeterminate(job)) {
// Only sweep while work is actually moving — an animated bar
// on a paused job reads as progress that isn't happening.
return html`
<div
class="indeterminate ${job.state === 'running'
? 'moving'
: ''}"
></div>
`;
}
return html`
<wa-progress-bar
value=${progressPercent(job) ?? 0}
></wa-progress-bar>
`;
}
private renderStats() {
if (this.variant !== 'full') return nothing;
if (!this.job.stats?.length) return nothing;
return html`
<div class="stats">
${this.job.stats.map(
(stat) => html`
<div>
<span class="stat-label">${stat.label}</span>
<span class="stat-value">${stat.value}</span>
</div>
`,
)}
</div>
`;
}
private renderControls() {
const job = this.job;
// A finished job offers only dismissal.
if (isTerminal(job)) {
return html`
<button
class="danger"
title="Dismiss"
aria-label="Dismiss ${job.title}"
@click=${this.onDismiss}
>
<wa-icon name="xmark"></wa-icon>
</button>
`;
}
const paused = job.state === 'paused';
const settling = job.state === 'pausing' || job.state === 'cancelling';
return html`
${job.caps.pausable
? html`
<button
title=${paused ? 'Resume' : 'Pause'}
aria-label=${paused
? `Resume ${job.title}`
: `Pause ${job.title}`}
?disabled=${settling}
@click=${paused ? this.onResume : this.onPause}
>
<wa-icon name=${paused ? 'play' : 'pause'}></wa-icon>
</button>
`
: nothing}
${job.caps.cancellable
? html`
<button
class="danger"
title="Stop"
aria-label="Stop ${job.title}"
?disabled=${job.state === 'cancelling'}
@click=${this.onCancel}
>
<wa-icon name="stop"></wa-icon>
</button>
`
: nothing}
`;
}
private onPause = (e: Event) => {
e.stopPropagation();
this.emitControl('pause');
};
private onResume = (e: Event) => {
e.stopPropagation();
this.emitControl('resume');
};
private onCancel = (e: Event) => {
e.stopPropagation();
this.emitControl('cancel');
};
private onDismiss = (e: Event) => {
e.stopPropagation();
this.emitControl('dismiss');
};
private onRowClick = () => {
if (this.openOnClick) this.emitOpen();
};
override render() {
const job = this.job;
if (!job) return nothing;
const tone = stateTone(job);
const elapsed =
this.variant === 'full' ? ` · ${formatElapsed(job)}` : '';
return html`
<div
class="row tone-${tone} ${this.openOnClick ? 'clickable' : ''}"
@click=${this.onRowClick}
>
<div class="icon">
<wa-icon name=${jobIcon(job)}></wa-icon>
</div>
<div class="body">
<div class="title">${job.title}</div>
<div class="status">
<span class="tone">${statusLine(job)}</span>${elapsed}
</div>
${this.renderProgress()} ${this.renderStats()}
${job.error
? html`<div class="error">${job.error}</div>`
: nothing}
</div>
<div class="controls">${this.renderControls()}</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'job-row': JobRow;
}
}
+510
View File
@@ -0,0 +1,510 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
import {
GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/Library';
import type { library } from '@go/models';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import './job-row';
import './job-details-drawer';
import { applyJobControl } from './job-controls';
import { jobStateStyles } from './job-format';
type LibraryInfo = library.Info;
/** Job states meaning the job will not progress further. */
const TERMINAL_STATES: ReadonlySet<string> = new Set([
'complete',
'cancelled',
'error',
]);
/**
* Full-page view of background work: everything running right now, the
* per-library scan controls that used to live in Settings, and a short
* history of what recently finished.
*
* This is the same job rows as the top-bar popover at a larger density —
* one implementation, two placements, so the two can never disagree.
*/
@customElement('jobs-view')
export class JobsView extends LitElement {
@state()
private jobs: Job[] = [];
@state()
private libraries: LibraryInfo[] = [];
@state()
private drawerJobId = '';
@state()
private drawerOpen = false;
private unsubscribe: (() => void) | null = null;
private eventCleanups: Array<() => void> = [];
static override styles = [
designTokens,
jobStateStyles,
css`
:host {
display: block;
overflow-y: auto;
height: 100%;
padding: 1.5em 1.75em 3em;
box-sizing: border-box;
}
h1 {
font-size: var(--yj-text-xl);
color: var(--yj-text-primary, #e9ecef);
margin: 0 0 0.2em;
}
.page-sub {
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
margin: 0 0 1.75em;
}
section {
margin-bottom: 2em;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
margin-bottom: 0.75em;
}
h2 {
font-size: var(--yj-text-sm);
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--yj-text-tertiary, #868e96);
margin: 0;
}
.card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
overflow: hidden;
}
.card > * + * {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.job-entry {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 0.5em;
padding-right: 0.75em;
}
.empty {
padding: 1.1em;
font-size: var(--yj-text-md);
color: var(--yj-text-tertiary, #868e96);
font-style: italic;
}
.library-row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 1em;
padding: 0.75em 0.9em;
}
.library-name {
font-size: var(--yj-text-md);
color: var(--yj-text-primary, #e9ecef);
}
.library-meta {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #868e96);
margin-top: 0.15em;
overflow-wrap: anywhere;
}
.library-state {
font-size: var(--yj-text-sm);
color: var(--job-tone);
margin-top: 0.15em;
}
button.action {
display: inline-flex;
align-items: center;
gap: 0.45em;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 7px;
background: rgba(255, 255, 255, 0.05);
color: var(--yj-text-primary, #e9ecef);
font-size: var(--yj-text-sm);
padding: 0.42em 0.85em;
cursor: pointer;
white-space: nowrap;
transition:
background-color 120ms ease,
border-color 120ms ease;
}
button.action:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.11);
}
button.action:disabled {
opacity: 0.4;
cursor: default;
}
button.action:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
button.action.danger {
color: #ff6b6b;
border-color: rgba(255, 107, 107, 0.35);
}
button.action.danger:hover:not(:disabled) {
background: rgba(255, 107, 107, 0.12);
}
button.link {
border: none;
background: transparent;
color: var(--yj-accent, #ffd43b);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.2em 0.4em;
border-radius: 5px;
}
button.link:hover {
text-decoration: underline;
}
.details-btn {
border: none;
background: transparent;
color: var(--yj-text-secondary, #adb5bd);
font-size: var(--yj-text-sm);
cursor: pointer;
padding: 0.3em 0.5em;
border-radius: 6px;
white-space: nowrap;
}
.details-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--yj-text-primary, #e9ecef);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
this.unsubscribe = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
void jobStore.init();
this.jobs = jobStore.jobs;
void this.loadLibraries();
// Library CRUD happens elsewhere; keep the picker in step.
for (const event of [
Events.LibraryAdded,
Events.LibraryRemoved,
Events.LibraryRenamed,
Events.LibraryScanComplete,
]) {
this.eventCleanups.push(
EventsOn(event, () => void this.loadLibraries()),
);
}
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubscribe?.();
this.unsubscribe = null;
this.eventCleanups.forEach((off) => off());
this.eventCleanups = [];
}
private async loadLibraries(): Promise<void> {
try {
this.libraries = (await GetAllLibrariesWithTrackCounts()) ?? [];
} catch (err) {
console.error('Failed to load libraries:', err);
}
}
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return jobStore.getJob(`scan:${id}`);
}
private openDetails(id: string) {
this.drawerJobId = id;
this.drawerOpen = true;
}
private onDrawerClosed = () => {
this.drawerOpen = false;
};
private async startScan(id: number) {
try {
await ScanLibrary(id);
} catch (err) {
console.error('Failed to start scan:', err);
}
}
private async startAllScans() {
try {
await ScanAllLibraries();
} catch (err) {
console.error('Failed to start scans:', err);
}
}
private async clearFinished() {
await jobStore.clearFinished();
}
private async fullRescan() {
if (
!window.confirm(
'Full rescan deletes ALL library data — including ' +
'downloaded cover art — and rebuilds it from your ' +
'files.\n\nThis is not the same as "Scan now", which ' +
'only picks up what changed. Continue?',
)
) {
return;
}
try {
await FullRescan();
} catch (err) {
console.error('Full rescan failed:', err);
}
}
private renderJobList(list: Job[], emptyText: string) {
if (list.length === 0) {
return html`<div class="card">
<div class="empty">${emptyText}</div>
</div>`;
}
return html`
<div class="card">
${list.map(
(job) => html`
<div class="job-entry">
<job-row
.job=${job}
variant="full"
@job-control=${applyJobControl}
></job-row>
<button
class="details-btn"
@click=${() => this.openDetails(job.id)}
>
Details${job.warnCount
? ` · ${job.warnCount}`
: ''}
</button>
</div>
`,
)}
</div>
`;
}
/** The status line under a library name in the scan-control list. */
private libraryStatus(job: Job | undefined): string | null {
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private renderLibraryRow(lib: LibraryInfo) {
const job = this.jobForLibrary(lib.id);
const status = this.libraryStatus(job);
const busy = status !== null;
return html`
<div class="library-row">
<div>
<div class="library-name">${lib.name}</div>
<div class="library-meta">
${lib.trackCount.toLocaleString()} tracks · ${lib.path}
</div>
${status
? html`<div class="library-state">${status}</div>`
: nothing}
</div>
${busy
? html`
<button
class="link"
@click=${() => this.openDetails(`scan:${lib.id}`)}
>
View progress
</button>
`
: html`
<button
class="action"
@click=${() => this.startScan(lib.id)}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan now
</button>
`}
</div>
`;
}
override render() {
// Derived from `this.jobs` rather than the store getters so Lit
// sees the reactive dependency and re-renders on every snapshot.
const active = this.jobs.filter((j) => !TERMINAL_STATES.has(j.state));
const finished = this.jobs.filter((j) => TERMINAL_STATES.has(j.state));
const anyScanning = this.libraries.some((lib) =>
Boolean(this.libraryStatus(this.jobForLibrary(lib.id))),
);
return html`
<h1>Background jobs</h1>
<p class="page-sub">
Library scans and search index builds, with their progress and
output.
</p>
<section>
<div class="section-head">
<h2>Running now</h2>
</div>
${this.renderJobList(active, 'Nothing is running.')}
</section>
<section>
<div class="section-head">
<h2>Libraries</h2>
<button
class="action"
?disabled=${anyScanning || this.libraries.length === 0}
@click=${this.startAllScans}
>
<wa-icon name="arrows-rotate"></wa-icon>
Scan all
</button>
</div>
<div class="card">
${this.libraries.length === 0
? html`<div class="empty">
No libraries yet — add one in Settings.
</div>`
: this.libraries.map((lib) =>
this.renderLibraryRow(lib),
)}
</div>
</section>
<section>
<div class="section-head">
<h2>Maintenance</h2>
</div>
<div class="card">
<div class="library-row">
<div>
<div class="library-name">Full rescan</div>
<div class="library-meta">
Wipes all library data and cover art, then
rebuilds from your files. Only needed when the
library is corrupt — a normal scan already
picks up changes.
</div>
</div>
<button
class="action danger"
?disabled=${anyScanning}
@click=${this.fullRescan}
>
<wa-icon name="triangle-exclamation"></wa-icon>
Full rescan
</button>
</div>
</div>
</section>
${finished.length > 0
? html`
<section>
<div class="section-head">
<h2>Recently finished</h2>
<button
class="link"
@click=${this.clearFinished}
>
Clear
</button>
</div>
${this.renderJobList(finished, '')}
</section>
`
: nothing}
<job-details-drawer
job-id=${this.drawerJobId}
?open=${this.drawerOpen}
@drawer-closed=${this.onDrawerClosed}
></job-details-drawer>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'jobs-view': JobsView;
}
}
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'settings';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'jobs' | 'settings';
interface NavItem {
id: View;
@@ -150,6 +150,7 @@ export class AppSidebar extends LitElement {
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];