Fas 1a punkt 3+4: worker-härdning (DLQ, larm, multi-instance), AAMOS training export
This commit is contained in:
@@ -10,6 +10,7 @@ import { SubscriptionsPage } from "./pages/Subscriptions.js";
|
||||
import { AuditLogsPage } from "./pages/AuditLogs.js";
|
||||
import { AnalyticsPage } from "./pages/Analytics.js";
|
||||
import { ReleaseGatesPage } from "./pages/ReleaseGates.js";
|
||||
import { WorkersPage } from "./pages/Workers.js";
|
||||
|
||||
export function App() {
|
||||
const [authed, setAuthed] = useState(hasToken());
|
||||
@@ -32,6 +33,7 @@ export function App() {
|
||||
<NavLink to="/audit">Audit logs</NavLink>
|
||||
<NavLink to="/analytics">Analytics</NavLink>
|
||||
<NavLink to="/release-gates">Release gates</NavLink>
|
||||
<NavLink to="/workers">Workers</NavLink>
|
||||
<button
|
||||
onClick={() => {
|
||||
setToken(null);
|
||||
@@ -51,6 +53,7 @@ export function App() {
|
||||
<Route path="/audit" element={<AuditLogsPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/release-gates" element={<ReleaseGatesPage />} />
|
||||
<Route path="/workers" element={<WorkersPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -124,3 +124,51 @@ input, select {
|
||||
.stat .value { font-size: 1.6rem; font-weight: 700; }
|
||||
.stat .label { color: var(--muted); font-size: 0.8rem; }
|
||||
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.card h3 { margin-top: 0; }
|
||||
|
||||
.metrics { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.metric {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 1rem;
|
||||
min-width: 90px;
|
||||
}
|
||||
.metric .value { font-size: 1.4rem; font-weight: 700; }
|
||||
.metric .label { color: var(--muted); font-size: 0.8rem; }
|
||||
|
||||
.btn {
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn:hover { background: var(--brand-dark); }
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn.small { padding: 0.25rem 0.55rem; font-size: 0.8rem; }
|
||||
.btn.danger { background: var(--danger); }
|
||||
|
||||
.alert {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.alert.err { background: #fee2e2; color: var(--danger); }
|
||||
|
||||
.ellipsis {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user