Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
12 kB · 339 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340import { ControlPlaneLayout } from "./ControlPlaneLayout.tsx";import { useEffect, useRef, useState } from "octane";import { Button, LinkButton } from "octane-kumo/components/button";import { LayerCard } from "octane-kumo/components/layer-card";import { InstallationProgress } from "./InstallationProgress.tsx";import type { ReleaseIdentity } from "../installation-metadata.ts";import { sameRelease } from "../installation-metadata.ts";
type View = | "checking" | "select" | "updating" | "success" | "current" | "unsupported" | "failed" | "reconnect" | "error";import { usePublisherSession } from "./PublisherQueryProvider";import { useInstallationQuery, installationOptions,} from "./queries/use-installation-query";import { useConnectionQuery, connectionOptions,} from "./queries/use-connection-query";import { PublisherReadError } from "./publisher-request";
const key = (id: string) => `flarebot-update-${id}`;const requestId = () => crypto.randomUUID().replaceAll("-", "");
export function UpdateInstallation({ installationId, identityError,}: { installationId: string; identityError: Error | null;}) { const [view, setView] = useState<View>("checking"); const cache = usePublisherSession(); const epoch = cache.getSnapshot().epoch; const query = useInstallationQuery({ id: installationId, interval: view === "updating" ? 1500 : false, }); const detail = query.data ?? null; const needsGrant = !!detail?.latestRelease && detail.installation.status === "ready" && !sameRelease(detail.installation.installedRelease, detail.latestRelease) && !!detail.installation.installedRelease && detail.upgradeFrom.includes( detail.installation.installedRelease.artifactDigest, ); const accountQuery = useConnectionQuery({ enabled: needsGrant && view === "checking", }); const connection = accountQuery.data ?? null; const started = useRef(false); const observedAt = useRef(0); const posted = useRef(false); const alive = useRef(true); const target = useRef<ReleaseIdentity | null>(null); const controller = useRef<AbortController | null>(null); const request = (url: string, options: RequestInit, timeout = 15_000) => fetch(url, { credentials: "same-origin", cache: "no-store", ...options, signal: AbortSignal.any([ controller.current!.signal, AbortSignal.timeout(timeout), ]), }); const beginUpdate = async () => { const value = detail!; if (["updating", "installing"].includes(value.installation.status)) { target.current = value.installation.desiredRelease; return setView("updating"); } if (value.installation.status === "failed") return setView("failed"); if (!value.latestRelease) return setView("error"); if (sameRelease(value.installation.installedRelease, value.latestRelease)) return setView("current"); if ( !value.installation.installedRelease || !value.upgradeFrom.includes( value.installation.installedRelease.artifactDigest, ) ) return setView("unsupported"); const connected = connection!; if ( !connected.accounts.some( (item) => item.id === value.installation.accountId, ) ) return setView("select"); if (connected.selectedAccountId !== value.installation.accountId) { const selected = await request("/api/account", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ accountId: value.installation.accountId, }).toString(), }); const selectedData = await selected.json(); if (!alive.current || cache.getSnapshot().epoch !== epoch) return; if (selected.status === 401) return setView("reconnect"); if (!selected.ok) return setView("select"); if (selectedData.ownerSubject !== cache.getSnapshot().ownerSubject) { cache.identify(selectedData.ownerSubject); return; } await cache.client.cancelQueries({ queryKey: connectionOptions(cache).queryKey, }); if (!alive.current || cache.getSnapshot().epoch !== epoch) return; cache.client.setQueryData(connectionOptions(cache).queryKey, (current) => current ? { ...current, selectedAccountId: value.installation.accountId } : current, ); } let intent: { requestId: string; target: ReleaseIdentity; ownerSubject: string; submitted?: boolean; } | null; try { intent = JSON.parse( sessionStorage.getItem(key(installationId)) ?? "null", ); } catch { intent = null; } if ( intent?.submitted && intent.ownerSubject === value.installation.ownerSubject && sameRelease(intent.target, value.installation.installedRelease) ) { // A completed saved target is no longer ambiguous, even if the owner // closed this page before observing success and another release shipped. sessionStorage.removeItem(key(installationId)); intent = null; } // Never turn a saved ambiguous operation into a different release request. if (intent?.ownerSubject !== value.installation.ownerSubject) intent = null; if (intent?.submitted) return setView("error"); if ( !intent || intent.ownerSubject !== value.installation.ownerSubject || !/^[a-f0-9]{32}$/.test(intent.requestId) || !sameRelease(intent.target, value.latestRelease) ) { intent = { requestId: requestId(), ownerSubject: value.installation.ownerSubject, target: Object.freeze({ ...value.latestRelease }), }; sessionStorage.setItem(key(installationId), JSON.stringify(intent)); } // An ambiguous previous submission is never automatically repeated. The // exact intent remains available for status reconciliation/manual recovery. if (posted.current) return; posted.current = true; target.current = intent.target; intent.submitted = true; sessionStorage.setItem(key(installationId), JSON.stringify(intent)); const upgraded = await request( `/api/installations/${installationId}/upgrade`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ requestId: intent.requestId, target: JSON.stringify(intent.target), }).toString(), }, 120_000, ); const result = await upgraded.json(); if (!alive.current || cache.getSnapshot().epoch !== epoch) return; if (upgraded.status === 401) return setView("reconnect"); if (!upgraded.ok) { return setView( [ "artifact_unavailable", "resource_conflict", "recovery_required", ].includes(result.error) ? "unsupported" : "error", ); } observedAt.current = query.dataUpdatedAt; setView("updating"); if (result.installation) { await cache.client.cancelQueries({ queryKey: installationOptions(cache, installationId).queryKey, }); if (!alive.current || cache.getSnapshot().epoch !== epoch) return; cache.client.setQueryData( installationOptions(cache, installationId).queryKey, (current) => current ? { ...current, installation: result.installation } : current, ); } }; useEffect(() => { alive.current = true; controller.current = new AbortController(); return () => { alive.current = false; controller.current?.abort(); }; }, [installationId]); useEffect(() => { if (started.current) return; const error = identityError ?? query.error ?? (needsGrant ? accountQuery.error : null); if (error || cache.getSnapshot().signedOut) { setView( cache.getSnapshot().signedOut || (error instanceof PublisherReadError && error.status === 401) ? "reconnect" : "error", ); return; } if (!detail || (needsGrant && !connection)) return; started.current = true; observedAt.current = query.dataUpdatedAt; void beginUpdate().catch(() => { if (alive.current && cache.getSnapshot().epoch === epoch) setView("error"); }); }, [detail, connection, query.error, accountQuery.error, identityError]); useEffect(() => { if ( view !== "updating" || !detail || query.dataUpdatedAt <= observedAt.current ) return; if (detail.installation.status === "ready") { if ( !target.current || !sameRelease(detail.installation.installedRelease, target.current) ) return setView("error"); sessionStorage.removeItem(key(installationId)); setView("success"); } else if (detail.installation.status === "failed") setView("failed"); }, [view, detail, query.dataUpdatedAt]); const returnUrl = detail?.installation.resources.runtimeOrigin ? new URL("/auth/login", detail.installation.resources.runtimeOrigin).href : null; useEffect(() => { if (view !== "success" || !returnUrl) return; const timer = setTimeout(() => location.assign(returnUrl), 2000); return () => clearTimeout(timer); }, [view, returnUrl]); const text: Record<View, string> = { checking: "Checking this installation and the latest compatible release…", updating: "Updating Flarebot… You will return to your instance when it is ready.", success: "Flarebot was updated successfully.", current: "This installation is already on the latest release.", unsupported: "This pre-release installation cannot be updated safely. Contact the publisher for manual recovery. Your existing resources and data have not been reset.", failed: "The previous installation operation failed. Automatic retry is disabled; reconnect and use the manual recovery controls.", reconnect: "Your deployment authorization expired. Reconnect Cloudflare and select this installation’s account, then return here.", select: "Reconnect Cloudflare or select the account that owns this installation before updating.", error: "The update status is unavailable. Reload to check the saved request; no automatic retry will be made.", }; return ( <ControlPlaneLayout title="Update your Flarebot" description="Install a compatible release in your Cloudflare account and follow its progress." page="Update installation" > <LayerCard className="connect-card"> <div className="intro installation-intro"> <h2>Update status</h2> {returnUrl && ["success", "current"].includes(view) && ( <LinkButton variant="primary" href={returnUrl}> {view === "success" ? "Return to Flarebot" : "Open Flarebot"} </LinkButton> )} </div> <p role={view === "error" || view === "failed" ? "alert" : "status"}> {text[view]} </p> {view === "updating" && query.error && detail && ( <p role="alert"> Could not refresh update status. The last available progress is shown; reconnect to check again. </p> )} {["checking", "updating", "success", "current", "failed"].includes( view, ) && ( <InstallationProgress updating installation={ view === "checking" || (view === "updating" && detail?.installation.status === "ready") ? null : (detail?.installation ?? null) } /> )} <div className="actions"> {["reconnect", "select"].includes(view) && ( <form action="/auth/start" method="post"> <Button type="submit">Reconnect Cloudflare</Button> </form> )} <Button variant="secondary" onClick={() => location.assign("/connect")} > Installation settings </Button> </div> {view === "select" && connection && ( <p>Required account: {detail?.installation.accountId}</p> )} </LayerCard> </ControlPlaneLayout> );}