ServerSettings reaches zero: OCR, STT, TTS, SMTP, maintenance and the AI harnesses. Two more hand-rolled plurals became real plural entries -- maintenance said "commit" with a ternary on length === 1, and both read correctly at 0, 1 and 2 in both locales now. The three Cancel buttons in AIHarnessesSection take ui.button.cancel rather than adding a fourth app-level copy of the word, which is what phase 1 put it there for. Scanner: a single token carrying digits and punctuation is a name, not a sentence -- the OCR placeholder is the example model file Qwen2.5-VL-7B-Instruct-q4_k_m.gguf. Prose has spaces, and requiring whitespace to be absent is what keeps this from eating real one-word labels. Verified the delta is exactly that one string. Note for the owner: another session has src/servers/api/sub-agents/router.ts in flight with three type errors. Untouched by me and excluded from my gate; the full tree will not typecheck clean until they land it.
204 lines
9.0 KiB
TypeScript
204 lines
9.0 KiB
TypeScript
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<string | null>(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 (
|
|
<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">{t('settings.server.maintenance.updateTitle')}</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Pulls this server’s repository. Refuses if anything is uncommitted or the checkout has diverged — it
|
|
will never discard local work.
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{t('settings.server.maintenance.updateHint')}</p>
|
|
{pulled && (
|
|
<div className="mt-2 rounded-md border border-border bg-muted/40 p-2">
|
|
<p className="font-mono text-[11px] text-muted-foreground">
|
|
{pulled.from} → {pulled.to}
|
|
</p>
|
|
<ul className="mt-1 space-y-0.5">
|
|
{pulled.commits.slice(0, 5).map((line) => (
|
|
<li key={line} className="truncate text-[11px] text-foreground/80">
|
|
{line}
|
|
</li>
|
|
))}
|
|
{pulled.commits.length > 5 && (
|
|
<li className="text-[11px] text-muted-foreground">+{pulled.commits.length - 5} more</li>
|
|
)}
|
|
</ul>
|
|
{pulled.needs.length > 0 && (
|
|
<p className="mt-1.5 text-[11px] text-warning">Needs: {pulled.needs.join(', ')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button disabled={pulling || rebuilding || restarting} onClick={() => void pull()} className="shrink-0">
|
|
{pulling ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
|
{pulling ? 'Pulling…' : 'Update'}
|
|
</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">{t('settings.server.maintenance.rebuild')}</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={pulling || 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">{t('settings.server.maintenance.restart')}</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 run outside this process’s tree.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={pulling || 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>
|
|
);
|
|
};
|