// 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 = { compose: ["fetch", "checkout", "build", "restart", "prune", "wait"], image: ["resolve", "pull", "restart", "prune", "wait"], command: ["fetch", "checkout", "command", "wait"], }; const STEP_LABELS: Record = { 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 ( Updates {state ? `Running ${buildLabel(state)}${state.running.commit ? ` (${state.running.commit.slice(0, 7)})` : ""}.` : "Reading the running version."} {phase === "idle" && state && (
{updater?.status !== "ok" && } {checkout?.dirty && ( The checkout has local modifications. The updater refuses to move it until they are committed or stashed, or `UPDATER_ALLOW_DIRTY=true`. )} {job && job.status === "failed" && !started && ( The last update failed at step {STEP_LABELS[job.step] ?? job.step}:{" "} {job.error} )} {confirming && (
{imageMode ? "This pulls the release images and restarts every service." : "This pulls the checkout, rebuilds the images and restarts every service."}
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.
)}
)} {(phase === "running" || phase === "restarting") && ( )} {phase === "done" && state && (
Updated to {buildLabel(state)}
Every service is back and sending has resumed. Reload to pick up the new admin panel.
{job?.log && }
)} {phase === "failed" && (
The update failed
{job?.error ?? "See the log below."} The previous version is still running unless the restart step had already begun.
{job?.log && }
)} How updates work
{phase === "idle" && canManage && ( )} {phase === "idle" && canApply && !confirming && ( )} {phase === "idle" && confirming && ( <> )} {(phase === "done" || phase === "failed") && ( )} {(phase === "running" || phase === "restarting") && ( )}
); } function Overview({ state }: { state: UpdateState }) { const { latest, updater } = state; const checkout = updater.checkout; const release = updater.release; return (
Latest release
{latest ? ( {latest.tag} {latest.published_at && ( {new Date(latest.published_at).toLocaleDateString()} )} {latest.html_url && ( Release notes )} ) : state.check_error ? ( Could not read releases: {state.check_error} ) : state.enabled ? ( No release found for {state.repo} ) : ( Release check is off )}
Status
{state.update_available ? ( Update available ) : ( Up to date )} {state.checked_at && ( checked {new Date(state.checked_at).toLocaleTimeString()}, every{" "} {state.interval} )}
{release && ( <>
Installed
{release.prefix}/*:{release.tag} {release.pinned ? "pinned to this release" : "following the channel tag"}
)} {checkout && ( <>
Checkout
{checkout.detached ? "pinned" : checkout.branch}@{checkout.commit.slice(0, 7)} {!checkout.detached && ( {checkout.behind > 0 ? `${checkout.behind} commit${checkout.behind === 1 ? "" : "s"} behind` : "matches the remote"} )} {checkout.fetch_error && ( fetch failed: {checkout.fetch_error} )}
)}
Updater
{updater.status === "ok" && ( ready ({updater.mode} mode) )} {updater.status === "off" && not configured} {updater.status === "unreachable" && unreachable}
); } 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 (
The updater is not answering
{u.error} Until it does, update by hand from the install directory: {pinnedTag && } {byHand ? {byHand} : }
); } return (
This panel can only report
No updater is configured, so apply updates from a shell on the host: {pinnedTag && } {byHand ? {byHand} : } 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.
); } // 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 ( <>
This install is pinned, so set the release in .env first:
{`WARMBLY_TAG=${tag}`}
then:
); } // 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 ( <>
From an install.sh install:
docker compose pull && docker compose up -d
From a git checkout:
git pull && make up ); } 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 (
{restarting ? "Restarting services" : `Updating: ${STEP_LABELS[current] ?? current}`} {percent}%
{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."}
    {steps.map((s, i) => { const done = currentIdx > i || (restarting && s !== "wait"); const active = s === current; return (
  1. {done ? ( ) : active ? ( ) : null} {STEP_LABELS[s] ?? s}
  2. ); })}
{job?.log && job.log.length > 0 && }
); } function LogPanel({ lines }: { lines: string[] }) { const ref = useRef(null); useEffect(() => { const el = ref.current; if (el) el.scrollTop = el.scrollHeight; }, [lines.length]); return (
            {lines.join("\n")}
        
); } 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 (
{children}
); } function Cmd({ children }: { children: string }) { return ( {children} ); }