diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/MaintenanceSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/MaintenanceSection.tsx new file mode 100644 index 00000000..5ac04163 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/MaintenanceSection.tsx @@ -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(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 ( +
+
+
+
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 are their own PM2 processes. +

+
+ +
+
+ ); +}; diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index c7087c13..c8ae423c 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -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: , + }, + ], + }, { label: 'Email', icon: Mail, diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index aa09fd18..ff926c5d 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -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()); }); diff --git a/src/servers/app-store/pm2.ts b/src/servers/app-store/pm2.ts index 07b8202e..c8f8136f 100644 --- a/src/servers/app-store/pm2.ts +++ b/src/servers/app-store/pm2.ts @@ -66,6 +66,22 @@ export async function startProcess(name: string, cwd: string): Promise { + 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. *