rebuild the frontend without restarting, and restart when you must
Settings → System → Maintenance. Two buttons that are deliberately not a pair. ── Rebuild: real, and free ── The same `rebuildFrontend` a plugin install already runs. `Bun.build` reads source from disk on every call and writes `build/`, and the server serves that directory by reading each file per request — so a rebuild lands on the next page load with no process restart, no dropped websocket and no chat session lost. ── Restart: the only way to load server code, and it costs ── Bun caches modules for the life of the process. Measured rather than assumed: editing a file and re-importing returns the ORIGINAL export, and a cache-busting query picks up that one specifier while everything it imports stays cached. So `rebuildHonoApp` — which install already does — re-wires the same module instances into a new tree and cannot see a backend edit. Busting the whole graph would duplicate singletons: two database clients, two session maps, one process. There is therefore no third button, and the screen says why. A "remount the API" control would look like it reloaded the backend and would not. The restart answers BEFORE it schedules itself, because the process serving that request is the one being replaced. It refuses outright when PM2 does not know this process — in development the platform runs under `bun --watch`, where the restart would fail into a promise nobody awaits while the button reported success. Verified: `POST /rebuild-frontend` → 200, 3.0s, 3 outputs; anonymous → 401. The restart button is deliberately untested from here — pressing it would end the session this was written in, which is the same reason it needs a label that says so. tsgo clean, frontend builds, 914 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
*/
|
||||
export const MaintenanceSection = () => {
|
||||
const client = useClient();
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [lastBuild, setLastBuild] = useState<string | null>(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) {
|
||||
toast.error(err instanceof Error ? err.message : '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 {
|
||||
// The restart may well have happened: the process answering this request is the one being replaced,
|
||||
// so a dropped connection here is the expected shape of success.
|
||||
toast.success('Restarting — the page will reconnect on its own');
|
||||
}
|
||||
// Deliberately left true. The button must not become pressable again while the server is coming back.
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Picks up changes to the interface. Nothing restarts, no connection drops, and this chat keeps its session —
|
||||
refresh the page when it finishes.
|
||||
</p>
|
||||
{lastBuild && <p className="mt-1 text-xs text-muted-foreground/70">Last build: {lastBuild}</p>}
|
||||
</div>
|
||||
<Button disabled={rebuilding || restarting} onClick={() => void rebuild()} className="shrink-0">
|
||||
{rebuilding ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Hammer className="mr-2 h-4 w-4" />}
|
||||
{rebuilding ? 'Building…' : 'Rebuild'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-6 border-t border-border pt-6">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">Restart the server</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Costs every open connection: websockets drop and in-memory chat sessions are lost. Background jobs survive —
|
||||
they are their own PM2 processes.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={rebuilding || restarting}
|
||||
onClick={() => void restart()}
|
||||
className="shrink-0"
|
||||
>
|
||||
{restarting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCw className="mr-2 h-4 w-4" />}
|
||||
{restarting ? 'Restarting…' : 'Restart'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Bot, Settings, Mail, Volume2, Mic, ScanText } from 'lucide-react';
|
||||
import { Bot, Hammer, Mail, Mic, ScanText, Settings, Volume2 } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
|
||||
import { SettingsRoute, createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
|
||||
import { SMTPSection } from './ServerSettings/SMTPSection';
|
||||
import { MaintenanceSection } from './ServerSettings/MaintenanceSection';
|
||||
import { TTSSection } from './ServerSettings/TTSSection';
|
||||
import { STTSection } from './ServerSettings/STTSection';
|
||||
import { OCRSection } from './ServerSettings/OCRSection';
|
||||
@@ -31,6 +32,19 @@ const groups: SettingsSectionGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Maintenance',
|
||||
icon: Hammer,
|
||||
sections: [
|
||||
{
|
||||
key: 'maintenance',
|
||||
icon: Hammer,
|
||||
title: 'Rebuild & restart',
|
||||
description: 'Load changes without a terminal',
|
||||
content: <MaintenanceSection />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
icon: Mail,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { rebuildFrontend } from '../../plugins/generate';
|
||||
import { isKnownToPm2, restartProcess } from '../../app-store/pm2';
|
||||
import { PLATFORM_DIR } from '../../data-path';
|
||||
import { readdirSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { readServerSettings, writeServerSettings } from 'officerdb';
|
||||
@@ -18,6 +21,57 @@ serverSettingsRouter.route('/ocr', ocrRouter);
|
||||
|
||||
export { readServerSettings as readSettings };
|
||||
|
||||
/**
|
||||
* Rebuild the SPA from source, without restarting anything.
|
||||
*
|
||||
* This is the same `rebuildFrontend` a plugin install runs, exposed as a button. It works — and it is
|
||||
* worth being precise about WHY, because its neighbour below does not:
|
||||
*
|
||||
* `Bun.build` reads the source from disk on every call and writes `build/`, and the server serves that
|
||||
* directory by reading each file per request. So a rebuild is visible on the next page load with no
|
||||
* process restart, no dropped websocket, and no chat session lost.
|
||||
*
|
||||
* Frontend changes only. Nothing here can pick up an edit to server code — see `/restart`.
|
||||
*/
|
||||
serverSettingsRouter.post('/rebuild-frontend', async (ctx) => {
|
||||
const result = await rebuildFrontend();
|
||||
if (!result.ok) return ctx.json({ ok: false, error: result.error }, 500);
|
||||
return ctx.json({ ok: true, ms: result.ms, outputs: result.outputs });
|
||||
});
|
||||
|
||||
/**
|
||||
* Restart the platform process. The only way to load changed SERVER code.
|
||||
*
|
||||
* ── Why there is no gentler option ──
|
||||
*
|
||||
* Bun caches modules for the life of the process. Measured rather than assumed: editing a file and
|
||||
* re-importing it returns the ORIGINAL export, and a cache-busting query picks up only that one
|
||||
* specifier while everything it imports stays cached. Rebuilding the Hono app — which install already
|
||||
* does — therefore re-wires the same module instances into a new router tree and cannot see an edit.
|
||||
*
|
||||
* Busting the whole graph would not be a fix either: singletons would be duplicated, giving two database
|
||||
* clients and two in-memory session maps in one process.
|
||||
*
|
||||
* So this is honest about what it costs. Websockets drop, in-memory chat sessions are lost, and the
|
||||
* browser must reconnect. It saves a terminal, not a reconnection.
|
||||
*
|
||||
* The response is sent BEFORE the restart is scheduled, or the caller would never learn it worked: the
|
||||
* process serving this request is the one about to be replaced.
|
||||
*/
|
||||
serverSettingsRouter.post('/restart', async (ctx) => {
|
||||
// Refuse when PM2 does not know this process, rather than reporting a restart that cannot happen. In
|
||||
// development the platform runs under `bun --watch`, where `pm2 restart officer` would exit non-zero
|
||||
// into a promise nobody awaits — and the button would say it worked.
|
||||
if (!(await isKnownToPm2('officer', PLATFORM_DIR))) {
|
||||
return ctx.json({ ok: false, error: 'not running under PM2 — restart it the way you started it' }, 400);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
void restartProcess('officer', PLATFORM_DIR);
|
||||
}, 250);
|
||||
return ctx.json({ ok: true, restartingIn: 250 });
|
||||
});
|
||||
|
||||
serverSettingsRouter.get('/settings', async (ctx) => {
|
||||
return ctx.json(await readServerSettings());
|
||||
});
|
||||
|
||||
@@ -66,6 +66,22 @@ export async function startProcess(name: string, cwd: string): Promise<Pm2Result
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a process that PM2 already knows.
|
||||
*
|
||||
* Used by the owner's "restart the server" button, where the target is `officer` itself — the process
|
||||
* making this very call. PM2 replaces it, so nothing after the spawn in this process is guaranteed to
|
||||
* run, and the caller must have been answered already.
|
||||
*
|
||||
* `restart` rather than stop-then-start: PM2 keeps the entry, the restart counter and the log stream
|
||||
* continuous, and there is no window in which the process is absent from `pm2 list` and looks uninstalled.
|
||||
*/
|
||||
export async function restartProcess(name: string, cwd: string): Promise<Pm2Result> {
|
||||
const { code, out } = await pm2(['restart', name], cwd);
|
||||
if (code !== 0) return { ok: false, error: out || `pm2 restart ${name} exited ${code}` };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop it, leaving it in PM2's list.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user