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