diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index fa227406..01daa38b 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -3,7 +3,13 @@ import * as errors from '../../custom-errors'; import { isSuperAdmin } from '../../super-admin'; import { mountPrefix } from '../../plugins/manifest'; import { snapshotPlugins } from '../../plugins/mount'; -import { installPlugin, pluginProcessStatus, setPluginRunning, uninstallPlugin } from '../../plugins/install'; +import { + installPlugin, + pluginProcessStatus, + setPluginRunning, + uninstallPlugin, + type PluginActionResult, +} from '../../plugins/install'; // /api/plugins — what is on this machine, what is installed, and the four verbs that change it. // @@ -90,3 +96,70 @@ pluginsRouter.post('/:appName/disable', async (ctx) => { const result = await setPluginRunning(ctx.req.param('appName'), false); return ctx.json(result, result.ok ? 200 : 400); }); + +// ── The same four verbs, streamed ── +// +// An install writes an ecosystem entry, starts a process and rebuilds the router. Collecting all of that +// and answering once means a spinner that stops, with no way to tell "started the sidecar" from "could +// not". Streaming each step as it completes turns the same work into something you can watch, and — more +// usefully — leaves the log on screen naming the step that failed. +// +// POST rather than GET, so `EventSource` cannot be used: it sends no `Authorization` header, and these +// routes are owner-only. The client reads the body and parses frames itself, which is exactly what +// `useCompanionLogStream` already does for the headscale container logs. + +const VERBS = { + install: (appName: string, onStep: (s: string) => Promise) => installPlugin(appName, onStep), + uninstall: (appName: string, onStep: (s: string) => Promise) => uninstallPlugin(appName, onStep), + enable: (appName: string, onStep: (s: string) => Promise) => setPluginRunning(appName, true, onStep), + disable: (appName: string, onStep: (s: string) => Promise) => setPluginRunning(appName, false, onStep), +} as const; + +type Verb = keyof typeof VERBS; +const isVerb = (v: string): v is Verb => v in VERBS; + +pluginsRouter.post('/:appName/:verb/stream', async (ctx) => { + const appName = ctx.req.param('appName'); + const verb = ctx.req.param('verb'); + if (!isVerb(verb)) throw errors.NOT_FOUND(`Unknown action: ${verb}`); + + const encoder = new TextEncoder(); + const frame = (event: string, data: unknown) => encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + + const stream = new ReadableStream({ + async start(controller) { + const send = (event: string, data: unknown) => { + // The client may have navigated away mid-install. The work continues — it is the server's job + // now — but writing to a closed stream would throw and abort it halfway. + try { + controller.enqueue(frame(event, data)); + } catch { + /* client gone */ + } + }; + + let result: PluginActionResult; + try { + result = await VERBS[verb](appName, async (step) => send('step', { step })); + } catch (err) { + result = { ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) }; + } + send('done', result); + try { + controller.close(); + } catch { + /* already closed */ + } + }, + }); + + return new Response(stream, { + headers: { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + // Through nginx as well as our own proxy chain: without it a buffering hop holds every frame until + // the response ends, which is precisely the behaviour this endpoint exists to avoid. + 'x-accel-buffering': 'no', + }, + }); +}); diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index fd5cb8ae..bef88e3b 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -32,6 +32,23 @@ import type { DiscoveredPlugin } from './manifest'; // delete a plugin's data as a side effect of stopping it — exactly the thing this file refuses to do. // Offscale does not need it yet (`headscale_servers` already ships in the platform schema). +/** + * Reported as each step completes, for the streaming endpoint. + * + * The runner does not know or care whether anyone is listening — it calls this and carries on, so the + * non-streaming path is the same code with no callback rather than a second implementation. + */ +export type OnStep = (step: string) => void | Promise; + +/** + * A beat between steps, so the log reads rather than blinks. + * + * Cosmetic, and worth being honest about: `pm2 start` genuinely takes a moment, but writing a row and + * rebuilding the router do not, and four lines arriving in the same frame look like a stall followed by a + * jump. This is small enough not to matter to a script and long enough for a person to follow. + */ +const STEP_BEAT_MS = 220; + export type PluginActionResult = { ok: boolean; appName: string; @@ -44,6 +61,13 @@ export type PluginActionResult = { /** PM2 is only involved when the plugin actually has a sidecar. Most won't. */ const hasSidecar = (plugin: DiscoveredPlugin) => !!plugin.sidecar; +/** Record a step, tell whoever is listening, and pause so the next one does not land in the same frame. */ +async function step(steps: string[], onStep: OnStep | undefined, text: string): Promise { + steps.push(text); + await onStep?.(text); + if (onStep) await Bun.sleep(STEP_BEAT_MS); +} + async function findPlugin(appName: string): Promise { const { states } = await snapshotPlugins(); return states.find((s) => s.plugin.appName === appName)?.plugin ?? null; @@ -58,7 +82,7 @@ async function findPlugin(appName: string): Promise { * `enabled` is deliberately untouched on the upgrade path — re-installing something the owner had * switched off must not switch it back on. */ -export async function installPlugin(appName: string): Promise { +export async function installPlugin(appName: string, onStep?: OnStep): Promise { const plugin = await findPlugin(appName); if (!plugin) return { ok: false, appName, steps: [], error: `No plugin directory named "${appName}"` }; @@ -66,7 +90,7 @@ export async function installPlugin(appName: string): Promise` work. return { ok: false, appName, steps, error: `sidecar failed to start: ${started.error}` }; } - steps.push('sidecar: started'); + await step(steps, onStep, 'sidecar: started'); } - if (plugin.schema) steps.push('schema: skipped — not wired yet (see install.ts)'); + if (plugin.schema) await step(steps, onStep, 'schema: skipped — not wired yet (see install.ts)'); await recordPluginInstall(appName, plugin.manifest.version); - steps.push(`recorded at ${plugin.manifest.version}`); + await step(steps, onStep, `recorded at ${plugin.manifest.version}`); const { mounted } = await refreshPluginMounts(); - steps.push(mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)'); + await step(steps, onStep, mounted.length ? `mounted: ${mounted.join(', ')}` : 'mounted: nothing (no api/router.ts)'); return { ok: true, appName, steps }; } catch (err) { @@ -97,7 +121,7 @@ export async function installPlugin(appName: string): Promise { +export async function uninstallPlugin(appName: string, onStep?: OnStep): Promise { const steps: string[] = []; const plugin = await findPlugin(appName); @@ -105,38 +129,42 @@ export async function uninstallPlugin(appName: string): Promise { +export async function setPluginRunning( + appName: string, + enabled: boolean, + onStep?: OnStep, +): Promise { const steps: string[] = []; const row = await setPluginEnabled(appName, enabled); if (!row) return { ok: false, appName, steps, error: `"${appName}" is not installed` }; - steps.push(enabled ? 'enabled' : 'disabled'); + await step(steps, onStep, enabled ? 'enabled' : 'disabled'); const plugin = await findPlugin(appName); if (plugin && hasSidecar(plugin)) { const name = pluginProcessName(appName); const result = enabled ? await startProcess(name, PLATFORM_DIR) : await stopProcess(name, PLATFORM_DIR); - steps.push(result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`); + await step(steps, onStep, result.ok ? `sidecar: ${enabled ? 'started' : 'stopped'}` : `sidecar: ${result.error}`); } const { mounted } = await refreshPluginMounts(); - steps.push(`mounts: ${mounted.join(', ') || 'no plugin routes'}`); + await step(steps, onStep, `mounts: ${mounted.join(', ') || 'no plugin routes'}`); return { ok: true, appName, steps }; } diff --git a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx index 92cf20d4..b3eeeaba 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx +++ b/src/workspaces/officerdev/src/apps/Plugins/PluginDetail.tsx @@ -53,7 +53,7 @@ const Parts = ({ has }: { has: PluginItem['has'] }) => { }; export const PluginDetail = () => { - const { plugins, install, uninstall, enable, disable } = usePlugins(); + const { plugins, steps, result, running, run } = usePlugins(); const [params] = useSearchParams(); const plugin = plugins.find((p) => p.appName === params.get('selected')); @@ -61,11 +61,7 @@ export const PluginDetail = () => { return
Select a plugin.
; } - const busy = install.isPending || uninstall.isPending || enable.isPending || disable.isPending; - // Whichever verb ran last. They are mutually exclusive in practice — the buttons are disabled while any - // is pending — so the first with data is the one that just happened. - const last = [install, uninstall, enable, disable].find((m) => m.data || m.error); - const result = last?.data; + const busy = running !== null; return (
@@ -111,43 +107,53 @@ export const PluginDetail = () => {
{!plugin.installed ? ( - ) : ( <> {plugin.enabled ? ( - ) : ( - )} {plugin.outdated ? ( - ) : null} - )}
- {/* What the last verb actually did. An install that mounted routes but could not start a sidecar is - a different outcome from one that worked, and a spinner that stops cannot say which happened. */} - {result ? ( + {/* The log, as it arrives. `steps` fills while the stream is open and `result` lands when it + closes, so the same block is both the progress and the verdict — an install that mounted routes + but could not start a sidecar reads differently from one that worked, and a spinner cannot. */} + {steps.length || result ? (
-
{result.ok ? 'Done' : 'Failed'}
-
    - {result.steps.map((step, i) => ( -
  • · {step}
  • +
    + {running ? ( + <> + + {running}… + + ) : ( + {result?.ok ? 'Done' : 'Failed'} + )} +
    +
      + {steps.map((s, i) => ( +
    • · {s}
    • ))}
    - {result.error ?
    {result.error}
    : null} + {result?.error ?
    {result.error}
    : null}
) : null} diff --git a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts index 54b4d38b..14eb1d88 100644 --- a/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts +++ b/src/workspaces/officerdev/src/apps/Plugins/usePlugins.ts @@ -1,5 +1,6 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useClient } from 'hooks/useClient'; +import { useCallback, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useClient, getHeaders } from 'hooks/useClient'; // Reading and driving the plugin system. One query, four verbs. // @@ -38,6 +39,8 @@ export type PluginItem = { /** What a verb actually did, in order. Shown rather than collapsed to a spinner. */ export type PluginActionResult = { ok: boolean; appName: string; steps: string[]; error?: string }; +export type PluginVerb = 'install' | 'uninstall' | 'enable' | 'disable'; + export type BrokenPlugin = { appName: string; error: string }; const PLUGINS_KEY = ['plugins']; @@ -53,38 +56,94 @@ export function usePlugins() { // Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile // and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing. - const invalidate = () => { + const invalidate = useCallback(() => { queryClient.invalidateQueries({ queryKey: PLUGINS_KEY }); queryClient.invalidateQueries({ queryKey: ['self-capabilities'] }); - }; + }, [queryClient]); - // Written out rather than generated in a loop: `useMutation` is a hook, and a hook called from inside a - // helper is a rules-of-hooks violation even when the call order happens to be stable. - const install = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/install`, {}), - onSuccess: invalidate, - }); - const uninstall = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/uninstall`, {}), - onSuccess: invalidate, - }); - const enable = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/enable`, {}), - onSuccess: invalidate, - }); - const disable = useMutation({ - mutationFn: (appName: string) => client.post(`/plugins/${appName}/disable`, {}), - onSuccess: invalidate, - }); + const [steps, setSteps] = useState([]); + const [result, setResult] = useState(null); + const [running, setRunning] = useState(null); + const abort = useRef(null); + + /** + * Run a verb and stream its steps. + * + * Not `useMutation`, because react-query models one request with one answer and this is a request with + * a running commentary. Hand-rolled for the same reason `useCompanionLogStream` is: `EventSource` + * cannot send an `Authorization` header, and these routes are owner-only. + * + * The frame parser is deliberately small — split on blank lines, read `event:` and `data:`. It only has + * to understand what our own endpoint emits. + */ + const run = useCallback( + async (verb: PluginVerb, appName: string) => { + abort.current?.abort(); + const controller = new AbortController(); + abort.current = controller; + + setSteps([]); + setResult(null); + setRunning(verb); + + try { + const res = await fetch(`/api/plugins/${appName}/${verb}/stream`, { + method: 'POST', + headers: getHeaders(), + signal: controller.signal, + }); + if (!res.body) throw new Error(`${verb} failed: no response body`); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // The last element is whatever has arrived since the last blank line — an incomplete frame, so + // it stays in the buffer rather than being parsed as a short one. + const frames = buffer.split('\n\n'); + buffer = frames.pop() ?? ''; + + for (const frame of frames) { + let event = 'message'; + let data = ''; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data += line.slice(5).trim(); + } + if (!data) continue; + const parsed = JSON.parse(data) as { step?: string } & PluginActionResult; + if (event === 'step' && parsed.step) setSteps((prev) => [...prev, parsed.step!]); + else if (event === 'done') setResult(parsed); + } + } + } catch (err) { + if ((err as Error).name !== 'AbortError') { + setResult({ ok: false, appName, steps: [], error: err instanceof Error ? err.message : String(err) }); + } + } finally { + setRunning(null); + invalidate(); + } + }, + [invalidate], + ); return { plugins: data?.plugins ?? [], broken: data?.broken ?? [], isLoading, error, - install, - uninstall, - enable, - disable, + /** Steps as they arrive. Cleared when the next verb starts. */ + steps, + /** The final answer, once the stream closes. Null while running. */ + result, + /** Which verb is in flight, or null. */ + running, + run, }; }