mirror of
https://github.com/bybrooklyn/alchemist.git
synced 2026-04-18 09:53:33 -04:00
Add engine mode controls and draining state
This commit is contained in:
@@ -75,7 +75,7 @@ export default function Dashboard() {
|
||||
const [jobs, setJobs] = useState<Job[]>([]);
|
||||
const [jobsLoading, setJobsLoading] = useState(true);
|
||||
const [bundle, setBundle] = useState<SettingsBundleResponse | null>(null);
|
||||
const [engineStatus, setEngineStatus] = useState<"paused" | "running">("paused");
|
||||
const [engineStatus, setEngineStatus] = useState<"paused" | "running" | "draining">("paused");
|
||||
const { stats: sharedStats, error: statsError } = useSharedStats();
|
||||
const stats = sharedStats ?? DEFAULT_STATS;
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function Dashboard() {
|
||||
// Ignore setup redirect lookup failures here; dashboard data fetches handle their own UX.
|
||||
}
|
||||
})();
|
||||
void apiJson<{ status: "paused" | "running" }>("/api/engine/status")
|
||||
void apiJson<{ status: "paused" | "running" | "draining" }>("/api/engine/status")
|
||||
.then((data) => setEngineStatus(data.status))
|
||||
.catch(() => undefined);
|
||||
|
||||
|
||||
@@ -1,31 +1,122 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Info, LogOut, Pause, Play } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { Info, LogOut, Pause, Play, Square, X } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import AboutDialog from "./AboutDialog";
|
||||
import { apiAction, apiJson } from "../lib/api";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [engineStatus, setEngineStatus] = useState<"paused" | "running">("paused");
|
||||
const [engineLoading, setEngineLoading] = useState(false);
|
||||
interface EngineStatus {
|
||||
status: "running" | "paused" | "draining";
|
||||
manual_paused: boolean;
|
||||
scheduler_paused: boolean;
|
||||
draining: boolean;
|
||||
mode: "background" | "balanced" | "throughput";
|
||||
concurrent_limit: number;
|
||||
is_manual_override: boolean;
|
||||
}
|
||||
|
||||
const showEngineControl =
|
||||
typeof window !== "undefined" && window.location.pathname === "/";
|
||||
interface EngineMode {
|
||||
mode: "background" | "balanced" | "throughput";
|
||||
is_manual_override: boolean;
|
||||
concurrent_limit: number;
|
||||
cpu_count: number;
|
||||
computed_limits: {
|
||||
background: number;
|
||||
balanced: number;
|
||||
throughput: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function HeaderActions() {
|
||||
const [engineStatus, setEngineStatus] = useState<EngineStatus | null>(null);
|
||||
const [engineMode, setEngineMode] = useState<EngineMode | null>(null);
|
||||
const [engineLoading, setEngineLoading] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [manualJobs, setManualJobs] = useState<number>(1);
|
||||
const [manualThreads, setManualThreads] = useState<number>(0);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
|
||||
const statusConfig = {
|
||||
running: {
|
||||
dot: "bg-emerald-500 animate-pulse",
|
||||
label: "Running",
|
||||
labelColor: "text-emerald-500",
|
||||
},
|
||||
paused: {
|
||||
dot: "bg-amber-500",
|
||||
label: "Paused",
|
||||
labelColor: "text-amber-500",
|
||||
},
|
||||
draining: {
|
||||
dot: "bg-blue-400",
|
||||
label: "Draining",
|
||||
labelColor: "text-blue-400",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const refreshEngineStatus = async () => {
|
||||
const data = await apiJson<EngineStatus>("/api/engine/status");
|
||||
setEngineStatus(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
const refreshEngineMode = async () => {
|
||||
const data = await apiJson<EngineMode>("/api/engine/mode");
|
||||
setEngineMode(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEngineControl) return;
|
||||
void apiJson<{ status: "paused" | "running" }>("/api/engine/status")
|
||||
.then((data) => setEngineStatus(data.status))
|
||||
.catch(() => undefined);
|
||||
}, [showEngineControl]);
|
||||
let cancelled = false;
|
||||
|
||||
const toggleEngine = async () => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [status, mode] = await Promise.all([
|
||||
apiJson<EngineStatus>("/api/engine/status"),
|
||||
apiJson<EngineMode>("/api/engine/mode"),
|
||||
]);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEngineStatus(status);
|
||||
setEngineMode(mode);
|
||||
setManualJobs(mode.concurrent_limit);
|
||||
setManualThreads(0);
|
||||
} catch {
|
||||
// Ignore transient header control failures.
|
||||
}
|
||||
};
|
||||
|
||||
const pollStatus = async () => {
|
||||
try {
|
||||
const status = await apiJson<EngineStatus>("/api/engine/status");
|
||||
if (!cancelled) {
|
||||
setEngineStatus(status);
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient polling failures.
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
const intervalId = window.setInterval(() => {
|
||||
void pollStatus();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStart = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
const nextAction = engineStatus === "paused" ? "resume" : "pause";
|
||||
await apiAction(`/api/engine/${nextAction}`, { method: "POST" });
|
||||
setEngineStatus(engineStatus === "paused" ? "running" : "paused");
|
||||
await apiAction("/api/engine/resume", { method: "POST" });
|
||||
await refreshEngineStatus();
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
@@ -37,6 +128,107 @@ export default function HeaderActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
await apiAction("/api/engine/pause", { method: "POST" });
|
||||
await refreshEngineStatus();
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
title: "Engine",
|
||||
message: "Failed to update engine state.",
|
||||
});
|
||||
} finally {
|
||||
setEngineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
await apiAction("/api/engine/drain", { method: "POST" });
|
||||
await refreshEngineStatus();
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
title: "Engine",
|
||||
message: "Failed to update engine state.",
|
||||
});
|
||||
} finally {
|
||||
setEngineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelStop = async () => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
await apiAction("/api/engine/stop-drain", { method: "POST" });
|
||||
await refreshEngineStatus();
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
title: "Engine",
|
||||
message: "Failed to update engine state.",
|
||||
});
|
||||
} finally {
|
||||
setEngineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = async (mode: EngineStatus["mode"]) => {
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
await apiAction("/api/engine/mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
const [status, nextMode] = await Promise.all([
|
||||
refreshEngineStatus(),
|
||||
refreshEngineMode(),
|
||||
]);
|
||||
setManualJobs(nextMode.concurrent_limit);
|
||||
setManualThreads(0);
|
||||
setEngineStatus(status);
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
title: "Engine",
|
||||
message: "Failed to update engine mode.",
|
||||
});
|
||||
} finally {
|
||||
setEngineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyAdvanced = async () => {
|
||||
const currentMode = engineStatus?.mode ?? engineMode?.mode;
|
||||
if (!currentMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEngineLoading(true);
|
||||
try {
|
||||
await apiAction("/api/engine/mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
mode: currentMode,
|
||||
concurrent_jobs_override: manualJobs,
|
||||
threads_override: manualThreads,
|
||||
}),
|
||||
});
|
||||
await Promise.all([refreshEngineStatus(), refreshEngineMode()]);
|
||||
} catch {
|
||||
showToast({
|
||||
kind: "error",
|
||||
title: "Engine",
|
||||
message: "Failed to apply advanced engine settings.",
|
||||
});
|
||||
} finally {
|
||||
setEngineLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await apiAction("/api/auth/logout", { method: "POST" });
|
||||
@@ -50,37 +242,205 @@ export default function HeaderActions() {
|
||||
}
|
||||
};
|
||||
|
||||
const status = engineStatus?.status ?? "paused";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{showEngineControl && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5 rounded border border-helios-line/20 bg-helios-surface-soft/60 px-2 py-1">
|
||||
<div className={`h-1.5 w-1.5 rounded-full ${statusConfig[status].dot}`} />
|
||||
<span className={`text-xs font-medium ${statusConfig[status].labelColor}`}>
|
||||
{statusConfig[status].label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(status === "paused" || status === "draining") && (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={engineLoading}
|
||||
className="flex items-center gap-1.5 rounded bg-helios-solar px-3 py-1.5 text-xs font-semibold text-helios-main transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Play size={13} />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === "running" && (
|
||||
<button
|
||||
onClick={handlePause}
|
||||
disabled={engineLoading}
|
||||
className="flex items-center gap-1.5 rounded border border-helios-line/20 px-3 py-1.5 text-xs font-medium text-helios-slate transition-colors hover:bg-helios-surface-soft hover:text-helios-ink disabled:opacity-50"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === "running" && (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={engineLoading}
|
||||
className="flex items-center gap-1.5 rounded border border-helios-line/20 px-3 py-1.5 text-xs font-medium text-helios-slate transition-colors hover:bg-helios-surface-soft hover:text-helios-ink disabled:opacity-50"
|
||||
>
|
||||
<Square size={13} />
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status === "draining" && (
|
||||
<button
|
||||
onClick={handleCancelStop}
|
||||
disabled={engineLoading}
|
||||
className="flex items-center gap-1.5 rounded border border-blue-400/30 px-3 py-1.5 text-xs font-medium text-blue-400 transition-colors hover:bg-blue-400/10 disabled:opacity-50"
|
||||
>
|
||||
<X size={13} />
|
||||
Cancel Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{(["background", "balanced", "throughput"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => void handleModeChange(m)}
|
||||
disabled={engineLoading}
|
||||
className={clsx(
|
||||
"px-2.5 py-1 rounded text-[11px] font-medium capitalize transition-colors disabled:opacity-50",
|
||||
engineStatus?.mode === m
|
||||
? "bg-helios-solar/15 text-helios-solar border border-helios-solar/30"
|
||||
: "text-helios-slate/70 hover:text-helios-slate border border-transparent hover:border-helios-line/20"
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
{engineStatus?.is_manual_override && (
|
||||
<span className="ml-1 text-[10px] italic text-helios-slate/50">
|
||||
manual
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{engineStatus?.scheduler_paused && !engineStatus.manual_paused && (
|
||||
<div className="text-[10px] text-helios-slate/50">Paused by schedule</div>
|
||||
)}
|
||||
|
||||
<details onToggle={(e) => setShowAdvanced((e.target as HTMLDetailsElement).open)}>
|
||||
<summary className="list-none w-fit cursor-pointer select-none text-[10px] text-helios-slate/50 hover:text-helios-slate/80">
|
||||
{showAdvanced ? "▾" : "▸"} Advanced
|
||||
</summary>
|
||||
<div className="mt-2 min-w-[220px] space-y-2 rounded-md border border-helios-line/20 bg-helios-surface-soft/40 p-3">
|
||||
{engineMode && (
|
||||
<div className="space-y-1 text-[10px] text-helios-slate/60">
|
||||
<div>Auto limits at current mode:</div>
|
||||
<div className="pl-2 font-mono">
|
||||
background →{engineMode.computed_limits.background} job
|
||||
<br />
|
||||
balanced →{engineMode.computed_limits.balanced} jobs
|
||||
<br />
|
||||
throughput →{engineMode.computed_limits.throughput} jobs
|
||||
</div>
|
||||
<div className="text-[10px] text-helios-slate/40">
|
||||
Based on {engineMode.cpu_count} logical CPUs
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-helios-slate">
|
||||
Concurrent jobs
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={32}
|
||||
value={manualJobs}
|
||||
onChange={(e) =>
|
||||
setManualJobs(
|
||||
Math.max(1, parseInt(e.target.value, 10) || 1)
|
||||
)
|
||||
}
|
||||
className="w-16 rounded border border-helios-line/20 bg-helios-surface px-2 py-1 text-xs text-helios-ink outline-none focus:border-helios-solar"
|
||||
/>
|
||||
<span className="text-[10px] text-helios-slate/60">
|
||||
(overrides auto)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium text-helios-slate">
|
||||
CPU threads per job
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={64}
|
||||
value={manualThreads}
|
||||
onChange={(e) =>
|
||||
setManualThreads(
|
||||
Math.max(0, parseInt(e.target.value, 10) || 0)
|
||||
)
|
||||
}
|
||||
className="w-16 rounded border border-helios-line/20 bg-helios-surface px-2 py-1 text-xs text-helios-ink outline-none focus:border-helios-solar"
|
||||
/>
|
||||
<span className="text-[10px] text-helios-slate/60">
|
||||
0 = auto
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={handleApplyAdvanced}
|
||||
disabled={engineLoading}
|
||||
className="flex-1 rounded bg-helios-solar px-3 py-1.5 text-xs font-semibold text-helios-main transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (engineStatus && engineMode) {
|
||||
void handleModeChange(engineStatus.mode);
|
||||
setManualJobs(
|
||||
engineMode.computed_limits[engineStatus.mode]
|
||||
);
|
||||
setManualThreads(0);
|
||||
}
|
||||
}}
|
||||
disabled={engineLoading}
|
||||
className="rounded border border-helios-line/20 px-3 py-1.5 text-xs font-medium text-helios-slate transition-colors hover:bg-helios-surface-soft disabled:opacity-50"
|
||||
>
|
||||
Reset to auto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 border-l border-helios-line/20 pl-3">
|
||||
<motion.button
|
||||
onClick={() => void toggleEngine()}
|
||||
onClick={() => setShowAbout(true)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
disabled={engineLoading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
|
||||
>
|
||||
{engineStatus === "paused" ? <Play size={16} /> : <Pause size={16} />}
|
||||
<span>{engineStatus === "paused" ? "Start" : "Pause"}</span>
|
||||
<Info size={16} />
|
||||
<span>About</span>
|
||||
</motion.button>
|
||||
)}
|
||||
<motion.button
|
||||
onClick={() => setShowAbout(true)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-helios-slate hover:bg-helios-surface-soft hover:text-helios-ink transition-colors"
|
||||
>
|
||||
<Info size={16} />
|
||||
<span>About</span>
|
||||
</motion.button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-red-500/80 hover:bg-red-500/10 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold text-red-500/80 hover:bg-red-500/10 hover:text-red-600 transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AboutDialog isOpen={showAbout} onClose={() => setShowAbout(false)} />
|
||||
|
||||
Reference in New Issue
Block a user