mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Add guarded dev restart handling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
dd44f69e2b
commit
8fc399f511
13 changed files with 758 additions and 43 deletions
89
ui/src/components/DevRestartBanner.tsx
Normal file
89
ui/src/components/DevRestartBanner.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react";
|
||||
import type { DevServerHealthStatus } from "../api/health";
|
||||
|
||||
function formatRelativeTimestamp(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (Number.isNaN(timestamp)) return null;
|
||||
|
||||
const deltaMs = Date.now() - timestamp;
|
||||
if (deltaMs < 60_000) return "just now";
|
||||
const deltaMinutes = Math.round(deltaMs / 60_000);
|
||||
if (deltaMinutes < 60) return `${deltaMinutes}m ago`;
|
||||
const deltaHours = Math.round(deltaMinutes / 60);
|
||||
if (deltaHours < 24) return `${deltaHours}h ago`;
|
||||
const deltaDays = Math.round(deltaHours / 24);
|
||||
return `${deltaDays}d ago`;
|
||||
}
|
||||
|
||||
function describeReason(devServer: DevServerHealthStatus): string {
|
||||
if (devServer.reason === "backend_changes_and_pending_migrations") {
|
||||
return "backend files changed and migrations are pending";
|
||||
}
|
||||
if (devServer.reason === "pending_migrations") {
|
||||
return "pending migrations need a fresh boot";
|
||||
}
|
||||
return "backend files changed since this server booted";
|
||||
}
|
||||
|
||||
export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) {
|
||||
if (!devServer?.enabled || !devServer.restartRequired) return null;
|
||||
|
||||
const changedAt = formatRelativeTimestamp(devServer.lastChangedAt);
|
||||
const sample = devServer.changedPathsSample.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
|
||||
<div className="flex flex-col gap-3 px-3 py-2.5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.18em]">
|
||||
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>Restart Required</span>
|
||||
{devServer.autoRestartEnabled ? (
|
||||
<span className="rounded-full bg-amber-900/10 px-2 py-0.5 text-[10px] tracking-[0.14em] dark:bg-amber-100/10">
|
||||
Auto-Restart On
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{describeReason(devServer)}
|
||||
{changedAt ? ` · updated ${changedAt}` : ""}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-amber-900/80 dark:text-amber-100/75">
|
||||
{sample.length > 0 ? (
|
||||
<span>
|
||||
Changed: {sample.join(", ")}
|
||||
{devServer.changedPathCount > sample.length ? ` +${devServer.changedPathCount - sample.length} more` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{devServer.pendingMigrations.length > 0 ? (
|
||||
<span>
|
||||
Pending migrations: {devServer.pendingMigrations.slice(0, 2).join(", ")}
|
||||
{devServer.pendingMigrations.length > 2 ? ` +${devServer.pendingMigrations.length - 2} more` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
|
||||
{devServer.waitingForIdle ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
|
||||
<TimerReset className="h-3.5 w-3.5" />
|
||||
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
|
||||
</div>
|
||||
) : devServer.autoRestartEnabled ? (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>Auto-restart will trigger when the instance is idle</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
<span>Restart `pnpm dev:once` after the active work is safe to interrupt</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
|
|||
import { ToastViewport } from "./ToastViewport";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
|
|
@ -78,6 +79,11 @@ export function Layout() {
|
|||
queryKey: queryKeys.health,
|
||||
queryFn: () => healthApi.get(),
|
||||
retry: false,
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
|
||||
return data?.devServer?.enabled ? 2000 : false;
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -266,6 +272,7 @@ export function Layout() {
|
|||
Skip to Main Content
|
||||
</a>
|
||||
<WorktreeBanner />
|
||||
<DevRestartBanner devServer={health?.devServer} />
|
||||
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
|
||||
{isMobile && sidebarOpen && (
|
||||
<button
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue