diff --git a/apps/web/src/components/app/release-admin-banners.tsx b/apps/web/src/components/app/release-admin-banners.tsx
new file mode 100644
index 00000000..a9ad2d8c
--- /dev/null
+++ b/apps/web/src/components/app/release-admin-banners.tsx
@@ -0,0 +1,91 @@
+import { CheckCircle2, XCircle } from "lucide-react";
+import { cleanError } from "@/components/app/release-utils";
+import { ActivityBars } from "@/components/ui/activity-bars";
+import { Button } from "@/components/ui/button";
+import type { ReleaseJob } from "@/hooks/use-release-stream";
+
+type ReleaseJobBannersProps = {
+ job: ReleaseJob;
+ releaseInFlight: boolean;
+ isFailed: boolean;
+ isDone: boolean;
+ watchedRelease: boolean;
+ onShowProgress: () => void;
+ onDismiss: () => void;
+};
+
+/**
+ * In-flight / failed / finished banners for a release-create job. The page
+ * stays usable behind them — each banner just links into the progress view.
+ */
+export function ReleaseJobBanners({
+ job,
+ releaseInFlight,
+ isFailed,
+ isDone,
+ watchedRelease,
+ onShowProgress,
+ onDismiss,
+}: ReleaseJobBannersProps): JSX.Element {
+ return (
+ <>
+ {releaseInFlight && (
+
+
+
+ Releasing{" "}
+ {job.versionType}
+ {job.tag && (
+ <>
+ {" "}
+ {job.tag}
+ >
+ )}{" "}
+ — {job.phase}
+
+
+
+ )}
+ {isFailed && (
+
+
+
+ Release failed
+ {job.error ? `: ${cleanError(job.error)}` : ""}
+
+
+
+
+
+
+ )}
+ {isDone && watchedRelease && (
+
+
+
+ Released {job.tag}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/components/app/release-admin-create.tsx b/apps/web/src/components/app/release-admin-create.tsx
new file mode 100644
index 00000000..cef07d7e
--- /dev/null
+++ b/apps/web/src/components/app/release-admin-create.tsx
@@ -0,0 +1,136 @@
+import { ShieldCheck, Sparkles, Zap } from "lucide-react";
+import { bumpVersion } from "@/components/app/release-utils";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import type {
+ ReleaseInfo,
+ ReleaseVersionType,
+} from "@/hooks/use-release-stream";
+import { cn } from "@/lib/utils";
+
+const VERSION_CONFIG: Record<
+ ReleaseVersionType,
+ { icon: typeof ShieldCheck; color: string }
+> = {
+ patch: { icon: ShieldCheck, color: "text-status-working" },
+ minor: { icon: Sparkles, color: "text-status-done" },
+ major: { icon: Zap, color: "text-violet-400" },
+};
+
+type CreateReleaseSectionProps = {
+ info: ReleaseInfo | null;
+ bumpBase: string | null;
+ releaseError: string | null;
+ releaseInFlight: boolean;
+ confirmType: ReleaseVersionType | null;
+ onConfirmTypeChange: (type: ReleaseVersionType | null) => void;
+ onRelease: (versionType: ReleaseVersionType) => void;
+};
+
+/**
+ * The "Create release" section — one button per version bump plus the
+ * confirmation dialog. Hidden entirely when there is nothing to release.
+ */
+export function CreateReleaseSection({
+ info,
+ bumpBase,
+ releaseError,
+ releaseInFlight,
+ confirmType,
+ onConfirmTypeChange,
+ onRelease,
+}: CreateReleaseSectionProps): JSX.Element | null {
+ if (
+ !info ||
+ !(
+ info.refMissing ||
+ Boolean(info.unreleasedFetchError) ||
+ info.unreleasedCount > 0
+ )
+ ) {
+ return null;
+ }
+
+ return (
+
+
+ Create release
+
+
+ {releaseError && (
+
+ {releaseError}
+
+ )}
+
+
+ {(["patch", "minor", "major"] as ReleaseVersionType[]).map((type) => {
+ const { icon: Icon, color } = VERSION_CONFIG[type];
+ const nextTag = bumpVersion(bumpBase, type);
+ return (
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/app/release-admin-list.tsx b/apps/web/src/components/app/release-admin-list.tsx
new file mode 100644
index 00000000..d2eff386
--- /dev/null
+++ b/apps/web/src/components/app/release-admin-list.tsx
@@ -0,0 +1,131 @@
+import { CheckCircle2, ExternalLink } from "lucide-react";
+import type { GitHubRelease } from "@/components/app/release-utils";
+import { ActivityBars } from "@/components/ui/activity-bars";
+import { Button } from "@/components/ui/button";
+import { formatShortDateTime } from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+type RecentReleasesProps = {
+ releases: GitHubRelease[];
+ releasesLoading: boolean;
+ promoteError: string | null;
+ promotingTag: string | null;
+ confirmPromoteTag: string | null;
+ onConfirmPromoteTagChange: (tag: string | null) => void;
+ onPromote: (tag: string) => void;
+};
+
+/**
+ * The "Recent releases" list, with the inline promote-to-stable confirm
+ * flow for prereleases.
+ */
+export function RecentReleases({
+ releases,
+ releasesLoading,
+ promoteError,
+ promotingTag,
+ confirmPromoteTag,
+ onConfirmPromoteTagChange,
+ onPromote,
+}: RecentReleasesProps): JSX.Element {
+ return (
+
+
+ Recent releases
+
+
+ {promoteError && (
+
+ {promoteError}
+
+ )}
+
+ {releasesLoading && releases.length === 0 && (
+
+ )}
+
+ {releases.length > 0 && (
+
+ {releases.map((r) => (
+
+
+ {r.tag}
+
+
+ {r.isPrerelease ? "pre-release" : "stable"}
+
+
+ {formatShortDateTime(r.publishedAt)}
+
+
+ {r.isPrerelease && confirmPromoteTag !== r.tag && (
+
+ )}
+ {r.isPrerelease && confirmPromoteTag === r.tag && (
+ <>
+
+ Mark stable + latest?
+
+
+
+ >
+ )}
+ {!r.isPrerelease && (
+
+ )}
+
+
+
+
+
+ ))}
+
+ )}
+
+ {!releasesLoading && releases.length === 0 && (
+
No releases found
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/app/release-admin-unreleased.tsx b/apps/web/src/components/app/release-admin-unreleased.tsx
new file mode 100644
index 00000000..4db7cdd7
--- /dev/null
+++ b/apps/web/src/components/app/release-admin-unreleased.tsx
@@ -0,0 +1,121 @@
+import { RefreshCw } from "lucide-react";
+import { cleanError, formatAgo } from "@/components/app/release-utils";
+import { ActivityBars } from "@/components/ui/activity-bars";
+import { Button } from "@/components/ui/button";
+import type { ReleaseInfo, ReleaseProgress } from "@/hooks/use-release-stream";
+import { cn } from "@/lib/utils";
+
+type UnreleasedChangesProps = {
+ info: ReleaseInfo | null;
+ infoLoading: boolean;
+ infoError: string | null;
+ infoProgress: ReleaseProgress | null;
+ lastCheckedAt: number | null;
+ now: number;
+ onRefresh: () => void;
+};
+
+/** The "Unreleased changes" section: commit count, commit list, and errors. */
+export function UnreleasedChanges({
+ info,
+ infoLoading,
+ infoError,
+ infoProgress,
+ lastCheckedAt,
+ now,
+ onRefresh,
+}: UnreleasedChangesProps): JSX.Element {
+ return (
+
+
+
+ Unreleased changes
+
+
+ {lastCheckedAt !== null && !infoLoading && (
+
+ Checked {formatAgo(lastCheckedAt, now)}
+
+ )}
+
+
+
+
+ {infoLoading && (
+
+
+ {infoProgress?.label ?? "Loading..."}
+ {infoProgress?.detail && (
+
+ {infoProgress.detail}
+
+ )}
+
+ )}
+
+ {infoError && (
+
+ {infoError}
+
+ )}
+
+ {info && !infoLoading && (
+ <>
+ {info.unreleasedFetchError ? (
+
+ Couldn't refresh
origin/main —
+ unreleased commit info is unavailable, not necessarily zero.
+
+ {cleanError(info.unreleasedFetchError)}
+
+
+ ) : info.refMissing ? (
+
+ Deployed version{" "}
+ {info.currentTag ?? "unknown"}{" "}
+ not found in origin — commit count unavailable.
+
+ ) : info.unreleasedCount === 0 ? (
+
+ No unreleased commits on main
+
+ ) : (
+
+
+ {info.unreleasedCount} unreleased{" "}
+ {info.unreleasedCount === 1 ? "commit" : "commits"} on{" "}
+ main
+
+
+ {info.commits.map((c) => (
+
+
+ {c.sha}
+
+ {c.subject}
+
+ ))}
+ {info.unreleasedCount > info.commits.length && (
+
+ +{info.unreleasedCount - info.commits.length} more
+
+ )}
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/app/release-admin.tsx b/apps/web/src/components/app/release-admin.tsx
index 1e1764c6..ce8f1197 100644
--- a/apps/web/src/components/app/release-admin.tsx
+++ b/apps/web/src/components/app/release-admin.tsx
@@ -1,81 +1,15 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import {
- ArrowLeft,
- CheckCircle2,
- ExternalLink,
- RefreshCw,
- ShieldCheck,
- Sparkles,
- XCircle,
- Zap,
-} from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+import { ArrowLeft } from "lucide-react";
+import { ReleaseJobBanners } from "@/components/app/release-admin-banners";
+import { CreateReleaseSection } from "@/components/app/release-admin-create";
+import { RecentReleases } from "@/components/app/release-admin-list";
+import { UnreleasedChanges } from "@/components/app/release-admin-unreleased";
import { OperationTakeover } from "@/components/app/release-operation-takeover";
-import { cleanError } from "@/components/app/release-utils";
-import { formatShortDateTime } from "@/lib/format";
-import { ActivityBars } from "@/components/ui/activity-bars";
+import { CREATE_PHASES, cleanError } from "@/components/app/release-utils";
+import { useReleaseAdminData } from "@/components/app/use-release-admin-data";
import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import type {
- ReleaseInfo,
- ReleaseVersionType,
-} from "@/hooks/use-release-stream";
+import type { ReleaseVersionType } from "@/hooks/use-release-stream";
import { useReleaseStream } from "@/hooks/use-release-stream";
-import { cn } from "@/lib/utils";
-
-type GitHubRelease = {
- tag: string;
- publishedAt: string;
- isPrerelease: boolean;
- url: string;
-};
-
-const VERSION_CONFIG: Record<
- ReleaseVersionType,
- { icon: typeof ShieldCheck; color: string }
-> = {
- patch: { icon: ShieldCheck, color: "text-status-working" },
- minor: { icon: Sparkles, color: "text-status-done" },
- major: { icon: Zap, color: "text-violet-400" },
-};
-
-const CREATE_PHASES = ["preflight", "triggering", "watching", "done"] as const;
-
-/**
- * Predict the tag the release workflow will cut for a version bump.
- * Exact stable tags only — the workflow bumps from main's package
- * version, so a prerelease-suffixed base would predict the wrong tag;
- * better to show no preview than a false promise.
- */
-function bumpVersion(
- base: string | null,
- type: ReleaseVersionType
-): string | null {
- if (!base) return null;
- const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(base);
- if (!match) return null;
- const major = Number(match[1]);
- const minor = Number(match[2]);
- const patch = Number(match[3]);
- if (type === "major") return `v${major + 1}.0.0`;
- if (type === "minor") return `v${major}.${minor + 1}.0`;
- return `v${major}.${minor}.${patch + 1}`;
-}
-
-function formatAgo(ts: number, now: number): string {
- const seconds = Math.max(0, Math.floor((now - ts) / 1000));
- if (seconds < 10) return "just now";
- if (seconds < 60) return `${seconds}s ago`;
- const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m ago`;
- const hours = Math.floor(minutes / 60);
- return `${hours}h ago`;
-}
export function ReleasesAdmin(): JSX.Element {
const stream = useReleaseStream("create");
@@ -89,77 +23,27 @@ export function ReleasesAdmin(): JSX.Element {
streamClientId,
} = stream;
- const [info, setInfo] = useState(null);
- const [infoLoading, setInfoLoading] = useState(false);
- const [infoError, setInfoError] = useState(null);
- const [lastCheckedAt, setLastCheckedAt] = useState(null);
- const [now, setNow] = useState(() => Date.now());
+ const {
+ info,
+ infoLoading,
+ infoError,
+ lastCheckedAt,
+ now,
+ releases,
+ releasesLoading,
+ promotingTag,
+ confirmPromoteTag,
+ promoteError,
+ setConfirmPromoteTag,
+ refresh,
+ promote,
+ } = useReleaseAdminData(streamClientId);
+
const [releaseError, setReleaseError] = useState(null);
const [confirmType, setConfirmType] = useState(
null
);
const [showProgress, setShowProgress] = useState(false);
- const [releases, setReleases] = useState([]);
- const [releasesLoading, setReleasesLoading] = useState(false);
- const [promotingTag, setPromotingTag] = useState(null);
- const [confirmPromoteTag, setConfirmPromoteTag] = useState(
- null
- );
- const [promoteError, setPromoteError] = useState(null);
-
- const fetchInfo = useCallback(async () => {
- setInfoLoading(true);
- setInfoError(null);
- try {
- // The client id lets the server stream info-progress events for
- // this request over the release SSE stream while git/GitHub run.
- const res = await fetch("/api/v1/release/info", {
- headers: { "x-dispatch-release-client-id": streamClientId },
- });
- if (!res.ok) {
- const err = (await res.json()) as { error?: string };
- setInfoError(cleanError(err.error ?? "Failed to load release info"));
- return;
- }
- setInfo((await res.json()) as ReleaseInfo);
- setLastCheckedAt(Date.now());
- setNow(Date.now());
- } catch (err) {
- setInfoError(
- err instanceof Error
- ? cleanError(err.message)
- : "Failed to load release info"
- );
- } finally {
- setInfoLoading(false);
- }
- }, [streamClientId]);
-
- const fetchReleases = useCallback(async () => {
- setReleasesLoading(true);
- try {
- const res = await fetch("/api/v1/releases");
- if (res.ok) {
- const data = (await res.json()) as { releases: GitHubRelease[] };
- setReleases(data.releases);
- }
- } catch {
- /* ignore */
- } finally {
- setReleasesLoading(false);
- }
- }, []);
-
- useEffect(() => {
- void fetchInfo();
- void fetchReleases();
- }, [fetchInfo, fetchReleases]);
-
- // Tick so the "checked Xs ago" label stays honest without refetching.
- useEffect(() => {
- const timer = setInterval(() => setNow(Date.now()), 30_000);
- return () => clearInterval(timer);
- }, []);
const createJob = job?.jobType === "create" ? job : null;
const isDone = createJob?.phase === "done";
@@ -187,10 +71,9 @@ export function ReleasesAdmin(): JSX.Element {
handledDoneRef.current !== createJob.startedAt
) {
handledDoneRef.current = createJob.startedAt;
- void fetchInfo();
- void fetchReleases();
+ refresh();
}
- }, [isDone, watchedRelease, createJob, fetchInfo, fetchReleases]);
+ }, [isDone, watchedRelease, createJob, refresh]);
const handleRelease = async (versionType: ReleaseVersionType) => {
setReleaseError(null);
@@ -220,35 +103,6 @@ export function ReleasesAdmin(): JSX.Element {
setShowProgress(true);
};
- const handlePromote = async (tag: string) => {
- setPromotingTag(tag);
- setConfirmPromoteTag(null);
- setPromoteError(null);
- try {
- const res = await fetch("/api/v1/release/promote", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ tag }),
- });
- if (res.ok) {
- setReleases((prev) =>
- prev.map((r) => (r.tag === tag ? { ...r, isPrerelease: false } : r))
- );
- } else {
- const err = (await res.json()) as { error?: string };
- setPromoteError(cleanError(err.error ?? `Failed to promote ${tag}`));
- }
- } catch (err) {
- setPromoteError(
- err instanceof Error
- ? cleanError(err.message)
- : `Failed to promote ${tag}`
- );
- } finally {
- setPromotingTag(null);
- }
- };
-
// The most recent GitHub release (prereleases included) is what the
// release workflow bumps from; fall back to the deployed tag.
const bumpBase = releases[0]?.tag ?? info?.currentTag ?? null;
@@ -295,353 +149,49 @@ export function ReleasesAdmin(): JSX.Element {
return (
{/* In-flight / finished release banner — page stays usable */}
- {createJob && releaseInFlight && (
-
-
-
- Releasing{" "}
-
- {createJob.versionType}
-
- {createJob.tag && (
- <>
- {" "}
- {createJob.tag}
- >
- )}{" "}
- — {createJob.phase}
-
-
-
- )}
- {createJob && isFailed && (
-
-
-
- Release failed
- {createJob.error ? `: ${cleanError(createJob.error)}` : ""}
-
-
-
-
-
-
+ {createJob && (
+
setShowProgress(true)}
+ onDismiss={dismissJob}
+ />
)}
- {createJob && isDone && watchedRelease && (
-
-
-
- Released{" "}
- {createJob.tag}
-
-
-
- )}
-
- {/* Unreleased commits */}
-
-
-
- Unreleased changes
-
-
- {lastCheckedAt !== null && !infoLoading && (
-
- Checked {formatAgo(lastCheckedAt, now)}
-
- )}
-
-
-
-
- {infoLoading && (
-
-
- {infoProgress?.label ?? "Loading..."}
- {infoProgress?.detail && (
-
- {infoProgress.detail}
-
- )}
-
- )}
- {infoError && (
-
- {infoError}
-
- )}
-
- {info && !infoLoading && (
- <>
- {info.unreleasedFetchError ? (
-
- Couldn't refresh
origin/main{" "}
- — unreleased commit info is unavailable, not necessarily zero.
-
- {cleanError(info.unreleasedFetchError)}
-
-
- ) : info.refMissing ? (
-
- Deployed version{" "}
-
- {info.currentTag ?? "unknown"}
- {" "}
- not found in origin — commit count unavailable.
-
- ) : info.unreleasedCount === 0 ? (
-
- No unreleased commits on main
-
- ) : (
-
-
- {info.unreleasedCount} unreleased{" "}
- {info.unreleasedCount === 1 ? "commit" : "commits"} on{" "}
- main
-
-
- {info.commits.map((c) => (
-
-
- {c.sha}
-
- {c.subject}
-
- ))}
- {info.unreleasedCount > info.commits.length && (
-
- +{info.unreleasedCount - info.commits.length} more
-
- )}
-
-
- )}
- >
- )}
-
-
- {/* Create release */}
- {info &&
- (info.refMissing ||
- Boolean(info.unreleasedFetchError) ||
- info.unreleasedCount > 0) && (
-
-
- Create release
-
-
- {releaseError && (
-
- {releaseError}
-
- )}
-
-
- {(["patch", "minor", "major"] as ReleaseVersionType[]).map(
- (type) => {
- const { icon: Icon, color } = VERSION_CONFIG[type];
- const nextTag = bumpVersion(bumpBase, type);
- return (
-
- );
- }
- )}
-
-
-
-
- )}
+
+
+ void handleRelease(versionType)}
+ />
- {/* Recent releases */}
-
-
- Recent releases
-
-
- {promoteError && (
-
- {promoteError}
-
- )}
-
- {releasesLoading && releases.length === 0 && (
-
- )}
-
- {releases.length > 0 && (
-
- {releases.map((r) => (
-
-
- {r.tag}
-
-
- {r.isPrerelease ? "pre-release" : "stable"}
-
-
- {formatShortDateTime(r.publishedAt)}
-
-
- {r.isPrerelease && confirmPromoteTag !== r.tag && (
-
- )}
- {r.isPrerelease && confirmPromoteTag === r.tag && (
- <>
-
- Mark stable + latest?
-
-
-
- >
- )}
- {!r.isPrerelease && (
-
- )}
-
-
-
-
-
- ))}
-
- )}
-
- {!releasesLoading && releases.length === 0 && (
-
No releases found
- )}
-
+ void promote(tag)}
+ />
);
}
diff --git a/apps/web/src/components/app/release-utils.test.ts b/apps/web/src/components/app/release-utils.test.ts
index 9a2fe5c1..e5653ec7 100644
--- a/apps/web/src/components/app/release-utils.test.ts
+++ b/apps/web/src/components/app/release-utils.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, it } from "vitest";
import {
+ bumpVersion,
cleanError,
describeForceTriggers,
+ formatAgo,
formatBytes,
formatInlineProgress,
formatProgressLabel,
@@ -284,3 +286,40 @@ describe("isAssistedPreferred", () => {
).toBe(false);
});
});
+
+describe("bumpVersion", () => {
+ it("bumps patch, minor, and major from a stable tag", () => {
+ expect(bumpVersion("v1.2.3", "patch")).toBe("v1.2.4");
+ expect(bumpVersion("v1.2.3", "minor")).toBe("v1.3.0");
+ expect(bumpVersion("v1.2.3", "major")).toBe("v2.0.0");
+ });
+
+ it("accepts a base without the v prefix", () => {
+ expect(bumpVersion("0.34.3", "patch")).toBe("v0.34.4");
+ });
+
+ it("returns null for a missing or prerelease-suffixed base", () => {
+ expect(bumpVersion(null, "patch")).toBeNull();
+ expect(bumpVersion("v1.2.3-rc.1", "patch")).toBeNull();
+ expect(bumpVersion("nightly", "minor")).toBeNull();
+ });
+});
+
+describe("formatAgo", () => {
+ const now = 1_000_000_000_000;
+
+ it("collapses the first ten seconds to 'just now'", () => {
+ expect(formatAgo(now, now)).toBe("just now");
+ expect(formatAgo(now - 9_000, now)).toBe("just now");
+ });
+
+ it("steps up through seconds, minutes, and hours", () => {
+ expect(formatAgo(now - 30_000, now)).toBe("30s ago");
+ expect(formatAgo(now - 90_000, now)).toBe("1m ago");
+ expect(formatAgo(now - 3 * 3_600_000, now)).toBe("3h ago");
+ });
+
+ it("clamps a future timestamp to 'just now'", () => {
+ expect(formatAgo(now + 5_000, now)).toBe("just now");
+ });
+});
diff --git a/apps/web/src/components/app/release-utils.ts b/apps/web/src/components/app/release-utils.ts
index b493f235..bf44544b 100644
--- a/apps/web/src/components/app/release-utils.ts
+++ b/apps/web/src/components/app/release-utils.ts
@@ -2,6 +2,7 @@ import type {
ReleaseInfo,
ReleaseJob,
ReleaseProgress,
+ ReleaseVersionType,
} from "@/hooks/use-release-stream";
import type { ReleaseInfoSnapshot } from "@/hooks/use-cached-release-info";
import { formatBytes } from "../../../../server/src/shared/lib/format-bytes";
@@ -16,6 +17,13 @@ export type AppVersionInfo = {
releaseUrl: string | null;
};
+export type GitHubRelease = {
+ tag: string;
+ publishedAt: string;
+ isPrerelease: boolean;
+ url: string;
+};
+
export const UPDATE_PHASES = [
"fetching",
"deploying",
@@ -23,6 +31,44 @@ export const UPDATE_PHASES = [
"done",
] as const;
+export const CREATE_PHASES = [
+ "preflight",
+ "triggering",
+ "watching",
+ "done",
+] as const;
+
+/**
+ * Predict the tag the release workflow will cut for a version bump.
+ * Exact stable tags only — the workflow bumps from main's package
+ * version, so a prerelease-suffixed base would predict the wrong tag;
+ * better to show no preview than a false promise.
+ */
+export function bumpVersion(
+ base: string | null,
+ type: ReleaseVersionType
+): string | null {
+ if (!base) return null;
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(base);
+ if (!match) return null;
+ const major = Number(match[1]);
+ const minor = Number(match[2]);
+ const patch = Number(match[3]);
+ if (type === "major") return `v${major + 1}.0.0`;
+ if (type === "minor") return `v${major}.${minor + 1}.0`;
+ return `v${major}.${minor}.${patch + 1}`;
+}
+
+export function formatAgo(ts: number, now: number): string {
+ const seconds = Math.max(0, Math.floor((now - ts) / 1000));
+ if (seconds < 10) return "just now";
+ if (seconds < 60) return `${seconds}s ago`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m ago`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h ago`;
+}
+
export function cleanError(raw: string): string {
const stderrMatch = raw.match(/stderr=(.+)$/s);
if (stderrMatch) {
diff --git a/apps/web/src/components/app/use-release-admin-data.ts b/apps/web/src/components/app/use-release-admin-data.ts
new file mode 100644
index 00000000..9edb832a
--- /dev/null
+++ b/apps/web/src/components/app/use-release-admin-data.ts
@@ -0,0 +1,150 @@
+import { useCallback, useEffect, useState } from "react";
+import type { GitHubRelease } from "@/components/app/release-utils";
+import { cleanError } from "@/components/app/release-utils";
+import type { ReleaseInfo } from "@/hooks/use-release-stream";
+
+export type UseReleaseAdminDataResult = {
+ info: ReleaseInfo | null;
+ infoLoading: boolean;
+ infoError: string | null;
+ lastCheckedAt: number | null;
+ now: number;
+ releases: GitHubRelease[];
+ releasesLoading: boolean;
+ promotingTag: string | null;
+ confirmPromoteTag: string | null;
+ promoteError: string | null;
+ setConfirmPromoteTag: (tag: string | null) => void;
+ refresh: () => void;
+ promote: (tag: string) => Promise;
+};
+
+/**
+ * Data layer for the admin Releases page: the unreleased-commit info, the
+ * recent GitHub releases list, and the promote-to-stable mutation that
+ * writes back into that list. Kept out of the render shell so the page
+ * components stay presentational.
+ *
+ * `streamClientId` comes from the release SSE stream so the server can push
+ * info-progress events for the in-flight /release/info request.
+ */
+export function useReleaseAdminData(
+ streamClientId: string
+): UseReleaseAdminDataResult {
+ const [info, setInfo] = useState(null);
+ const [infoLoading, setInfoLoading] = useState(false);
+ const [infoError, setInfoError] = useState(null);
+ const [lastCheckedAt, setLastCheckedAt] = useState(null);
+ const [now, setNow] = useState(() => Date.now());
+ const [releases, setReleases] = useState([]);
+ const [releasesLoading, setReleasesLoading] = useState(false);
+ const [promotingTag, setPromotingTag] = useState(null);
+ const [confirmPromoteTag, setConfirmPromoteTag] = useState(
+ null
+ );
+ const [promoteError, setPromoteError] = useState(null);
+
+ const fetchInfo = useCallback(async () => {
+ setInfoLoading(true);
+ setInfoError(null);
+ try {
+ // The client id lets the server stream info-progress events for
+ // this request over the release SSE stream while git/GitHub run.
+ const res = await fetch("/api/v1/release/info", {
+ headers: { "x-dispatch-release-client-id": streamClientId },
+ });
+ if (!res.ok) {
+ const err = (await res.json()) as { error?: string };
+ setInfoError(cleanError(err.error ?? "Failed to load release info"));
+ return;
+ }
+ setInfo((await res.json()) as ReleaseInfo);
+ setLastCheckedAt(Date.now());
+ setNow(Date.now());
+ } catch (err) {
+ setInfoError(
+ err instanceof Error
+ ? cleanError(err.message)
+ : "Failed to load release info"
+ );
+ } finally {
+ setInfoLoading(false);
+ }
+ }, [streamClientId]);
+
+ const fetchReleases = useCallback(async () => {
+ setReleasesLoading(true);
+ try {
+ const res = await fetch("/api/v1/releases");
+ if (res.ok) {
+ const data = (await res.json()) as { releases: GitHubRelease[] };
+ setReleases(data.releases);
+ }
+ } catch {
+ /* ignore */
+ } finally {
+ setReleasesLoading(false);
+ }
+ }, []);
+
+ const refresh = useCallback(() => {
+ void fetchInfo();
+ void fetchReleases();
+ }, [fetchInfo, fetchReleases]);
+
+ useEffect(() => {
+ void fetchInfo();
+ void fetchReleases();
+ }, [fetchInfo, fetchReleases]);
+
+ // Tick so the "checked Xs ago" label stays honest without refetching.
+ useEffect(() => {
+ const timer = setInterval(() => setNow(Date.now()), 30_000);
+ return () => clearInterval(timer);
+ }, []);
+
+ const promote = useCallback(async (tag: string) => {
+ setPromotingTag(tag);
+ setConfirmPromoteTag(null);
+ setPromoteError(null);
+ try {
+ const res = await fetch("/api/v1/release/promote", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tag }),
+ });
+ if (res.ok) {
+ setReleases((prev) =>
+ prev.map((r) => (r.tag === tag ? { ...r, isPrerelease: false } : r))
+ );
+ } else {
+ const err = (await res.json()) as { error?: string };
+ setPromoteError(cleanError(err.error ?? `Failed to promote ${tag}`));
+ }
+ } catch (err) {
+ setPromoteError(
+ err instanceof Error
+ ? cleanError(err.message)
+ : `Failed to promote ${tag}`
+ );
+ } finally {
+ setPromotingTag(null);
+ }
+ }, []);
+
+ return {
+ info,
+ infoLoading,
+ infoError,
+ lastCheckedAt,
+ now,
+ releases,
+ releasesLoading,
+ promotingTag,
+ confirmPromoteTag,
+ promoteError,
+ setConfirmPromoteTag,
+ refresh,
+ promote,
+ };
+}