stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and the detail panel renders them arriving. POST rather than GET, so EventSource is unavailable — it sends no Authorization header and these routes are owner-only. The client reads the body and parses frames by hand, which is what useCompanionLogStream already does for the headscale container logs; the parser only has to understand what our own endpoint emits. the runner does not know whether anyone is listening. it takes an optional onStep and calls it, so the non-streaming path is the same code with no callback rather than a second implementation of the same four verbs. there is a 220ms beat between steps and it is cosmetic — worth saying out loud. pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the router do not, and four lines landing in one frame look like a stall followed by a jump. small enough not to matter to a script, long enough to follow. writing to a closed stream is caught rather than fatal: navigating away mid-install must not abort the install, because by then it is the server's work and half an install is the one outcome the ordering was designed to avoid. verified over the wire with timestamps — frames arrive incrementally, the sidecar step showing its real duration rather than the beat. afterwards pm2 holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is byte-identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <div className="p-6 text-sm text-duck-dark/50">Select a plugin.</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
@@ -111,43 +107,53 @@ export const PluginDetail = () => {
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{!plugin.installed ? (
|
||||
<Button tone="primary" disabled={busy} onClick={() => install.mutate(plugin.appName)}>
|
||||
<Button tone="primary" disabled={busy} onClick={() => run('install', plugin.appName)}>
|
||||
Install
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{plugin.enabled ? (
|
||||
<Button disabled={busy} onClick={() => disable.mutate(plugin.appName)}>
|
||||
<Button disabled={busy} onClick={() => run('disable', plugin.appName)}>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button tone="primary" disabled={busy} onClick={() => enable.mutate(plugin.appName)}>
|
||||
<Button tone="primary" disabled={busy} onClick={() => run('enable', plugin.appName)}>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
{plugin.outdated ? (
|
||||
<Button disabled={busy} onClick={() => install.mutate(plugin.appName)}>
|
||||
<Button disabled={busy} onClick={() => run('install', plugin.appName)}>
|
||||
Update to {plugin.version}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button tone="danger" disabled={busy} onClick={() => uninstall.mutate(plugin.appName)}>
|
||||
<Button tone="danger" disabled={busy} onClick={() => run('uninstall', plugin.appName)}>
|
||||
Uninstall
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="mt-5 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3">
|
||||
<div className="mb-1 text-xs font-medium text-duck-dark/60">{result.ok ? 'Done' : 'Failed'}</div>
|
||||
<ul className="space-y-0.5 text-xs text-duck-dark/70">
|
||||
{result.steps.map((step, i) => (
|
||||
<li key={i}>· {step}</li>
|
||||
<div className="mb-1.5 flex items-center gap-2 text-xs font-medium text-duck-dark/60">
|
||||
{running ? (
|
||||
<>
|
||||
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-duck-teal" />
|
||||
{running}…
|
||||
</>
|
||||
) : (
|
||||
<span className={result?.ok ? 'text-emerald-600' : 'text-red-600'}>{result?.ok ? 'Done' : 'Failed'}</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-xs text-duck-dark/70">
|
||||
{steps.map((s, i) => (
|
||||
<li key={i}>· {s}</li>
|
||||
))}
|
||||
</ul>
|
||||
{result.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
|
||||
{result?.error ? <div className="mt-2 text-xs text-red-600">{result.error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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<PluginActionResult>(`/plugins/${appName}/install`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const uninstall = useMutation({
|
||||
mutationFn: (appName: string) => client.post<PluginActionResult>(`/plugins/${appName}/uninstall`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const enable = useMutation({
|
||||
mutationFn: (appName: string) => client.post<PluginActionResult>(`/plugins/${appName}/enable`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const disable = useMutation({
|
||||
mutationFn: (appName: string) => client.post<PluginActionResult>(`/plugins/${appName}/disable`, {}),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const [steps, setSteps] = useState<string[]>([]);
|
||||
const [result, setResult] = useState<PluginActionResult | null>(null);
|
||||
const [running, setRunning] = useState<PluginVerb | null>(null);
|
||||
const abort = useRef<AbortController | null>(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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user