Files
warmbly/admin/src/components/layout/UpdateDialog.tsx
T

570 lines
25 KiB
TypeScript

// The update dialog behind the version pill: what is running, what is newest,
// and the one button that pulls, rebuilds and restarts through the updater.
// While a job runs it shows the steps and the live log; when the backend goes
// away for the restart it says so and keeps polling until it is back.
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
AlertTriangle,
Check,
CheckCircle2,
ExternalLink,
GitBranch,
Loader2,
Package,
RefreshCw,
RotateCw,
XCircle,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { docsUrl } from "@/lib/docs";
import { cn } from "@/lib/utils";
import { useAdminPerm } from "@/hooks/useAdminPerm";
import { AdminPerm } from "@/lib/auth/permissions";
import {
UPDATE_JOB_KEY,
UPDATE_STATE_KEY,
buildLabel,
isUpdating,
useUpdateJob,
useUpdateState,
} from "@/hooks/useUpdateState";
import {
applyUpdate,
checkForUpdates,
type UpdateJob,
type UpdateState,
} from "@/lib/api/client/admin/updates";
import { markUpdateStarted, readUpdateStarted } from "@/lib/updateSession";
const DOCS_UPDATES = "/development/updates/";
const STEPS: Record<string, string[]> = {
compose: ["fetch", "checkout", "build", "restart", "prune", "wait"],
image: ["resolve", "pull", "restart", "prune", "wait"],
command: ["fetch", "checkout", "command", "wait"],
};
const STEP_LABELS: Record<string, string> = {
fetch: "Fetch",
checkout: "Pull",
build: "Build",
resolve: "Pin release",
pull: "Pull images",
restart: "Restart",
prune: "Clean up",
command: "Run script",
wait: "Wait for backend",
starting: "Starting",
};
type Phase = "idle" | "running" | "restarting" | "done" | "failed";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function UpdateDialog({ open, onOpenChange }: Props) {
const qc = useQueryClient();
const canManage = useAdminPerm(AdminPerm.ManageSettings);
const stateQ = useUpdateState();
const jobQ = useUpdateJob(open);
const state: UpdateState | undefined = jobQ.data ?? stateQ.data;
const [confirming, setConfirming] = useState(false);
const started = readUpdateStarted();
const phase: Phase = useMemo(() => {
if (isUpdating(state)) return "running";
if (started && (jobQ.isError || stateQ.isError)) return "restarting";
const last = state?.updater.last_job;
if (started && last && last.status !== "running") {
return last.status === "succeeded" ? "done" : "failed";
}
return "idle";
}, [state, started, jobQ.isError, stateQ.isError]);
const canApply =
canManage &&
state?.updater.status === "ok" &&
!!state?.update_available &&
!state?.updater.checkout?.dirty &&
phase === "idle";
// Leaving the confirmation open once the update can no longer start
// (a phase change, or the checkout turning dirty) would let a stale
// click start a job that fails.
useEffect(() => {
if (!canApply) setConfirming(false);
}, [canApply]);
const checkMut = useMutation({
mutationFn: checkForUpdates,
onSuccess: (data) => {
qc.setQueryData(UPDATE_STATE_KEY, data);
qc.setQueryData(UPDATE_JOB_KEY, data);
toast.success(
data.update_available ? "A newer version is available" : "This instance is up to date",
);
},
onError: (err: Error) => toast.error(err.message || "Could not check for updates"),
});
const applyMut = useMutation({
mutationFn: () => {
// The state keeps refreshing while the confirmation is open; a
// checkout that turned dirty meanwhile must not start a job.
if (!canApply) return Promise.reject(new Error("The update can no longer start; check the status above."));
return applyUpdate("latest");
},
onSuccess: (job: UpdateJob) => {
markUpdateStarted(state?.running.version ?? "", state?.running.commit ?? job.from_commit);
setConfirming(false);
toast.success("Update started");
void qc.invalidateQueries({ queryKey: UPDATE_STATE_KEY });
void qc.invalidateQueries({ queryKey: UPDATE_JOB_KEY });
},
onError: (err: Error) => toast.error(err.message || "Could not start the update"),
});
const updater = state?.updater;
const checkout = updater?.checkout;
const job = updater?.job ?? updater?.last_job;
// An image install never builds, so the confirmation must not promise a
// rebuild it will not do.
const imageMode = updater?.mode === "image";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Updates</DialogTitle>
<DialogDescription>
{state
? `Running ${buildLabel(state)}${state.running.commit ? ` (${state.running.commit.slice(0, 7)})` : ""}.`
: "Reading the running version."}
</DialogDescription>
</DialogHeader>
{phase === "idle" && state && (
<div className="space-y-3">
<Overview state={state} />
{updater?.status !== "ok" && <UpdaterNotice state={state} />}
{checkout?.dirty && (
<Notice tone="warning">
The checkout has local modifications. The updater refuses to move it
until they are committed or stashed, or `UPDATER_ALLOW_DIRTY=true`.
</Notice>
)}
{job && job.status === "failed" && !started && (
<Notice tone="error">
The last update failed at step {STEP_LABELS[job.step] ?? job.step}:{" "}
{job.error}
</Notice>
)}
{confirming && (
<div className="animate-in fade-in slide-in-from-bottom-1 duration-200">
<Notice tone="warning">
<div className="font-medium text-foreground">
{imageMode
? "This pulls the release images and restarts every service."
: "This pulls the checkout, rebuilds the images and restarts every service."}
</div>
<div className="mt-1">
Sending and syncing pause for a few minutes and resume on their own;
nothing in flight is lost. Migrations apply when the backend comes
back. This panel reconnects by itself.
</div>
</Notice>
</div>
)}
</div>
)}
{(phase === "running" || phase === "restarting") && (
<Progress state={state} job={job} restarting={phase === "restarting"} />
)}
{phase === "done" && state && (
<div className="space-y-3 animate-in fade-in zoom-in-95 duration-300">
<div className="flex items-start gap-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3">
<CheckCircle2 className="mt-0.5 size-4 shrink-0 text-emerald-600 animate-in zoom-in-50 duration-300" />
<div className="text-[13px] leading-relaxed text-emerald-800">
<div className="font-semibold">Updated to {buildLabel(state)}</div>
Every service is back and sending has resumed. Reload to pick up the
new admin panel.
</div>
</div>
{job?.log && <LogPanel lines={job.log} />}
</div>
)}
{phase === "failed" && (
<div className="space-y-3 animate-in fade-in duration-200">
<Notice tone="error">
<div className="font-semibold text-foreground">The update failed</div>
{job?.error ?? "See the log below."} The previous version is still running
unless the restart step had already begun.
</Notice>
{job?.log && <LogPanel lines={job.log} />}
</div>
)}
<DialogFooter className="gap-2 sm:justify-between">
<a
href={docsUrl(DOCS_UPDATES)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent-strong)] hover:underline"
>
How updates work
<ExternalLink className="size-3" />
</a>
<div className="flex flex-wrap items-center gap-2">
{phase === "idle" && canManage && (
<Button
size="sm"
variant="outline"
onClick={() => checkMut.mutate()}
disabled={checkMut.isPending}
>
<RefreshCw className={cn("size-4", checkMut.isPending && "animate-spin")} />
{checkMut.isPending ? "Checking..." : "Check now"}
</Button>
)}
{phase === "idle" && canApply && !confirming && (
<Button size="sm" onClick={() => setConfirming(true)}>
<RotateCw className="size-4" />
Update and restart
</Button>
)}
{phase === "idle" && confirming && (
<>
<Button size="sm" variant="ghost" onClick={() => setConfirming(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={() => applyMut.mutate()}
disabled={applyMut.isPending}
>
{applyMut.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RotateCw className="size-4" />
)}
Update now
</Button>
</>
)}
{(phase === "done" || phase === "failed") && (
<Button size="sm" onClick={() => window.location.reload()}>
Reload
</Button>
)}
{(phase === "running" || phase === "restarting") && (
<Button size="sm" variant="ghost" onClick={() => onOpenChange(false)}>
Keep running in the background
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Overview({ state }: { state: UpdateState }) {
const { latest, updater } = state;
const checkout = updater.checkout;
const release = updater.release;
return (
<dl className="grid grid-cols-[8rem_1fr] gap-x-3 gap-y-2 text-[13px]">
<dt className="text-muted-foreground">Latest release</dt>
<dd>
{latest ? (
<span className="inline-flex flex-wrap items-center gap-2">
<span className="font-medium">{latest.tag}</span>
{latest.published_at && (
<span className="text-muted-foreground">
{new Date(latest.published_at).toLocaleDateString()}
</span>
)}
{latest.html_url && (
<a
href={latest.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-[var(--admin-accent-strong)] hover:underline"
>
Release notes
<ExternalLink className="size-3" />
</a>
)}
</span>
) : state.check_error ? (
<span className="text-amber-700">Could not read releases: {state.check_error}</span>
) : state.enabled ? (
<span className="text-muted-foreground">No release found for {state.repo}</span>
) : (
<span className="text-muted-foreground">Release check is off</span>
)}
</dd>
<dt className="text-muted-foreground">Status</dt>
<dd>
{state.update_available ? (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
Update available
</Badge>
) : (
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700">
Up to date
</Badge>
)}
{state.checked_at && (
<span className="ml-2 text-xs text-muted-foreground">
checked {new Date(state.checked_at).toLocaleTimeString()}, every{" "}
{state.interval}
</span>
)}
</dd>
{release && (
<>
<dt className="text-muted-foreground">Installed</dt>
<dd className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 font-mono text-xs">
<Package className="size-3.5 text-muted-foreground" />
{release.prefix}/*:{release.tag}
</span>
<span className="text-muted-foreground">
{release.pinned
? "pinned to this release"
: "following the channel tag"}
</span>
</dd>
</>
)}
{checkout && (
<>
<dt className="text-muted-foreground">Checkout</dt>
<dd className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1 font-mono text-xs">
<GitBranch className="size-3.5 text-muted-foreground" />
{checkout.detached ? "pinned" : checkout.branch}@{checkout.commit.slice(0, 7)}
</span>
{!checkout.detached && (
<span className="text-muted-foreground">
{checkout.behind > 0
? `${checkout.behind} commit${checkout.behind === 1 ? "" : "s"} behind`
: "matches the remote"}
</span>
)}
{checkout.fetch_error && (
<span className="text-amber-700">fetch failed: {checkout.fetch_error}</span>
)}
</dd>
</>
)}
<dt className="text-muted-foreground">Updater</dt>
<dd>
{updater.status === "ok" && (
<span>
ready
<span className="text-muted-foreground"> ({updater.mode} mode)</span>
</span>
)}
{updater.status === "off" && <span className="text-muted-foreground">not configured</span>}
{updater.status === "unreachable" && <span className="text-amber-700">unreachable</span>}
</dd>
</dl>
);
}
function UpdaterNotice({ state }: { state: UpdateState }) {
const u = state.updater;
// A clone-free install has no checkout to pull, so the by-hand command is
// the compose one. Which install this is comes from the release block, and
// an unreachable updater reports neither block, so that case names both
// rather than printing one command that may not work.
//
// A pinned install needs the tag edited first: pulling again resolves the
// same fixed version and updates nothing, which is the kind of instruction
// that looks like it worked.
const pinnedTag = u.release?.pinned ? (state.latest?.tag ?? "vX.Y.Z") : null;
const byHand = u.release
? "docker compose pull && docker compose up -d"
: u.checkout
? "git pull && make up"
: null;
if (u.status === "unreachable") {
return (
<Notice tone="warning">
<div className="font-medium text-foreground">The updater is not answering</div>
{u.error} Until it does, update by hand from the install directory:
{pinnedTag && <SetTagFirst tag={pinnedTag} />}
{byHand ? <Cmd>{byHand}</Cmd> : <BothCommands />}
</Notice>
);
}
return (
<Notice tone="info">
<div className="font-medium text-foreground">This panel can only report</div>
No updater is configured, so apply updates from a shell on the host:
{pinnedTag && <SetTagFirst tag={pinnedTag} />}
{byHand ? <Cmd>{byHand}</Cmd> : <BothCommands />}
To get the button, enable the updater compose profile (`make up` and the installer
both do) or run the updater unit on a bare-metal host.
</Notice>
);
}
// An image install pinned to a fixed version resolves the same images on every
// pull, so moving it is an edit to .env and then the pull. Without this the
// command below reports success and changes nothing.
function SetTagFirst({ tag }: { tag: string }) {
return (
<>
<div className="mt-1.5 text-xs">
This install is pinned, so set the release in <code>.env</code> first:
</div>
<Cmd>{`WARMBLY_TAG=${tag}`}</Cmd>
<div className="text-xs">then:</div>
</>
);
}
// Shown when the updater is unreachable, so nothing says which shape of install
// this is. Naming both beats guessing: the wrong one fails confusingly on an
// install that has no checkout, or no images to pull.
function BothCommands() {
return (
<>
<div className="mt-1.5 text-xs">From an install.sh install:</div>
<Cmd>docker compose pull && docker compose up -d</Cmd>
<div className="text-xs">From a git checkout:</div>
<Cmd>git pull && make up</Cmd>
</>
);
}
function Progress({
state,
job,
restarting,
}: {
state: UpdateState | undefined;
job: UpdateJob | undefined;
restarting: boolean;
}) {
const mode = state?.updater.mode ?? "compose";
const steps = STEPS[mode] ?? STEPS.compose;
const current = restarting ? "wait" : (job?.step ?? "starting");
const currentIdx = Math.max(0, steps.indexOf(current));
const percent = Math.round(((currentIdx + 0.5) / steps.length) * 100);
return (
<div className="space-y-3 animate-in fade-in duration-200">
<div className="flex items-start gap-3 rounded-lg border border-sky-200 bg-sky-50/60 p-3">
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin text-sky-600" />
<div className="min-w-0 flex-1 text-[13px] leading-relaxed text-sky-900">
<div className="flex items-center justify-between gap-3">
<span className="font-semibold">
{restarting ? "Restarting services" : `Updating: ${STEP_LABELS[current] ?? current}`}
</span>
<span className="text-xs tabular-nums text-sky-700">{percent}%</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-sky-100">
<div
className="h-full rounded-full bg-sky-500 transition-[width] duration-500 ease-out"
style={{ width: `${percent}%` }}
/>
</div>
<div className="mt-1.5 text-xs text-sky-800/80">
{restarting
? "The backend is coming back up. This panel reconnects on its own; keep the tab open or come back later, the result is kept."
: "You can close this dialog; the pill in the top bar keeps following the job."}
</div>
</div>
</div>
<ol className="flex flex-wrap gap-1.5">
{steps.map((s, i) => {
const done = currentIdx > i || (restarting && s !== "wait");
const active = s === current;
return (
<li
key={s}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px]",
done && "border-emerald-200 bg-emerald-50 text-emerald-700",
active && "border-sky-300 bg-sky-50 text-sky-700",
!done && !active && "border-border text-muted-foreground",
)}
>
{done ? (
<Check className="size-3" />
) : active ? (
<Loader2 className="size-3 animate-spin" />
) : null}
{STEP_LABELS[s] ?? s}
</li>
);
})}
</ol>
{job?.log && job.log.length > 0 && <LogPanel lines={job.log} />}
</div>
);
}
function LogPanel({ lines }: { lines: string[] }) {
const ref = useRef<HTMLPreElement>(null);
useEffect(() => {
const el = ref.current;
if (el) el.scrollTop = el.scrollHeight;
}, [lines.length]);
return (
<pre
ref={ref}
className="max-h-64 overflow-auto rounded-md border border-border bg-zinc-950 p-3 text-[11px] leading-relaxed text-zinc-200"
>
{lines.join("\n")}
</pre>
);
}
function Notice({ tone, children }: { tone: "info" | "warning" | "error"; children: React.ReactNode }) {
const styles = {
info: "border-sky-200 bg-sky-50/60 text-sky-900",
warning: "border-amber-200 bg-amber-50/60 text-amber-900",
error: "border-red-200 bg-red-50/60 text-red-900",
}[tone];
const Icon = tone === "error" ? XCircle : AlertTriangle;
return (
<div className={cn("flex items-start gap-3 rounded-lg border p-3 text-[13px] leading-relaxed", styles)}>
<Icon className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}
function Cmd({ children }: { children: string }) {
return (
<code className="mt-1.5 mb-1.5 block rounded bg-white/70 px-2 py-1 font-mono text-[12px] text-foreground">
{children}
</code>
);
}