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:
+75
-3
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Hammer, Loader2, RotateCw } from 'lucide-react';
|
import { Download, Hammer, Loader2, RotateCw } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -31,10 +31,45 @@ export function errorMessage(err: unknown, fallback: string): string {
|
|||||||
|
|
||||||
export const MaintenanceSection = () => {
|
export const MaintenanceSection = () => {
|
||||||
const client = useClient();
|
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 [rebuilding, setRebuilding] = useState(false);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
const [lastBuild, setLastBuild] = useState<string | null>(null);
|
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 () => {
|
const rebuild = async () => {
|
||||||
setRebuilding(true);
|
setRebuilding(true);
|
||||||
try {
|
try {
|
||||||
@@ -90,6 +125,43 @@ export const MaintenanceSection = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-start justify-between 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’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="min-w-0">
|
||||||
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
|
<div className="text-sm font-medium text-foreground">Rebuild the frontend</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -98,7 +170,7 @@ export const MaintenanceSection = () => {
|
|||||||
</p>
|
</p>
|
||||||
{lastBuild && <p className="mt-1 text-xs text-muted-foreground/70">Last build: {lastBuild}</p>}
|
{lastBuild && <p className="mt-1 text-xs text-muted-foreground/70">Last build: {lastBuild}</p>}
|
||||||
</div>
|
</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 ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Hammer className="mr-2 h-4 w-4" />}
|
||||||
{rebuilding ? 'Building…' : 'Rebuild'}
|
{rebuilding ? 'Building…' : 'Rebuild'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -118,7 +190,7 @@ export const MaintenanceSection = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={rebuilding || restarting}
|
disabled={pulling || rebuilding || restarting}
|
||||||
onClick={() => void restart()}
|
onClick={() => void restart()}
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -40,6 +40,86 @@ serverSettingsRouter.post('/rebuild-frontend', async (ctx) => {
|
|||||||
return ctx.json({ ok: true, ms: result.ms, outputs: result.outputs });
|
return ctx.json({ ok: true, ms: result.ms, outputs: result.outputs });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the platform's own repository.
|
||||||
|
*
|
||||||
|
* The button that was missing between the two below: `/rebuild-frontend` compiles what is on disk and
|
||||||
|
* `/restart` reloads it, but neither CHANGES what is on disk. Doing that meant a terminal, on every
|
||||||
|
* server, every time.
|
||||||
|
*
|
||||||
|
* ── What it refuses, and why ──
|
||||||
|
*
|
||||||
|
* A dirty tree, and a non-fast-forward. Same two refusals as `pullPlugin`, 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 a button that does not exist. `--ff-only` so a divergence is reported rather than
|
||||||
|
* merged by a machine.
|
||||||
|
*
|
||||||
|
* ── What it does NOT do ──
|
||||||
|
*
|
||||||
|
* It does not rebuild, restart, install dependencies or push the schema. Those are separate buttons and
|
||||||
|
* separate decisions — `bun install` after a lockfile change is a supply-chain moment the frozen-lockfile
|
||||||
|
* rule exists to make deliberate, and `db:push` alters a live database. So this REPORTS when the pull
|
||||||
|
* touched `package.json`, `bun.lock` or a schema file, and leaves the choice with whoever pressed it.
|
||||||
|
*/
|
||||||
|
serverSettingsRouter.post('/pull', async (ctx) => {
|
||||||
|
const git = async (args: string[]) => {
|
||||||
|
const proc = Bun.spawn(['git', ...args], { cwd: PLATFORM_DIR, stdout: 'pipe', stderr: 'pipe' });
|
||||||
|
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||||
|
return { code: await proc.exited, out: out.trim(), err: err.trim() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const dirty = await git(['status', '--porcelain']);
|
||||||
|
if (dirty.out) {
|
||||||
|
const files = dirty.out.split('\n').length;
|
||||||
|
return ctx.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: `${files} uncommitted change(s) — commit or discard them first, refusing to overwrite local work`,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = (await git(['rev-parse', 'HEAD'])).out;
|
||||||
|
|
||||||
|
// `origin HEAD`, not a branch name: a checkout made before the default branch was renamed is on one
|
||||||
|
// the remote no longer has, and `git pull origin <local-name>` fails on exactly the installs that most
|
||||||
|
// need updating. Learned in `plugins/update.ts`; the same trap applies here.
|
||||||
|
const fetched = await git(['fetch', 'origin', 'HEAD']);
|
||||||
|
if (fetched.code !== 0) return ctx.json({ ok: false, error: fetched.err || 'git fetch failed' }, 500);
|
||||||
|
|
||||||
|
const merged = await git(['merge', '--ff-only', 'FETCH_HEAD']);
|
||||||
|
if (merged.code !== 0) {
|
||||||
|
return ctx.json(
|
||||||
|
{ ok: false, error: merged.err || merged.out || 'not a fast-forward — this checkout has diverged' },
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = (await git(['rev-parse', 'HEAD'])).out;
|
||||||
|
if (before === after) return ctx.json({ ok: true, updated: false, commits: [], needs: [] });
|
||||||
|
|
||||||
|
const log = await git(['log', '--oneline', '--no-decorate', `${before}..${after}`]);
|
||||||
|
const changed = (await git(['diff', '--name-only', `${before}..${after}`])).out.split('\n');
|
||||||
|
|
||||||
|
// What the pull implies but must not do on its own.
|
||||||
|
const needs: string[] = [];
|
||||||
|
if (changed.some((f) => f === 'package.json' || f === 'bun.lock')) needs.push('bun install');
|
||||||
|
if (changed.some((f) => f.includes('/schema.ts') || f.endsWith('drizzle-schema.ts'))) needs.push('bun db:push');
|
||||||
|
if (changed.some((f) => f.startsWith('src/servers/') || f.startsWith('src/databases/'))) needs.push('restart');
|
||||||
|
else if (changed.some((f) => f.startsWith('src/'))) needs.push('rebuild-frontend');
|
||||||
|
|
||||||
|
return ctx.json({
|
||||||
|
ok: true,
|
||||||
|
updated: true,
|
||||||
|
from: before.slice(0, 7),
|
||||||
|
to: after.slice(0, 7),
|
||||||
|
commits: log.out ? log.out.split('\n') : [],
|
||||||
|
files: changed.length,
|
||||||
|
needs,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restart the platform process. The only way to load changed SERVER code.
|
* Restart the platform process. The only way to load changed SERVER code.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user