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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user