205 lines
5.6 KiB
TypeScript
205 lines
5.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { api } from "../api.js";
|
||
|
||
interface JobCounts {
|
||
wait: number;
|
||
active: number;
|
||
delayed: number;
|
||
completed: number;
|
||
failed: number;
|
||
paused?: number;
|
||
}
|
||
|
||
interface WorkerInfo {
|
||
id: string;
|
||
addr: string;
|
||
concurrency: number;
|
||
lastSeenAt: number;
|
||
}
|
||
|
||
interface SchedulerInfo {
|
||
id: string;
|
||
pattern?: string;
|
||
every?: number;
|
||
}
|
||
|
||
interface HealthResponse {
|
||
ok: boolean;
|
||
queues: {
|
||
jobs: JobCounts;
|
||
deadLetter: JobCounts;
|
||
};
|
||
workers: WorkerInfo[];
|
||
schedulers: SchedulerInfo[];
|
||
}
|
||
|
||
interface DlqJob {
|
||
id: string;
|
||
name: string;
|
||
data: unknown;
|
||
failedReason?: string;
|
||
attemptsMade: number;
|
||
timestamp: number;
|
||
}
|
||
|
||
export function WorkersPage() {
|
||
const [health, setHealth] = useState<HealthResponse | null>(null);
|
||
const [dlq, setDlq] = useState<DlqJob[]>([]);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [actionId, setActionId] = useState<string | null>(null);
|
||
|
||
const load = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [h, d] = await Promise.all([
|
||
api<HealthResponse>("/admin/v1/workers/health"),
|
||
api<{ jobs: DlqJob[] }>("/admin/v1/workers/dead-letter?limit=50"),
|
||
]);
|
||
setHealth(h);
|
||
setDlq(d.jobs);
|
||
setError(null);
|
||
} catch (e) {
|
||
setError(String((e as Error).message));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
load();
|
||
const t = setInterval(load, 10_000);
|
||
return () => clearInterval(t);
|
||
}, []);
|
||
|
||
const retry = async (id: string) => {
|
||
setActionId(id);
|
||
try {
|
||
await api(`/admin/v1/workers/dead-letter/${id}/retry`, { method: "POST" });
|
||
await load();
|
||
} catch (e) {
|
||
setError(String((e as Error).message));
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
};
|
||
|
||
const discard = async (id: string) => {
|
||
if (!confirm("Ta bort jobbet permanent?")) return;
|
||
setActionId(id);
|
||
try {
|
||
await api(`/admin/v1/workers/dead-letter/${id}/discard`, { method: "POST" });
|
||
await load();
|
||
} catch (e) {
|
||
setError(String((e as Error).message));
|
||
} finally {
|
||
setActionId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="page">
|
||
<h2>Workers & köer</h2>
|
||
{error && <div className="alert err">{error}</div>}
|
||
{loading && !health && <div>Laddar …</div>}
|
||
|
||
{health && (
|
||
<>
|
||
<section className="card">
|
||
<h3>Jobbkö</h3>
|
||
<div className="metrics">
|
||
<Metric label="Väntar" value={health.queues.jobs.wait} />
|
||
<Metric label="Aktiva" value={health.queues.jobs.active} />
|
||
<Metric label="Fördröjda" value={health.queues.jobs.delayed} />
|
||
<Metric label="Misslyckade" value={health.queues.jobs.failed} />
|
||
<Metric label="Klara" value={health.queues.jobs.completed} />
|
||
</div>
|
||
</section>
|
||
|
||
<section className="card">
|
||
<h3>Dead letter ({health.queues.deadLetter.wait + health.queues.deadLetter.failed})</h3>
|
||
{dlq.length === 0 ? (
|
||
<p>Inga jobb i dead letter.</p>
|
||
) : (
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Jobb</th>
|
||
<th>Fel</th>
|
||
<th>Försök</th>
|
||
<th>Tid</th>
|
||
<th>Åtgärder</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{dlq.map((job) => (
|
||
<tr key={job.id}>
|
||
<td>{job.name}</td>
|
||
<td className="ellipsis" title={job.failedReason}>
|
||
{job.failedReason ?? "–"}
|
||
</td>
|
||
<td>{job.attemptsMade}</td>
|
||
<td>{new Date(job.timestamp).toLocaleString("sv-SE")}</td>
|
||
<td>
|
||
<button
|
||
onClick={() => retry(job.id)}
|
||
disabled={actionId === job.id}
|
||
className="btn small"
|
||
>
|
||
Kör igen
|
||
</button>{" "}
|
||
<button
|
||
onClick={() => discard(job.id)}
|
||
disabled={actionId === job.id}
|
||
className="btn small danger"
|
||
>
|
||
Kasta
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</section>
|
||
|
||
<section className="card">
|
||
<h3>Aktiva worker-instanser</h3>
|
||
{health.workers.length === 0 ? (
|
||
<p className="err">Inga aktiva workers!</p>
|
||
) : (
|
||
<ul>
|
||
{health.workers.map((w) => (
|
||
<li key={w.id}>
|
||
{w.id} @ {w.addr} (concurrency {w.concurrency})
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
|
||
<section className="card">
|
||
<h3>Scheduler</h3>
|
||
<ul>
|
||
{health.schedulers.map((s) => (
|
||
<li key={s.id}>
|
||
{s.id}: {s.pattern ?? `every ${s.every}ms`}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Metric({ label, value }: { label: string; value: number }) {
|
||
return (
|
||
<div className="metric">
|
||
<div className="value">{value}</div>
|
||
<div className="label">{label}</div>
|
||
</div>
|
||
);
|
||
}
|