feat(ops): restart individual PM2 children and enrich manage UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 22:31:34 +07:00
co-authored by Cursor
parent 84f29057df
commit aa3c2a25b2
12 changed files with 658 additions and 237 deletions
@@ -0,0 +1,165 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { HeartbeatsToolbar } from "./HeartbeatsToolbar.jsx";
import { useNotifications } from "../notifications.jsx";
import { formatBytes, formatCpu, formatDuration } from "../lib/format.js";
const MAX_WORKERS = 32;
export function HeartbeatsAndPm2Card({
data,
children,
restart,
processRestart,
workerCount,
desiredWorkers,
onDecreaseWorkers,
onIncreaseWorkers,
onScale,
scalePending = false,
onReload,
reloadPending = false,
}) {
const { notify } = useNotifications();
const [forceRestart, setForceRestart] = useState(false);
const [pendingPmId, setPendingPmId] = useState(null);
async function onRestartAll() {
const label = forceRestart ? "Force restart" : "Restart";
if (
forceRestart &&
!window.confirm("Force restart will interrupt active jobs. Continue?")
) {
return;
}
try {
await restart.mutateAsync({ force: forceRestart });
notify.success(`${label} ok`);
} catch (e) {
notify.error(errorMessage(e, `${label} failed`));
}
}
async function onProcessRestart(pmId) {
const id = Number(pmId);
setPendingPmId(id);
try {
await processRestart.mutateAsync({ pmId: id });
notify.success("Process restart ok");
} catch (e) {
notify.error(errorMessage(e, "Process restart failed"));
} finally {
setPendingPmId(null);
}
}
const heartbeats = data?.heartbeats ?? [];
const httpChildren = children?.http ?? [];
const workerChildren = children?.workers ?? [];
const pm2Children = [...httpChildren, ...workerChildren];
const heartbeatByPid = new Map();
for (const h of heartbeats) {
if (!heartbeatByPid.has(h.pid)) heartbeatByPid.set(h.pid, h);
}
return (
<div className="card bg-base-100 border border-base-300 shadow-sm">
<div className="card-body gap-4">
<h2 className="card-title text-lg">Heartbeats</h2>
<HeartbeatsToolbar
forceRestart={forceRestart}
onForceRestartChange={setForceRestart}
onRestartAll={onRestartAll}
restartPending={restart.isPending}
workerCount={workerCount}
desiredWorkers={desiredWorkers}
onDecreaseWorkers={onDecreaseWorkers}
onIncreaseWorkers={onIncreaseWorkers}
onScale={onScale}
scalePending={scalePending}
onReload={onReload}
reloadPending={reloadPending}
maxWorkers={MAX_WORKERS}
/>
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Role</th>
<th>Name</th>
<th>PM ID</th>
<th>Status</th>
<th>PID</th>
<th>Generation</th>
<th>Uptime</th>
<th>Memory</th>
<th>CPU</th>
<th>Restarts</th>
<th />
</tr>
</thead>
<tbody>
{pm2Children.map((p) => {
const pid = p.pid ?? null;
const hb = pid != null ? heartbeatByPid.get(pid) : undefined;
const role =
hb?.role ??
(p.name === "jflow-http"
? "http"
: p.name === "jflow-worker"
? "worker"
: p.name ?? "unknown");
const generation = hb?.generation ?? p.generation ?? "—";
const uptimeMs =
p.uptime != null
? Math.max(0, Date.now() - Number(p.uptime))
: hb
? Math.max(0, Date.now() - hb.ts)
: null;
return (
<tr key={p.pmId}>
<td>{role}</td>
<td className="font-mono">{p.name}</td>
<td className="font-mono">{p.pmId}</td>
<td>{p.status}</td>
<td className="font-mono">{pid ?? "—"}</td>
<td className="font-mono">{generation}</td>
<td className="font-mono" title={uptimeMs != null ? `${uptimeMs}ms` : undefined}>
{formatDuration(uptimeMs)}
</td>
<td className="font-mono">{formatBytes(p.memory)}</td>
<td className="font-mono">{formatCpu(p.cpu)}</td>
<td className="font-mono">{p.restarts ?? 0}</td>
<td className="text-end">
<button
type="button"
className="btn btn-xs"
disabled={
processRestart.isPending && pendingPmId === p.pmId
}
onClick={() => onProcessRestart(p.pmId)}
>
Restart
</button>
</td>
</tr>
);
})}
{!pm2Children.length ? (
<tr>
<td colSpan={11} className="opacity-60">
No PM2 children
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { LuMinus, LuPlus } from "react-icons/lu";
export function HeartbeatsToolbar({
forceRestart,
onForceRestartChange,
onRestartAll,
restartPending = false,
workerCount,
desiredWorkers,
onDecreaseWorkers,
onIncreaseWorkers,
onScale,
scalePending = false,
onReload,
reloadPending = false,
minWorkers = 0,
maxWorkers = 32,
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<label className="label cursor-pointer gap-2 px-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={forceRestart}
onChange={(e) => onForceRestartChange(e.target.checked)}
/>
<span className="label-text">Force</span>
</label>
<button
type="button"
className={`btn btn-sm ${forceRestart ? "btn-error" : "btn-primary"}`}
disabled={restartPending}
onClick={onRestartAll}
>
Restart all
</button>
<div className="divider divider-horizontal mx-1" />
<button
type="button"
className="btn btn-circle btn-sm"
aria-label="Decrease workers"
disabled={workerCount <= minWorkers}
onClick={onDecreaseWorkers}
>
<LuMinus className="size-4" />
</button>
<span className="min-w-20 text-center text-sm tabular-nums">
{workerCount} worker{workerCount === 1 ? "" : "s"}
</span>
<button
type="button"
className="btn btn-circle btn-sm"
aria-label="Increase workers"
disabled={workerCount >= maxWorkers}
onClick={onIncreaseWorkers}
>
<LuPlus className="size-4" />
</button>
<button
type="button"
className="btn btn-sm"
disabled={scalePending || workerCount === desiredWorkers}
onClick={onScale}
>
Scale
</button>
<div className="divider divider-horizontal mx-1" />
<button
type="button"
className="btn btn-sm"
disabled={reloadPending}
onClick={onReload}
>
Reload workflows
</button>
</div>
<div className="divider my-0" />
</div>
);
}
@@ -0,0 +1,46 @@
import { LuCircleCheck, LuCircleX } from "react-icons/lu";
export function HttpProcessCard({
httpOnline,
onStart,
onStop,
startPending = false,
stopPending = false,
}) {
return (
<div className="card bg-base-100 border border-base-300 shadow-sm">
<div className="card-body gap-4">
<h2 className="card-title text-lg">HTTP process</h2>
<div className="flex items-center gap-2">
{httpOnline ? (
<LuCircleCheck className="size-6 text-success" aria-hidden />
) : (
<LuCircleX className="size-6 text-error" aria-hidden />
)}
<span>{httpOnline ? "Running" : "Stopped"}</span>
</div>
<div className="card-actions justify-start px-0">
{httpOnline ? (
<button
type="button"
className="btn btn-sm btn-warning"
disabled={stopPending}
onClick={onStop}
>
Stop HTTP
</button>
) : (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={startPending}
onClick={onStart}
>
Start HTTP
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import { formatBytes, formatCpu } from "../lib/format.js";
export function ProcessResourcesCard({ children }) {
const totals = children?.totals ?? { memory: 0, cpu: 0 };
return (
<div className="card bg-base-100 border border-base-300 shadow-sm">
<div className="card-body gap-4">
<h2 className="card-title text-lg">Resources</h2>
<div className="space-y-1 text-sm tabular-nums">
<p>{formatBytes(totals.memory)} memory total</p>
<p>{formatCpu(totals.cpu)} CPU total</p>
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { LuCircleCheck, LuCirclePause } from "react-icons/lu";
export function QueueCard({
paused = false,
workersOnline = 0,
queuedJobs = 0,
activeJobs = 0,
onPause,
onResume,
pausePending = false,
resumePending = false,
}) {
return (
<div className="card bg-base-100 border border-base-300 shadow-sm">
<div className="card-body gap-4">
<h2 className="card-title text-lg">Queue</h2>
<div className="flex items-center gap-2">
{paused ? (
<LuCirclePause className="size-6 text-warning" aria-hidden />
) : (
<LuCircleCheck className="size-6 text-success" aria-hidden />
)}
<span>
{paused
? "Paused"
: `Running (${workersOnline} worker${workersOnline === 1 ? "" : "s"})`}
</span>
</div>
<div className="space-y-1 text-sm tabular-nums">
<p>
{activeJobs} running job{activeJobs === 1 ? "" : "s"}
</p>
<p>
{queuedJobs} queued job{queuedJobs === 1 ? "" : "s"}
</p>
</div>
<div className="card-actions justify-start px-0">
{paused ? (
<button
type="button"
className="btn btn-sm btn-primary"
disabled={resumePending}
onClick={onResume}
>
Resume workers
</button>
) : (
<button
type="button"
className="btn btn-sm btn-warning"
disabled={pausePending}
onClick={onPause}
>
Pause workers
</button>
)}
</div>
</div>
</div>
);
}