feat(web): add Ops page and proxy control on :8600
Vite listens on 8500 and proxies /ops to the control plane. Admin Ops UI covers pause/resume, scale, drain/force restart, and restart-needed banner. Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
||||
import { OpsPage } from "./pages/OpsPage.jsx";
|
||||
|
||||
export function App() {
|
||||
const qc = useQueryClient();
|
||||
@@ -71,11 +72,13 @@ export function App() {
|
||||
<>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/secrets" element={<SecretsPage />} />
|
||||
<Route path="/ops" element={<OpsPage />} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Route path="/users" element={<Navigate to="/" replace />} />
|
||||
<Route path="/secrets" element={<Navigate to="/" replace />} />
|
||||
<Route path="/ops" element={<Navigate to="/" replace />} />
|
||||
</>
|
||||
)}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -5,11 +5,21 @@ export const api = axios.create({
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
/** Control-plane ops API (proxied to :8600 in `pnpm dev:pm2`). */
|
||||
export const opsApi = axios.create({
|
||||
baseURL: "/ops",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
const url = err.config?.url ?? "";
|
||||
const isAuthCall = url.includes("/auth/login") || url.includes("/auth/register") || url.includes("/auth/me") || url.includes("/auth/bootstrap");
|
||||
const isAuthCall =
|
||||
url.includes("/auth/login") ||
|
||||
url.includes("/auth/register") ||
|
||||
url.includes("/auth/me") ||
|
||||
url.includes("/auth/bootstrap");
|
||||
if (err.response?.status === 401 && !isAuthCall) {
|
||||
window.dispatchEvent(new Event("jerapah-flow:unauthorized"));
|
||||
}
|
||||
@@ -17,6 +27,16 @@ api.interceptors.response.use(
|
||||
},
|
||||
);
|
||||
|
||||
opsApi.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
window.dispatchEvent(new Event("jerapah-flow:unauthorized"));
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export function errorMessage(err, fallback = "request failed") {
|
||||
return err?.response?.data?.error ?? err?.message ?? fallback;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "./client.js";
|
||||
import { api, opsApi } from "./client.js";
|
||||
|
||||
export function useBootstrap() {
|
||||
return useQuery({
|
||||
@@ -450,3 +450,84 @@ export function useDeleteHttpAuth() {
|
||||
export async function fetchHttpAuthLiterals(name) {
|
||||
return (await api.get(`/http-auths/${encodeURIComponent(name)}/reveal`)).data;
|
||||
}
|
||||
|
||||
export function useOpsStatus(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["ops-status"],
|
||||
queryFn: async () => (await opsApi.get("/status")).data,
|
||||
enabled,
|
||||
retry: false,
|
||||
refetchInterval: enabled ? 3000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsPause() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await opsApi.post("/pause")).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsResume() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await opsApi.post("/resume")).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsReload() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await opsApi.post("/reload")).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||
qc.invalidateQueries({ queryKey: ["workflows"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsRestart() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ force = false } = {}) =>
|
||||
(await opsApi.post("/restart", { force })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsScale() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ workers, force = false }) =>
|
||||
(await opsApi.post("/scale", { workers, force })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsHttpStart() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await opsApi.post("/http/start")).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsHttpStop() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await opsApi.post("/http/stop")).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOpsBumpGeneration() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (reason) =>
|
||||
(await opsApi.post("/generation/bump", { reason })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NavLink, useNavigate } from "react-router-dom";
|
||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
LuActivity,
|
||||
LuCode,
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
LuLogOut,
|
||||
LuMenu,
|
||||
LuMoon,
|
||||
LuServer,
|
||||
LuShield,
|
||||
LuSun,
|
||||
LuTags,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { useLogout } from "../api/hooks.js";
|
||||
import { useLogout, useOpsStatus } from "../api/hooks.js";
|
||||
import { brandMark } from "../theme/brand.js";
|
||||
import { useTheme } from "../theme.jsx";
|
||||
|
||||
@@ -34,6 +35,8 @@ export function Layout({ user, children }) {
|
||||
const { theme, toggle } = useTheme();
|
||||
const logout = useLogout();
|
||||
const navigate = useNavigate();
|
||||
const ops = useOpsStatus(user.role === "admin");
|
||||
const restartNeeded = Boolean(ops.data?.desired?.restartNeeded);
|
||||
|
||||
const navItems = [
|
||||
...links,
|
||||
@@ -41,6 +44,7 @@ export function Layout({ user, children }) {
|
||||
? [
|
||||
{ to: "/secrets", label: "Secrets", icon: LuKey },
|
||||
{ to: "/users", label: "Users", icon: LuUsers },
|
||||
{ to: "/ops", label: "Ops", icon: LuServer },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
@@ -88,7 +92,24 @@ export function Layout({ user, children }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<main className="flex-1 p-4">{children}</main>
|
||||
<main className="flex-1 p-4">
|
||||
{restartNeeded ? (
|
||||
<div className="alert alert-warning mb-4 text-sm">
|
||||
<span>
|
||||
Config generation changed
|
||||
{ops.data?.desired?.restartReason
|
||||
? ` (${ops.data.desired.restartReason})`
|
||||
: ""}
|
||||
.{" "}
|
||||
<Link to="/ops" className="link font-medium">
|
||||
Drain restart from Ops
|
||||
</Link>{" "}
|
||||
to apply on HTTP + workers.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<div className="drawer-side z-20">
|
||||
<label htmlFor="nav-drawer" aria-label="Close menu" className="drawer-overlay" />
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useOpsBumpGeneration,
|
||||
useOpsHttpStart,
|
||||
useOpsHttpStop,
|
||||
useOpsPause,
|
||||
useOpsReload,
|
||||
useOpsRestart,
|
||||
useOpsResume,
|
||||
useOpsScale,
|
||||
useOpsStatus,
|
||||
} from "../api/hooks.js";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
|
||||
function Stat({ label, value }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs uppercase tracking-wide opacity-60">{label}</span>
|
||||
<span className="font-mono text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpsPage() {
|
||||
const status = useOpsStatus(true);
|
||||
const pause = useOpsPause();
|
||||
const resume = useOpsResume();
|
||||
const reload = useOpsReload();
|
||||
const restart = useOpsRestart();
|
||||
const scale = useOpsScale();
|
||||
const httpStart = useOpsHttpStart();
|
||||
const httpStop = useOpsHttpStop();
|
||||
const bump = useOpsBumpGeneration();
|
||||
const [workerCount, setWorkerCount] = useState("");
|
||||
const [msg, setMsg] = useState(null);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const unavailable =
|
||||
status.isError &&
|
||||
(status.error?.code === "ERR_NETWORK" ||
|
||||
status.error?.response?.status === 404 ||
|
||||
status.error?.message?.includes("Network"));
|
||||
|
||||
function run(label, mutateAsync, args) {
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
mutateAsync(args)
|
||||
.then((data) => {
|
||||
setMsg(`${label} ok`);
|
||||
return data;
|
||||
})
|
||||
.catch((e) => setErr(errorMessage(e, `${label} failed`)));
|
||||
}
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Ops</h1>
|
||||
<div className="alert alert-warning">
|
||||
<span>
|
||||
Control plane is not reachable on <code>/ops</code>. Use{" "}
|
||||
<code>pnpm dev:pm2</code> (Vite :8500, control :8600, HTTP :8700) to
|
||||
manage processes. Plain <code>pnpm dev</code> runs the monolith and
|
||||
has no control API.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = status.data;
|
||||
const desired = data?.desired;
|
||||
const queue = data?.queue;
|
||||
const children = data?.children;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Ops</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
Process control via PM2. Pause stops workers from opening jobs;
|
||||
cron/HTTP can still enqueue. Restart drains active jobs unless forced.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => status.refetch()}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{desired?.restartNeeded ? (
|
||||
<div className="alert alert-warning">
|
||||
<span>
|
||||
Restart needed (generation {desired.generation})
|
||||
{desired.restartReason ? `: ${desired.restartReason}` : ""}. Use
|
||||
Drain restart after install/config changes.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{msg ? (
|
||||
<div className="alert alert-success text-sm">
|
||||
<span>{msg}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{err ? (
|
||||
<div className="alert alert-error text-sm">
|
||||
<span>{err}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.isLoading && !data ? (
|
||||
<span className="loading loading-spinner" />
|
||||
) : (
|
||||
<>
|
||||
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="HTTP desired" value={desired?.http ?? "—"} />
|
||||
<Stat label="Workers desired" value={desired?.workers ?? "—"} />
|
||||
<Stat label="Generation" value={desired?.generation ?? "—"} />
|
||||
<Stat
|
||||
label="Queue"
|
||||
value={queue?.paused ? "paused" : "running"}
|
||||
/>
|
||||
<Stat
|
||||
label="Active jobs"
|
||||
value={queue?.counts?.active ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label="Waiting jobs"
|
||||
value={queue?.counts?.waiting ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label="HTTP online"
|
||||
value={children?.httpOnline ? "yes" : "no"}
|
||||
/>
|
||||
<Stat
|
||||
label="Workers online"
|
||||
value={children?.workerOnlineCount ?? "—"}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-medium">Queue</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${queue?.paused ? "btn-disabled" : ""}`}
|
||||
disabled={pause.isPending || Boolean(queue?.paused)}
|
||||
onClick={() => run("Pause", pause.mutateAsync)}
|
||||
>
|
||||
Pause workers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm ${!queue?.paused ? "btn-disabled" : ""}`}
|
||||
disabled={resume.isPending || !queue?.paused}
|
||||
onClick={() => run("Resume", resume.mutateAsync)}
|
||||
>
|
||||
Resume workers
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={reload.isPending}
|
||||
onClick={() => run("Reload", reload.mutateAsync)}
|
||||
>
|
||||
Reload workflows
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-medium">HTTP process</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={httpStart.isPending}
|
||||
onClick={() => run("HTTP start", httpStart.mutateAsync)}
|
||||
>
|
||||
Start HTTP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-warning"
|
||||
disabled={httpStop.isPending}
|
||||
onClick={() => run("HTTP stop", httpStop.mutateAsync)}
|
||||
>
|
||||
Stop HTTP
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-medium">Workers</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={32}
|
||||
className="input input-bordered input-sm w-24"
|
||||
placeholder={String(desired?.workers ?? 1)}
|
||||
value={workerCount}
|
||||
onChange={(e) => setWorkerCount(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={scale.isPending || workerCount === ""}
|
||||
onClick={() =>
|
||||
run("Scale", scale.mutateAsync, {
|
||||
workers: Number(workerCount),
|
||||
force: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Scale (drain)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-warning"
|
||||
disabled={scale.isPending || workerCount === ""}
|
||||
onClick={() =>
|
||||
run("Scale force", scale.mutateAsync, {
|
||||
workers: Number(workerCount),
|
||||
force: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Scale (force)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-medium">Restart HTTP + workers</h2>
|
||||
<p className="text-sm opacity-70">
|
||||
Pauses the queue, waits until active jobs are 0, migrates DB, then
|
||||
recreates PM2 processes. Force skips the wait (may interrupt runs).
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={restart.isPending}
|
||||
onClick={() => run("Restart", restart.mutateAsync, { force: false })}
|
||||
>
|
||||
Drain restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-error"
|
||||
disabled={restart.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm(
|
||||
"Force restart will interrupt active jobs. Continue?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
run("Force restart", restart.mutateAsync, { force: true });
|
||||
}}
|
||||
>
|
||||
Force restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
disabled={bump.isPending}
|
||||
onClick={() =>
|
||||
run("Bump generation", bump.mutateAsync, "manual test flag")
|
||||
}
|
||||
>
|
||||
Flag restart-needed
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-medium">Heartbeats</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>PID</th>
|
||||
<th>Generation</th>
|
||||
<th>Age (ms)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data?.heartbeats ?? []).map((h) => (
|
||||
<tr key={h.field}>
|
||||
<td>{h.role}</td>
|
||||
<td className="font-mono">{h.pid}</td>
|
||||
<td className="font-mono">{h.generation}</td>
|
||||
<td className="font-mono">
|
||||
{Math.max(0, Date.now() - h.ts)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!data?.heartbeats?.length ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="opacity-60">
|
||||
No live heartbeats yet
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-medium">PM2 children</h2>
|
||||
<pre className="bg-base-200 max-h-64 overflow-auto rounded-lg p-3 text-xs">
|
||||
{JSON.stringify(children ?? {}, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,10 +5,12 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
port: 8500,
|
||||
proxy: {
|
||||
"/api": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||
"/admin": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||
"/u": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||
"/ops": { target: "http://127.0.0.1:8600", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user