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
+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);
}
`;