mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
570 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|