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 { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { WorkerStatus } from "./worker-status";
|
||||||
|
|
||||||
export function Masthead() {
|
export function Masthead() {
|
||||||
return (
|
return (
|
||||||
@@ -10,10 +11,7 @@ export function Masthead() {
|
|||||||
<div className="tagline">A record label for an audience of one</div>
|
<div className="tagline">A record label for an audience of one</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="press-status">
|
<div className="press-status">
|
||||||
<div>
|
<WorkerStatus />
|
||||||
<span className="live-dot" />
|
|
||||||
Worker · listening
|
|
||||||
</div>
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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[] }) {
|
export function PressedList({ items }: { items: PressedItem[] }) {
|
||||||
return (
|
return (
|
||||||
<ul className="pressed">
|
<ul className="pressed">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<li key={it.id}>
|
<li key={it.id}>
|
||||||
<span className="mark">✓</span>
|
<CoverArt rgMbid={it.rgMbid} alt={it.album} className="pressed-thumb" />
|
||||||
<span className="name">
|
<span className="name">
|
||||||
{it.album} <span className="artist">· {it.artist}</span>
|
{it.album} <span className="artist">· {it.artist}</span>
|
||||||
</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() {
|
export async function GET() {
|
||||||
const requests = await prisma.request.findMany({
|
const [requests, releases] = await Promise.all([
|
||||||
orderBy: { createdAt: "desc" },
|
prisma.request.findMany({ orderBy: { createdAt: "desc" }, include: { job: true } }),
|
||||||
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({
|
return Response.json({
|
||||||
requests: requests.map((r) => ({
|
requests: requests.map((r) => ({
|
||||||
@@ -47,6 +48,7 @@ export async function GET() {
|
|||||||
album: r.album,
|
album: r.album,
|
||||||
status: r.status,
|
status: r.status,
|
||||||
createdAt: r.createdAt,
|
createdAt: r.createdAt,
|
||||||
|
rgMbid: rgByKey.get(`${r.artist} ${r.album}`) ?? null,
|
||||||
job: r.job ? { state: r.job.state, currentStage: r.job.currentStage } : 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; } }
|
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
||||||
@media (prefers-reduced-motion: reduce) { .toast { animation: 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 { useEffect, useMemo, useState } from "react";
|
||||||
import { PageHead } from "../_ui/page-head";
|
import { PageHead } from "../_ui/page-head";
|
||||||
|
import { SectionHeader } from "../_ui/section-header";
|
||||||
import { CoverArt } from "../_ui/cover-art";
|
import { CoverArt } from "../_ui/cover-art";
|
||||||
import { AlbumModal } from "../_ui/album-modal";
|
import { AlbumModal } from "../_ui/album-modal";
|
||||||
|
|
||||||
@@ -42,14 +43,17 @@ export function LibraryClient() {
|
|||||||
<span>Filter</span>
|
<span>Filter</span>
|
||||||
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input aria-label="filter library" placeholder="Artist or album…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<span className="rmeta">
|
|
||||||
{shown.length} of {albums.length} albums
|
|
||||||
</span>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{albums.length === 0 ? (
|
{albums.length === 0 ? (
|
||||||
<p className="empty">Nothing pressed yet. Scan your library in Settings, or queue a release from The Floor.</p>
|
<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">
|
<div className="album-grid">
|
||||||
{shown.map((a) => (
|
{shown.map((a) => (
|
||||||
<button key={a.id} className="album-card" onClick={() => setOpen(a)} aria-label={`Open ${a.album}`}>
|
<button key={a.id} className="album-card" onClick={() => setOpen(a)} aria-label={`Open ${a.album}`}>
|
||||||
@@ -64,7 +68,7 @@ export function LibraryClient() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<AlbumModal
|
<AlbumModal
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Row = {
|
|||||||
album: string;
|
album: string;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
rgMbid?: string | null;
|
||||||
job: { state: string; currentStage: string } | null;
|
job: { state: string; currentStage: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ export function Queue() {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader title="Recently Pressed" note={`${pressed.length} in library`} />
|
<SectionHeader title="Recently Pressed" note={`${pressed.length} in library`} />
|
||||||
<PressedList
|
<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}
|
) : null}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from lyra_worker.registry import (
|
|||||||
from lyra_worker.scan import scan_chunk
|
from lyra_worker.scan import scan_chunk
|
||||||
|
|
||||||
IDLE_SLEEP = 2.0
|
IDLE_SLEEP = 2.0
|
||||||
|
HEARTBEAT_SECONDS = 15.0 # how often to stamp worker.heartbeat (its updatedAt = liveness)
|
||||||
MONITOR_TICK_SECONDS = 60.0
|
MONITOR_TICK_SECONDS = 60.0
|
||||||
DISCOVER_TICK_SECONDS = 300.0 # check the discovery interval every 5 min (interval itself is hours)
|
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
|
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)
|
print("worker: waiting for jobs", flush=True)
|
||||||
last_tick = 0.0
|
last_tick = 0.0
|
||||||
last_discover_tick = 0.0
|
last_discover_tick = 0.0
|
||||||
|
last_heartbeat = 0.0
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
# A transient DB connection loss (e.g. AdminShutdown when Postgres restarts) raises
|
# A transient DB connection loss (e.g. AdminShutdown when Postgres restarts) raises
|
||||||
@@ -157,6 +159,9 @@ def run_forever() -> None:
|
|||||||
config = get_config(conn)
|
config = get_config(conn)
|
||||||
mcfg = MonitorConfig.from_config(config)
|
mcfg = MonitorConfig.from_config(config)
|
||||||
now = time.monotonic()
|
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:
|
if mcfg.enabled and now - last_tick >= MONITOR_TICK_SECONDS:
|
||||||
try:
|
try:
|
||||||
sweep(conn, browser, mcfg)
|
sweep(conn, browser, mcfg)
|
||||||
|
|||||||
Reference in New Issue
Block a user