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:
Jonathan
2026-07-12 17:58:23 +02:00
parent e624d12089
commit 606ea82b70
10 changed files with 103 additions and 15 deletions
+2 -4
View File
@@ -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>
+4 -2
View File
@@ -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>
+34
View File
@@ -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>
);
}
+6 -4
View File
@@ -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,
})),
});
+24
View File
@@ -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);
});
});
+10
View File
@@ -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 });
}
+8
View File
@@ -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; }
+8 -4
View File
@@ -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
+2 -1
View File
@@ -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}
+5
View File
@@ -16,6 +16,7 @@ from lyra_worker.registry import (
from lyra_worker.scan import scan_chunk
IDLE_SLEEP = 2.0
HEARTBEAT_SECONDS = 15.0 # how often to stamp worker.heartbeat (its updatedAt = liveness)
MONITOR_TICK_SECONDS = 60.0
DISCOVER_TICK_SECONDS = 300.0 # check the discovery interval every 5 min (interval itself is hours)
DEST_ROOT = "/music" # must match run_pipeline's default library root
@@ -147,6 +148,7 @@ def run_forever() -> None:
print("worker: waiting for jobs", flush=True)
last_tick = 0.0
last_discover_tick = 0.0
last_heartbeat = 0.0
try:
while True:
# A transient DB connection loss (e.g. AdminShutdown when Postgres restarts) raises
@@ -157,6 +159,9 @@ def run_forever() -> None:
config = get_config(conn)
mcfg = MonitorConfig.from_config(config)
now = time.monotonic()
if now - last_heartbeat >= HEARTBEAT_SECONDS:
_set_config(conn, "worker.heartbeat", "1") # value irrelevant; updatedAt is the signal
last_heartbeat = now
if mcfg.enabled and now - last_tick >= MONITOR_TICK_SECONDS:
try:
sweep(conn, browser, mcfg)