Skip to content

Commit ae22eb3

Browse files
authored
Settings, System: Check for updates, Checked just now, and Update installs the newest release (#310)
The Version section shows when the install last read GitHub's release list and a Check for updates button (POST /api/system/releases/check). Update now sends no version, so the updater reads the list first and installs the newest release at that moment. A newer release GitHub doesn't list yet shows as such, with no Update button for an older one.
1 parent dcc848d commit ae22eb3

3 files changed

Lines changed: 128 additions & 6 deletions

File tree

‎dashboard/e2e/selfhost.spec.ts‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ function domainAnswer(code: string) {
9696
};
9797
}
9898

99-
async function setup(page: Page, cfg: RuntimeConfig | null, opts: { native?: unknown; signedIn?: boolean; provider?: "anthropic" | "bedrock"; blocked?: boolean; domain?: string; history?: unknown[]; version?: Record<string, unknown>; blockers?: unknown[]; onlyBlockers?: unknown[] } = {}) {
99+
async function setup(page: Page, cfg: RuntimeConfig | null, opts: { native?: unknown; signedIn?: boolean; provider?: "anthropic" | "bedrock"; blocked?: boolean; domain?: string; history?: unknown[]; version?: Record<string, unknown>; checked?: Record<string, unknown>; blockers?: unknown[]; onlyBlockers?: unknown[] } = {}) {
100100
const calls: string[] = [];
101101
const domainState = { code: opts.domain ?? "none" };
102102
const checksState = { fixed: false, runs: 0 };
@@ -128,7 +128,8 @@ async function setup(page: Page, cfg: RuntimeConfig | null, opts: { native?: unk
128128
deployment_provider: opts.provider ?? "anthropic",
129129
});
130130
}
131-
if (p === "/api/system/version") {
131+
if (p === "/api/system/version" || p === "/api/system/releases/check") {
132+
if (p !== "/api/system/version") systemCalls.push({ path: p, body: JSON.parse(req.postData() || "{}") });
132133
return json(route, {
133134
self_hosted: true,
134135
current: { version: "v1.2.0", commit: "abc1234def", released_at: "2026-10-01T00:00:00Z", config_fingerprint: "0123456789ab" },
@@ -142,6 +143,7 @@ async function setup(page: Page, cfg: RuntimeConfig | null, opts: { native?: unk
142143
{ id: "UPDATE#0", version: "v1.1.5", from_version: "v1.1.0", actor: "owner@example.org", mode: "update", status: "rolled_back", started_at: "2026-10-02T03:00:00Z", reason: "health check failed after deploying v1.1.5; back on v1.1.0" },
143144
],
144145
...(opts.version ?? {}),
146+
...(p === "/api/system/version" ? {} : (opts.checked ?? {})),
145147
});
146148
}
147149
if (p === "/api/system/auto-update") {
@@ -297,7 +299,8 @@ for (const [name, viewport] of [["desktop", { width: 1280, height: 800 }], ["pho
297299

298300
await page.getByRole("button", { name: "Update now to v2.0.0" }).click();
299301
await expect.poll(() => systemCalls.map((c) => c.path)).toContain("/api/system/update");
300-
expect(systemCalls.find((c) => c.path === "/api/system/update")!.body).toEqual({ version: "v2.0.0" });
302+
// No version: the updater reads GitHub first and installs the newest release at that moment.
303+
expect(systemCalls.find((c) => c.path === "/api/system/update")!.body).toEqual({});
301304

302305
await page.getByRole("switch", { name: "Install updates automatically" }).click();
303306
await pickOption(page.getByRole("combobox", { name: "Day" }), "sat");
@@ -551,7 +554,7 @@ test("System: a one-stack install's Update applies it, and shows it running", as
551554
await expect(page.getByRole("button", { name: "Prepare the update to v2.0.0" })).toHaveCount(0);
552555
await page.getByRole("button", { name: "Update to v2.0.0" }).click();
553556
await expect.poll(() => systemCalls.map((c) => c.path)).toContain("/api/system/update");
554-
expect(systemCalls.find((c) => c.path === "/api/system/update")!.body).toEqual({ version: "v2.0.0" });
557+
expect(systemCalls.find((c) => c.path === "/api/system/update")!.body).toEqual({});
555558
});
556559

557560
test("System: a running one-stack update says so, with nothing to do and no console step", async ({ page }) => {
@@ -756,6 +759,55 @@ for (const [name, viewport] of [["desktop", { width: 1280, height: 800 }], ["pho
756759
await page.screenshot({ path: `${SHOTS}/system-version-updating-${name}.png`, fullPage: true });
757760
});
758761

762+
test("Check for updates reads GitHub now and shows the newer release, Checked just now", async ({ page }) => {
763+
const hourAgo = Math.floor(Date.now() / 1000) - 31 * 60;
764+
const { systemCalls } = await setup(page, SELF, {
765+
signedIn: true,
766+
onlyBlockers: [],
767+
version: { latest: null, update_available: false, releases: { fetched_at: hourAgo, stale: false, reason: null, error: null, behind: null } },
768+
checked: {
769+
latest: { version: "v1.3.0", notes: "## Changes\n\n- Fixes.", published_at: "2026-11-04T17:22:00Z", url: "https://example.org/r", breaking: false },
770+
update_available: true,
771+
releases: { fetched_at: Math.floor(Date.now() / 1000), stale: false, reason: null, error: null, behind: null },
772+
},
773+
});
774+
await page.goto("/settings/system/");
775+
const status = page.getByTestId("update-status");
776+
await expect(status).toHaveAttribute("data-state", "current");
777+
const check = page.getByTestId("release-check");
778+
await expect(check).toContainText("Checked 31 min ago");
779+
await check.getByRole("button", { name: "Check for updates" }).click();
780+
await expect(check).toContainText("Checked just now");
781+
expect(systemCalls.filter((c) => c.path === "/api/system/releases/check")).toHaveLength(1);
782+
await expect(status).toHaveAttribute("data-state", "available");
783+
await expect(status.getByRole("button", { name: "Update now to v1.3.0" })).toBeEnabled();
784+
const width = await page.evaluate(() => document.documentElement.scrollWidth);
785+
expect(width).toBeLessThanOrEqual(viewport.width);
786+
await page.screenshot({ path: `${SHOTS}/system-version-checked-${name}.png`, fullPage: true });
787+
});
788+
789+
test("a newer release GitHub doesn't list yet: no older Update button, and a failed check says so", async ({ page }) => {
790+
await setup(page, SELF, {
791+
signedIn: true,
792+
onlyBlockers: [],
793+
version: {
794+
latest: null,
795+
update_available: false,
796+
releases: {
797+
fetched_at: Math.floor(Date.now() / 1000) - 600, stale: false, reason: null, error: "GitHub is down",
798+
behind: { version: "v1.3.0", reason: "v1.3.0 is out, but GitHub's release list doesn't show it yet; it shows here once it does" },
799+
},
800+
},
801+
});
802+
await page.goto("/settings/system/");
803+
const status = page.getByTestId("update-status");
804+
await expect(status).toHaveAttribute("data-state", "behind");
805+
await expect(status).toContainText("v1.3.0 is out");
806+
await expect(page.locator("#version").getByRole("button", { name: /^Update/ })).toHaveCount(0);
807+
await expect(page.getByTestId("release-check")).toContainText("Checked 10 min ago");
808+
await expect(page.getByTestId("release-check-error")).toContainText("couldn't reach GitHub");
809+
});
810+
759811
test("up to date: green, with the version", async ({ page }) => {
760812
await setup(page, SELF, { signedIn: true, version: { latest: null, update_available: false }, onlyBlockers: [] });
761813
await page.goto("/settings/system/");

‎dashboard/src/app/settings/system/page.tsx‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,10 @@ function System() {
199199
setBusy(kind);
200200
try {
201201
if (kind === "update") {
202-
const r = await api.updateNow(latest?.version, skipCheck);
202+
// No version: the updater reads GitHub first and installs the newest
203+
// release at this moment. Skipping a model check names the release
204+
// whose check the admin read.
205+
const r = await api.updateNow(skipCheck ? data.model_check?.version : undefined, skipCheck);
203206
if (!r.ok) {
204207
// A model the release calls was refused: show which, with its fix.
205208
if (r.model_check) setData({ ...data, model_check: r.model_check });
@@ -237,6 +240,7 @@ function System() {
237240
<SettingsSection id="version" title="Version">
238241
<div className="min-w-0 space-y-3 px-4 py-4">
239242
<UpdateStatus data={data} failure={failure} blocked={blocked} busy={busy} onUpdate={() => start("update")} onChanged={load} />
243+
{!data.running && !pending && <ReleaseCheck data={data} onChecked={setData} />}
240244
<SettingsMore summary="Technical details">
241245
<VersionDetails version={data} autoUpdates={!!data.settings?.auto} failure={failure} />
242246
</SettingsMore>
@@ -564,6 +568,17 @@ function UpdateStatus({
564568
);
565569
}
566570

571+
const behind = data.releases?.behind;
572+
if (behind && current) {
573+
return (
574+
<div data-testid="update-status" data-state="behind">
575+
<Callout variant="info" title={<>{behind.version} is out</>}>
576+
<p>You&apos;re on {current}. GitHub&apos;s release list doesn&apos;t show {behind.version} yet, so nothing older is offered meanwhile. It shows here, with Update, once it does: Check for updates in a minute.</p>
577+
</Callout>
578+
</div>
579+
);
580+
}
581+
567582
if (!current) {
568583
return (
569584
<div data-testid="update-status" data-state="development">
@@ -583,6 +598,46 @@ function UpdateStatus({
583598
);
584599
}
585600

601+
/**
602+
* When this install last read GitHub's release list, and Check for updates
603+
* to read it now (the updater reads at most once per 30 seconds).
604+
*/
605+
function ReleaseCheck({ data, onChecked }: { data: SystemVersion; onChecked: (v: SystemVersion) => void }) {
606+
const { toast } = useToast();
607+
const [checking, setChecking] = useState(false);
608+
// "just now" ages into "2 min ago" while the page stays open.
609+
const [, tick] = useState(0);
610+
useEffect(() => {
611+
const t = window.setInterval(() => tick((n) => n + 1), 30_000);
612+
return () => window.clearInterval(t);
613+
}, []);
614+
const r = data.releases;
615+
const at = r?.fetched_at ? new Date(r.fetched_at * 1000).toISOString() : null;
616+
const check = async () => {
617+
setChecking(true);
618+
try {
619+
onChecked(await api.checkReleases());
620+
} catch (e) {
621+
toast(e instanceof Error ? e.message : "Could not check for updates", "error");
622+
} finally {
623+
setChecking(false);
624+
}
625+
};
626+
return (
627+
<div data-testid="release-check" className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-zinc-400">
628+
<span data-testid="release-checked" aria-live="polite">
629+
{checking ? "Checking for updates…" : at ? <>Checked <Time iso={at} /></> : "Not checked yet"}
630+
</span>
631+
<button type="button" className={buttonClass("secondary", "sm")} onClick={check} disabled={checking}>
632+
Check for updates
633+
</button>
634+
{r?.error && !checking && (
635+
<p className="w-full text-amber-300" data-testid="release-check-error">The last check couldn&apos;t reach GitHub, so this is the list from then.</p>
636+
)}
637+
</div>
638+
);
639+
}
640+
586641
/** Version details: what this version is, how updates arrive, and the facts that tell this install apart (only those known). */
587642
function VersionDetails({ version, autoUpdates, failure }: { version: SystemVersion; autoUpdates: boolean; failure: FailedTry | null }) {
588643
const raw = failure?.reason && isRawReason(failure.reason) ? failure.reason : null;

‎dashboard/src/lib/api.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,8 @@ export const api = {
12461246
getIntegrations: () => request<IntegrationsResponse>("/api/integrations"),
12471247
/** Self-hosted installs (install admins): this build, the latest release, settings, history. */
12481248
getSystemVersion: () => request<SystemVersion>("/api/system/version"),
1249+
/** Check for updates: the updater reads GitHub's release list now (at most once per 30 seconds), and answers with the same status. */
1250+
checkReleases: () => request<SystemVersion>("/api/system/releases/check", { method: "POST", body: "{}" }),
12491251
setAutoUpdate: (settings: Partial<AutoUpdateSettings>) =>
12501252
request<{ settings: AutoUpdateSettings }>("/api/system/auto-update", { method: "PUT", body: JSON.stringify(settings) }),
12511253
/**
@@ -1255,7 +1257,8 @@ export const api = {
12551257
setUninstallMode: (deleteData: boolean, confirm = "") =>
12561258
request<{ ok: boolean; uninstall: UninstallMode }>("/api/system/uninstall-mode", { method: "PUT", body: JSON.stringify({ delete_data: deleteData, confirm }) }),
12571259
/**
1258-
* Update to `version` (default: the latest). Refused with the model check
1260+
* Update to `version` (default: the newest release, read from GitHub at
1261+
* that moment, never the copy the page showed). Refused with the model check
12591262
* when a model the release calls was refused; `skipCheck` passes only a
12601263
* failure on a model the release doesn't call.
12611264
*/
@@ -4252,6 +4255,8 @@ export interface SystemVersion {
42524255
current: { version: string | null; commit: string | null; released_at: string | null; config_fingerprint: string | null };
42534256
latest: null | { version: string; notes: string; published_at?: string; url?: string; prerelease?: boolean; breaking: boolean };
42544257
update_available: boolean;
4258+
/** The release list: when GitHub last answered (unix seconds), and a newer release it doesn't show yet. */
4259+
releases?: ReleaseFreshness;
42554260
settings?: AutoUpdateSettings;
42564261
running?: boolean;
42574262
history?: UpdateEntry[];
@@ -4265,6 +4270,16 @@ export interface SystemVersion {
42654270
uninstall?: UninstallMode | null;
42664271
}
42674272

4273+
export interface ReleaseFreshness {
4274+
fetched_at: number | null;
4275+
stale: boolean;
4276+
reason: string | null;
4277+
/** The last try to read the list failed (GitHub didn't answer); the list is from fetched_at. */
4278+
error?: string | null;
4279+
/** A release launch/latest.json names that GitHub's list doesn't show yet: nothing older is offered meanwhile. */
4280+
behind?: { version: string; reason: string } | null;
4281+
}
4282+
42684283
export interface UninstallMode {
42694284
delete_data: boolean;
42704285
changed_by: string | null;

0 commit comments

Comments
 (0)