import { useState } from 'react'; import { Download, Hammer, Loader2, RotateCw } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { useClient } from 'hooks/useClient'; import { useTranslation } from '@/lib/i18n'; /** * 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 { t } = useTranslation(); const client = useClient(); const [pulling, setPulling] = useState(false); const [pulled, setPulled] = useState<{ from: string; to: string; commits: string[]; needs: string[] } | null>(null); const [rebuilding, setRebuilding] = useState(false); const [restarting, setRestarting] = useState(false); const [lastBuild, setLastBuild] = useState(null); /** * Pull, and then say what the pull implies rather than acting on it. * * Deliberately does not chain into a rebuild or a restart. A pull that touched `bun.lock` means an * install, which the frozen-lockfile rule exists to keep deliberate; one that touched a schema means * `db:push` against a live database. Neither is a thing a button should decide, so `needs` is reported * and the buttons below stay where they are. */ const pull = async () => { setPulling(true); try { const res = await client.post<{ ok: boolean; updated: boolean; from?: string; to?: string; commits: string[]; needs: string[]; }>('/server-settings/pull', {}); if (!res.updated) { setPulled(null); toast.success(t('settings.server.maintenance.upToDate')); return; } setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs }); toast.success(t('settings.server.maintenance.pulled', { count: res.commits.length })); } catch (err) { toast.error(errorMessage(err, 'Pull failed')); } finally { setPulling(false); } }; 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(t('settings.server.maintenance.rebuilt')); } 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(t('settings.server.maintenance.restarting')); } 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(t('settings.server.maintenance.restarting')); } // Left disabled on the success path, deliberately: the button must not become pressable again while // the server is coming back. }; return (
{t('settings.server.maintenance.updateTitle')}

Pulls this server’s repository. Refuses if anything is uncommitted or the checkout has diverged — it will never discard local work.

{t('settings.server.maintenance.updateHint')}

{pulled && (

{pulled.from} → {pulled.to}

    {pulled.commits.slice(0, 5).map((line) => (
  • {line}
  • ))} {pulled.commits.length > 5 && (
  • +{pulled.commits.length - 5} more
  • )}
{pulled.needs.length > 0 && (

Needs: {pulled.needs.join(', ')}

)}
)}
{t('settings.server.maintenance.rebuild')}

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}

}
{t('settings.server.maintenance.restart')}

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.

); };