an Update button, so a server is not a terminal away from current

Maintenance had "rebuild the frontend" and "restart the server" — one compiles what is on disk, the
other reloads it, and neither CHANGES what is on disk. Doing that meant ssh, on every server, every
time.

Refuses a dirty tree and a non-fast-forward, the same two refusals as pullPlugin and for the same
reason: this checkout is somebody's working copy on at least one machine, and a button that discards
uncommitted work is worse than no button. Fetches `origin HEAD` rather than a branch name, because a
checkout made before the default branch was renamed is on one the remote no longer has — the trap
plugins/update.ts already hit, and it bites hardest on the installs most in need of updating.

It deliberately does not chain into anything. A pull touching bun.lock means an install, which the
frozen-lockfile rule exists to keep deliberate; one touching a schema means db:push against a live
database. So it REPORTS what the update implies — install, db:push, restart or rebuild — from the file
list, and leaves the decision with whoever pressed it. The commits pulled are shown, because "it
worked" is less useful than knowing what arrived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 04:22:30 +00:00
co-authored by Claude Opus 5
parent 4e2db3dc2f
commit 03abdeb246
2 changed files with 155 additions and 3 deletions
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Hammer, Loader2, RotateCw } from 'lucide-react';
import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
@@ -31,10 +31,45 @@ export function errorMessage(err: unknown, fallback: string): string {
export const MaintenanceSection = () => {
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('Already up to date');
return;
}
setPulled({ from: res.from ?? '', to: res.to ?? '', commits: res.commits, needs: res.needs });
toast.success(`Pulled ${res.commits.length} commit${res.commits.length === 1 ? '' : 's'}`);
} catch (err) {
toast.error(errorMessage(err, 'Pull failed'));
} finally {
setPulling(false);
}
};
const rebuild = async () => {
setRebuilding(true);
try {
@@ -90,6 +125,43 @@ export const MaintenanceSection = () => {
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">Update the platform</div>
<p className="mt-1 text-xs text-muted-foreground">
Pulls this server&rsquo;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">
Changes nothing that is running. Whatever the update needs is listed here, and stays your call.
</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">Rebuild the frontend</div>
<p className="mt-1 text-xs text-muted-foreground">
@@ -98,7 +170,7 @@ export const MaintenanceSection = () => {
</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">
<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>
@@ -118,7 +190,7 @@ export const MaintenanceSection = () => {
</div>
<Button
variant="destructive"
disabled={rebuilding || restarting}
disabled={pulling || rebuilding || restarting}
onClick={() => void restart()}
className="shrink-0"
>