feat: real worker status, covers on Recently Pressed, library counter
- Worker stamps a throttled worker.heartbeat; GET /api/worker reports online from its updatedAt recency (DB-time); masthead shows a live listening/offline dot instead of a static decoration. - /api/requests enriched with rgMbid; Recently Pressed shows cover thumbs. - Library album count moved from the filter row into a section header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
import { WorkerStatus } from "./worker-status";
|
||||
|
||||
export function Masthead() {
|
||||
return (
|
||||
@@ -10,10 +11,7 @@ export function Masthead() {
|
||||
<div className="tagline">A record label for an audience of one</div>
|
||||
</div>
|
||||
<div className="press-status">
|
||||
<div>
|
||||
<span className="live-dot" />
|
||||
Worker · listening
|
||||
</div>
|
||||
<WorkerStatus />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
export type PressedItem = { id: string; artist: string; album: string; matrix: string };
|
||||
import { CoverArt } from "./cover-art";
|
||||
|
||||
export type PressedItem = { id: string; artist: string; album: string; matrix: string; rgMbid: string | null };
|
||||
|
||||
export function PressedList({ items }: { items: PressedItem[] }) {
|
||||
return (
|
||||
<ul className="pressed">
|
||||
{items.map((it) => (
|
||||
<li key={it.id}>
|
||||
<span className="mark">✓</span>
|
||||
<CoverArt rgMbid={it.rgMbid} alt={it.album} className="pressed-thumb" />
|
||||
<span className="name">
|
||||
{it.album} <span className="artist">· {it.artist}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Live worker liveness in the masthead — polls /api/worker (heartbeat recency). */
|
||||
export function WorkerStatus() {
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
async function check() {
|
||||
try {
|
||||
const d = await (await fetch("/api/worker")).json();
|
||||
if (alive) setOnline(!!d.online);
|
||||
} catch {
|
||||
if (alive) setOnline(false);
|
||||
}
|
||||
}
|
||||
check();
|
||||
const id = setInterval(check, 10000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const label = online === null ? "…" : online ? "listening" : "offline";
|
||||
return (
|
||||
<div className={`worker-status${online === false ? " off" : ""}`}>
|
||||
<span className="live-dot" />
|
||||
Worker · {label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,10 +35,11 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const requests = await prisma.request.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { job: true },
|
||||
});
|
||||
const [requests, releases] = await Promise.all([
|
||||
prisma.request.findMany({ orderBy: { createdAt: "desc" }, include: { job: true } }),
|
||||
prisma.monitoredRelease.findMany({ select: { artistName: true, album: true, rgMbid: true } }),
|
||||
]);
|
||||
const rgByKey = new Map(releases.map((mr) => [`${mr.artistName} ${mr.album}`, mr.rgMbid]));
|
||||
|
||||
return Response.json({
|
||||
requests: requests.map((r) => ({
|
||||
@@ -47,6 +48,7 @@ export async function GET() {
|
||||
album: r.album,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
rgMbid: rgByKey.get(`${r.artist} ${r.album}`) ?? null,
|
||||
job: r.job ? { state: r.job.state, currentStage: r.job.currentStage } : null,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { GET } from "./route";
|
||||
|
||||
async function online() {
|
||||
return (await (await GET()).json()).online;
|
||||
}
|
||||
|
||||
describe("worker status API", () => {
|
||||
it("reports offline when there is no heartbeat", async () => {
|
||||
expect(await online()).toBe(false);
|
||||
});
|
||||
|
||||
it("reports online for a fresh heartbeat", async () => {
|
||||
await prisma.config.create({ data: { key: "worker.heartbeat", value: "1", secret: false } });
|
||||
expect(await online()).toBe(true);
|
||||
});
|
||||
|
||||
it("reports offline for a stale heartbeat", async () => {
|
||||
await prisma.config.create({ data: { key: "worker.heartbeat", value: "1", secret: false } });
|
||||
await prisma.$executeRaw`UPDATE "Config" SET "updatedAt" = now() - interval '5 minutes' WHERE key = 'worker.heartbeat'`;
|
||||
expect(await online()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
// The worker stamps worker.heartbeat every ~15s; it's "online" if that row was updated
|
||||
// recently. Uses DB time on both write and read so container clock skew doesn't matter.
|
||||
export async function GET() {
|
||||
const rows = await prisma.$queryRaw<{ online: boolean }[]>`
|
||||
SELECT (now() - "updatedAt") < interval '40 seconds' AS online
|
||||
FROM "Config" WHERE key = 'worker.heartbeat'`;
|
||||
return Response.json({ online: rows[0]?.online ?? false });
|
||||
}
|
||||
@@ -342,3 +342,11 @@ nav.contents .sep { flex: 1; }
|
||||
}
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||
@media (prefers-reduced-motion: reduce) { .toast { animation: none; } }
|
||||
|
||||
/* ── Worker status (live) ─────────────────────────────── */
|
||||
.worker-status.off { color: var(--graphite); }
|
||||
.worker-status.off .live-dot { background: var(--graphite); animation: none; }
|
||||
|
||||
/* ── Recently-pressed thumbnail ───────────────────────── */
|
||||
.pressed li { align-items: center; }
|
||||
.pressed-thumb { width: 30px; height: 30px; flex-shrink: 0; }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { PageHead } from "../_ui/page-head";
|
||||
import { SectionHeader } from "../_ui/section-header";
|
||||
import { CoverArt } from "../_ui/cover-art";
|
||||
import { AlbumModal } from "../_ui/album-modal";
|
||||
|
||||
@@ -42,14 +43,17 @@ export function LibraryClient() {
|
||||
<span>Filter</span>
|
||||
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
</label>
|
||||
<span className="rmeta">
|
||||
{shown.length} of {albums.length} albums
|
||||
</span>
|
||||
</form>
|
||||
|
||||
{albums.length === 0 ? (
|
||||
<p className="empty">Nothing pressed yet. Scan your library in Settings, or queue a release from The Floor.</p>
|
||||
) : (
|
||||
<SectionHeader
|
||||
title="Pressings"
|
||||
note={q ? `${shown.length} of ${albums.length} albums` : `${albums.length} albums`}
|
||||
/>
|
||||
)}
|
||||
{albums.length > 0 ? (
|
||||
<div className="album-grid">
|
||||
{shown.map((a) => (
|
||||
<button key={a.id} className="album-card" onClick={() => setOpen(a)} aria-label={`Open ${a.album}`}>
|
||||
@@ -64,7 +68,7 @@ export function LibraryClient() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{open ? (
|
||||
<AlbumModal
|
||||
|
||||
@@ -14,6 +14,7 @@ type Row = {
|
||||
album: string;
|
||||
status: string;
|
||||
createdAt?: string;
|
||||
rgMbid?: string | null;
|
||||
job: { state: string; currentStage: string } | null;
|
||||
};
|
||||
|
||||
@@ -129,7 +130,7 @@ export function Queue() {
|
||||
<>
|
||||
<SectionHeader title="Recently Pressed" note={`${pressed.length} in library`} />
|
||||
<PressedList
|
||||
items={pressed.map((r) => ({ id: r.id, artist: r.artist, album: r.album, matrix: pressedMark(r) }))}
|
||||
items={pressed.map((r) => ({ id: r.id, artist: r.artist, album: r.album, matrix: pressedMark(r), rgMbid: r.rgMbid ?? null }))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user