import { useState } from 'react'; import { Hammer, Loader2, RotateCw } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; /** * The two ways to load a change without leaving the browser. * * They look like a pair and they are not, which is the whole content of this screen. One is free; the * other costs you every open connection. Presenting them identically would invite the reflex of pressing * the second when the first would have done. */ /** * The message a route meant to send, out of what the client threw. * * `useClient` throws with the raw response BODY as its message, and these routes answer with JSON. So the * useful sentence is one `JSON.parse` inside a string nobody should be asked to read. */ export function errorMessage(err: unknown, fallback: string): string { const raw = err instanceof Error ? err.message : ''; if (!raw) return fallback; try { const parsed = JSON.parse(raw) as { error?: unknown }; if (typeof parsed.error === 'string' && parsed.error) return parsed.error; } catch { // Not JSON — a proxy error page, or a plain string. Whatever it is, it is what the server said. } return raw; } export const MaintenanceSection = () => { const client = useClient(); const [rebuilding, setRebuilding] = useState(false); const [restarting, setRestarting] = useState(false); const [lastBuild, setLastBuild] = useState(null); const rebuild = async () => { setRebuilding(true); try { const res = await client.post<{ ok: boolean; ms: number; outputs: number; error?: string }>( '/server-settings/rebuild-frontend', {}, ); if (!res.ok) throw new Error(res.error ?? 'Build failed'); setLastBuild(`${(res.ms / 1000).toFixed(1)}s · ${res.outputs} files`); // Nothing reloads itself: the bundle on disk changed, this page is still the old one. Saying so is // the difference between "it worked" and "why does my change not show". toast.success('Frontend rebuilt — refresh this page to see it'); } catch (err) { // The client throws on any status >= 400 with the raw response body as its message, so a build // failure arrives here as the JSON the route sent. Unwrapped, or the toast shows a reader // `{"ok":false,"error":"…"}` and makes them dig the real message out of punctuation. toast.error(errorMessage(err, 'Rebuild failed')); } finally { setRebuilding(false); } }; const restart = async () => { setRestarting(true); try { await client.post('/server-settings/restart', {}); toast.success('Restarting — the page will reconnect on its own'); } catch (err) { /** * Two failures arrive here and they are opposites. * * A REFUSAL — the route answering 400 because PM2 does not manage this process — is a real error * with a status, and nothing is restarting. A DROPPED CONNECTION has no status, and is the expected * shape of success: the process answering the request is the one being replaced, so it often dies * before the response lands. * * The first version treated both as success. It would have told you "restarting" while refusing to * restart, and left the button disabled forever — which is precisely the case this screen exists to * make legible. */ const status = (err as { status?: number }).status; if (typeof status === 'number') { toast.error(errorMessage(err, 'Restart refused')); setRestarting(false); return; } toast.success('Restarting — the page will reconnect on its own'); } // Left disabled on the success path, deliberately: the button must not become pressable again while // the server is coming back. }; return (
Rebuild the frontend

Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session — refresh the page when it finishes.

{lastBuild &&

Last build: {lastBuild}

}
Restart the server

The only way to load changed server code — Bun caches modules for the life of the process, so rebuilding cannot see an edit to the backend.

Costs every open connection: websockets drop and in-memory chat sessions are lost. Background jobs survive — they run outside this process’s tree.

); };