feat(web): richer active-job rows on The Floor (source/format/tracks, stage, elapsed, error)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ export function JobRow({
|
|||||||
meta,
|
meta,
|
||||||
note,
|
note,
|
||||||
indeterminate,
|
indeterminate,
|
||||||
|
stageLabel,
|
||||||
}: {
|
}: {
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
@@ -30,6 +31,7 @@ export function JobRow({
|
|||||||
meta?: ReactNode;
|
meta?: ReactNode;
|
||||||
note?: ReactNode;
|
note?: ReactNode;
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
|
stageLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<article className={`job ${rowClass(kind)}`}>
|
<article className={`job ${rowClass(kind)}`}>
|
||||||
@@ -42,7 +44,7 @@ export function JobRow({
|
|||||||
{note ? (
|
{note ? (
|
||||||
<div className="note">{note}</div>
|
<div className="note">{note}</div>
|
||||||
) : kind !== "attention" ? (
|
) : kind !== "attention" ? (
|
||||||
<ProgressBar kind={kind} step={step} total={total} indeterminate={indeterminate} />
|
<ProgressBar kind={kind} step={step} total={total} indeterminate={indeterminate} stageLabel={stageLabel} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<StatusChip kind={kind} label={label} />
|
<StatusChip kind={kind} label={label} />
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ export function ProgressBar({
|
|||||||
step,
|
step,
|
||||||
total,
|
total,
|
||||||
indeterminate,
|
indeterminate,
|
||||||
|
stageLabel,
|
||||||
}: {
|
}: {
|
||||||
kind: Kind;
|
kind: Kind;
|
||||||
step: number;
|
step: number;
|
||||||
total: number;
|
total: number;
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
|
stageLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
if (indeterminate) {
|
if (indeterminate) {
|
||||||
return (
|
return (
|
||||||
@@ -29,9 +31,7 @@ export function ProgressBar({
|
|||||||
<div className="bar">
|
<div className="bar">
|
||||||
<span style={{ width: `${pct}%` }} />
|
<span style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="pct">
|
<div className="pct">{stageLabel ? `${stageLabel} · step ${step} of ${total}` : `${step}/${total}`}</div>
|
||||||
{step}/{total}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { describeJob } from "./status";
|
import { describeJob, timeAgo } from "./status";
|
||||||
|
|
||||||
describe("describeJob", () => {
|
describe("describeJob", () => {
|
||||||
it("maps pipeline states to literal labels + severity kind", () => {
|
it("maps pipeline states to literal labels + severity kind", () => {
|
||||||
@@ -22,3 +22,29 @@ describe("describeJob", () => {
|
|||||||
expect(describeJob("downloading", "???").step).toBe(0);
|
expect(describeJob("downloading", "???").step).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("timeAgo", () => {
|
||||||
|
const now = new Date("2026-07-13T12:00:00Z").getTime();
|
||||||
|
|
||||||
|
it("returns 'just now' for deltas under 60s", () => {
|
||||||
|
expect(timeAgo(new Date(now - 30_000).toISOString(), now)).toBe("just now");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns minutes for sub-hour deltas", () => {
|
||||||
|
expect(timeAgo(new Date(now - 5 * 60_000).toISOString(), now)).toBe("5m");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns hours for sub-day deltas", () => {
|
||||||
|
expect(timeAgo(new Date(now - 2 * 3600_000).toISOString(), now)).toBe("2h");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns days for multi-day deltas", () => {
|
||||||
|
expect(timeAgo(new Date(now - 3 * 86_400_000).toISOString(), now)).toBe("3d");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for null/undefined/invalid input", () => {
|
||||||
|
expect(timeAgo(null, now)).toBe("");
|
||||||
|
expect(timeAgo(undefined, now)).toBe("");
|
||||||
|
expect(timeAgo("not-a-date", now)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ const STATE_MAP: Record<string, { label: string; kind: Kind }> = {
|
|||||||
needs_attention: { label: "Needs attention", kind: "attention" },
|
needs_attention: { label: "Needs attention", kind: "attention" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Compact elapsed label from an ISO/Date string to `now` (ms since epoch). */
|
||||||
|
export function timeAgo(value: string | Date | null | undefined, now: number): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const t = new Date(value).getTime();
|
||||||
|
if (Number.isNaN(t)) return "";
|
||||||
|
const s = Math.max(0, Math.floor((now - t) / 1000));
|
||||||
|
if (s < 60) return "just now";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
if (m < 60) return `${m}m`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h`;
|
||||||
|
return `${Math.floor(h / 24)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
export function describeJob(
|
export function describeJob(
|
||||||
state: string,
|
state: string,
|
||||||
stage: string,
|
stage: string,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { POST, GET } from "./route";
|
import { POST, GET } from "./route";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
function postReq(body: unknown) {
|
function postReq(body: unknown) {
|
||||||
return new Request("http://localhost/api/requests", {
|
return new Request("http://localhost/api/requests", {
|
||||||
@@ -55,4 +56,58 @@ describe("requests API", () => {
|
|||||||
expect(body.requests[0].artist).toBe("B");
|
expect(body.requests[0].artist).toBe("B");
|
||||||
expect(body.requests[0].job.state).toBe("requested");
|
expect(body.requests[0].job.state).toBe("requested");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("expands job detail with candidate summary and chosen candidate", async () => {
|
||||||
|
const created = await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
artist: "Chosen Artist",
|
||||||
|
album: "Chosen Album",
|
||||||
|
job: { create: { state: "downloading", currentStage: "download", attempts: 2, error: "boom" } },
|
||||||
|
},
|
||||||
|
include: { job: true },
|
||||||
|
});
|
||||||
|
await prisma.candidate.create({
|
||||||
|
data: {
|
||||||
|
jobId: created.job!.id,
|
||||||
|
source: "qobuz",
|
||||||
|
format: "flac",
|
||||||
|
qualityClass: 1,
|
||||||
|
trackCount: 12,
|
||||||
|
confidence: 0.9,
|
||||||
|
sourceRef: "ref-1",
|
||||||
|
chosen: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.candidate.create({
|
||||||
|
data: {
|
||||||
|
jobId: created.job!.id,
|
||||||
|
source: "slskd",
|
||||||
|
format: "mp3",
|
||||||
|
qualityClass: 2,
|
||||||
|
trackCount: 12,
|
||||||
|
confidence: 0.5,
|
||||||
|
sourceRef: "ref-2",
|
||||||
|
chosen: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
const found = body.requests.find((r: { id: string }) => r.id === created.id);
|
||||||
|
expect(found.job.candidateCount).toBe(2);
|
||||||
|
expect(found.job.chosen).toEqual({ source: "qobuz", format: "flac", trackCount: 12 });
|
||||||
|
expect(found.job.error).toBe("boom");
|
||||||
|
expect(found.job.attempts).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no chosen candidate and zero count when a job has none", async () => {
|
||||||
|
const res = await POST(postReq({ artist: "NoCand", album: "Album" }));
|
||||||
|
const created = await res.json();
|
||||||
|
const listRes = await GET();
|
||||||
|
const body = await listRes.json();
|
||||||
|
const found = body.requests.find((r: { id: string }) => r.id === created.id);
|
||||||
|
expect(found.job.candidateCount).toBe(0);
|
||||||
|
expect(found.job.chosen).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const [requests, releases] = await Promise.all([
|
const [requests, releases] = await Promise.all([
|
||||||
prisma.request.findMany({ orderBy: { createdAt: "desc" }, include: { job: true } }),
|
prisma.request.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: { job: { include: { candidates: { select: { source: true, format: true, trackCount: true, chosen: true } } } } },
|
||||||
|
}),
|
||||||
prisma.monitoredRelease.findMany({ select: { artistName: true, album: true, rgMbid: true } }),
|
prisma.monitoredRelease.findMany({ select: { artistName: true, album: true, rgMbid: true } }),
|
||||||
]);
|
]);
|
||||||
const rgByKey = new Map(releases.map((mr) => [`${mr.artistName} ${mr.album}`, mr.rgMbid]));
|
const rgByKey = new Map(releases.map((mr) => [`${mr.artistName} ${mr.album}`, mr.rgMbid]));
|
||||||
@@ -49,7 +52,22 @@ export async function GET() {
|
|||||||
status: r.status,
|
status: r.status,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
rgMbid: rgByKey.get(`${r.artist} ${r.album}`) ?? null,
|
rgMbid: rgByKey.get(`${r.artist} ${r.album}`) ?? null,
|
||||||
job: r.job ? { state: r.job.state, currentStage: r.job.currentStage } : null,
|
job: r.job
|
||||||
|
? (() => {
|
||||||
|
const cands = r.job.candidates;
|
||||||
|
const chosen = cands.find((c) => c.chosen) ?? null;
|
||||||
|
return {
|
||||||
|
state: r.job.state,
|
||||||
|
currentStage: r.job.currentStage,
|
||||||
|
attempts: r.job.attempts,
|
||||||
|
error: r.job.error,
|
||||||
|
claimedAt: r.job.claimedAt,
|
||||||
|
updatedAt: r.job.updatedAt,
|
||||||
|
candidateCount: cands.length,
|
||||||
|
chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null,
|
||||||
|
};
|
||||||
|
})()
|
||||||
|
: null,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-10
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { describeJob } from "./_ui/status";
|
import { describeJob, timeAgo } from "./_ui/status";
|
||||||
import { StatTiles } from "./_ui/stat-tiles";
|
import { StatTiles } from "./_ui/stat-tiles";
|
||||||
import { SectionHeader } from "./_ui/section-header";
|
import { SectionHeader } from "./_ui/section-header";
|
||||||
import { JobRow } from "./_ui/job-row";
|
import { JobRow } from "./_ui/job-row";
|
||||||
@@ -15,11 +15,31 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
rgMbid?: string | null;
|
rgMbid?: string | null;
|
||||||
job: { state: string; currentStage: string } | null;
|
job: {
|
||||||
|
state: string;
|
||||||
|
currentStage: string;
|
||||||
|
attempts: number;
|
||||||
|
error: string | null;
|
||||||
|
claimedAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
candidateCount: number;
|
||||||
|
chosen: { source: string; format: string; trackCount: number } | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function jobOf(r: Row) {
|
function jobOf(r: Row) {
|
||||||
return r.job ?? { state: "requested", currentStage: "intake" };
|
return (
|
||||||
|
r.job ?? {
|
||||||
|
state: "requested",
|
||||||
|
currentStage: "intake",
|
||||||
|
attempts: 0,
|
||||||
|
error: null,
|
||||||
|
claimedAt: null,
|
||||||
|
updatedAt: null,
|
||||||
|
candidateCount: 0,
|
||||||
|
chosen: null,
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pressedMark(r: Row): string {
|
function pressedMark(r: Row): string {
|
||||||
@@ -101,10 +121,42 @@ export function Queue() {
|
|||||||
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
|
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
|
||||||
) : (
|
) : (
|
||||||
<section className="floor">
|
<section className="floor">
|
||||||
{active.map((r) => {
|
{(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
return active.map((r) => {
|
||||||
const j = jobOf(r);
|
const j = jobOf(r);
|
||||||
const d = describeJob(j.state, j.currentStage);
|
const d = describeJob(j.state, j.currentStage);
|
||||||
const attention = d.kind === "attention";
|
const attention = d.kind === "attention";
|
||||||
|
|
||||||
|
let meta: string | undefined;
|
||||||
|
if (j.state === "downloading" || j.state === "tagging") {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format} · ${j.chosen.trackCount} tracks`);
|
||||||
|
const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now);
|
||||||
|
if (elapsed) parts.push(elapsed);
|
||||||
|
meta = parts.join(" · ") || undefined;
|
||||||
|
} else if (j.state === "matching" || j.state === "matched") {
|
||||||
|
meta =
|
||||||
|
j.candidateCount > 0
|
||||||
|
? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}`
|
||||||
|
: "Searching sources…";
|
||||||
|
} else if (j.state === "requested") {
|
||||||
|
const elapsed = timeAgo(r.createdAt, now);
|
||||||
|
meta = elapsed ? `In the queue · ${elapsed}` : "In the queue";
|
||||||
|
} else if (j.state === "needs_attention") {
|
||||||
|
meta = undefined;
|
||||||
|
} else {
|
||||||
|
meta = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = attention
|
||||||
|
? j.error?.trim() ||
|
||||||
|
"No source met the quality cutoff, or the pipeline stalled. Retry the request, or lower the cutoff for this release."
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const stageLabel =
|
||||||
|
attention || j.state === "matching" || j.state === "matched" ? undefined : j.currentStage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JobRow
|
<JobRow
|
||||||
key={r.id}
|
key={r.id}
|
||||||
@@ -115,14 +167,13 @@ export function Queue() {
|
|||||||
step={d.step}
|
step={d.step}
|
||||||
total={d.totalSteps}
|
total={d.totalSteps}
|
||||||
indeterminate={j.state === "matching" || j.state === "matched"}
|
indeterminate={j.state === "matching" || j.state === "matched"}
|
||||||
note={
|
meta={meta}
|
||||||
attention
|
note={note}
|
||||||
? "No source met the quality cutoff, or the pipeline stalled. Retry the request, or lower the cutoff for this release."
|
stageLabel={stageLabel}
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
});
|
||||||
|
})()}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user