diff --git a/apps/dashboard/src/api/amacrin/mock.ts b/apps/dashboard/src/api/amacrin/mock.ts index a242382..c1afa34 100644 --- a/apps/dashboard/src/api/amacrin/mock.ts +++ b/apps/dashboard/src/api/amacrin/mock.ts @@ -9,6 +9,8 @@ import { ApiError, SlugTakenError } from "@/api/http/errors"; import type { Archive } from "@/domain/archive"; import type { Build, ComponentBuild } from "@/domain/build"; import type { Deployment } from "@/domain/deployment"; +import type { OsaVersion } from "@/domain/osa-version"; +import { compareOsaVersions } from "@/domain/osa-version"; import type { Organisation } from "@/domain/organisation"; import type { BuildListItem, OrgMember } from "@/domain/tenant"; import type { Session } from "@/domain/user"; @@ -32,6 +34,32 @@ function notFound(what: string): ApiError { /** The OSA server version the mock control plane provisions. */ const MOCK_OSA_VERSION = "v0.0.9"; +/** The mock registry: one older deprecated, the pin, and two upgrades. */ +const MOCK_OSA_VERSIONS: OsaVersion[] = [ + { + version: "v0.0.11", + status: "supported", + isDefault: true, + notesUrl: + "https://github.com/opensciencearchive/server/releases/tag/v0.0.11", + }, + { + version: "v0.0.10", + status: "supported", + isDefault: false, + notesUrl: + "https://github.com/opensciencearchive/server/releases/tag/v0.0.10", + }, + { + version: "v0.0.9", + status: "supported", + isDefault: false, + notesUrl: + "https://github.com/opensciencearchive/server/releases/tag/v0.0.9", + }, + { version: "v0.0.8", status: "deprecated", isDefault: false, notesUrl: null }, +]; + /** The signed-in user, as a member of any org they create. */ const SELF_MEMBER: OrgMember = { userId: "user_mock000001", @@ -152,6 +180,10 @@ export class MockAmacrinService implements AmacrinService { organisationId: orgId, name: input.name, slug: input.slug, + // New archives are born on the registry default (the cloud rule). + osaVersionPin: + MOCK_OSA_VERSIONS.find((v) => v.isDefault)?.version ?? + MOCK_OSA_VERSION, domain: `${input.slug}.amacr.in`, status: { kind: "deploying" }, orcidAdmins: input.adminOrcidIds, @@ -217,6 +249,62 @@ export class MockAmacrinService implements AmacrinService { return Promise.resolve([state.deployment, ...state.past]); } + listOsaVersions(): Promise { + return Promise.resolve([...MOCK_OSA_VERSIONS]); + } + + upgradeArchive(archiveId: string, toVersion: string): Promise { + const state = this.archives.get(archiveId); + if (!state) return Promise.reject(notFound("archive")); + const target = MOCK_OSA_VERSIONS.find((v) => v.version === toVersion); + if (!target) return Promise.reject(notFound("OSA version")); + if (target.status !== "supported") { + return Promise.reject( + new ApiError({ + status: 400, + code: "validation_error", + message: `OSA version '${toVersion}' is ${target.status} — not an upgrade target`, + }), + ); + } + if (compareOsaVersions(toVersion, state.archive.osaVersionPin) <= 0) { + return Promise.reject( + new ApiError({ + status: 400, + code: "validation_error", + message: `'${toVersion}' is not newer than the archive's current version '${state.archive.osaVersionPin}' — upgrades are forward-only`, + }), + ); + } + if (state.archive.status.kind === "deploying") { + return Promise.reject( + new ApiError({ + status: 422, + code: "invalid_state", + message: "deployment already in progress for this archive", + }), + ); + } + // The pin moves WITH the deployment start — mirroring the cloud tx. + state.archive = { + ...state.archive, + osaVersionPin: toVersion, + status: { kind: "deploying" }, + updatedAt: T0, + }; + state.past = [state.deployment, ...state.past]; + state.deployment = { + id: this.mintId("deploy"), + archiveId, + provider: "aws_eks", + status: { kind: "pending" }, + osaVersion: null, + startedAt: new Date(state.past[0]!.startedAt.getTime() + 60_000), + }; + state.pollsUntilAdvance = 2; + return Promise.resolve(state.deployment); + } + destroyArchive( archiveId: string, opts?: { force?: boolean }, @@ -402,6 +490,7 @@ export class MockAmacrinService implements AmacrinService { const archive: Archive = { ...args, domain: `${args.slug}.amacr.in`, + osaVersionPin: MOCK_OSA_VERSION, deploymentConfig: { provider: "aws_eks", region: "eu-west-1", diff --git a/apps/dashboard/src/api/amacrin/real.ts b/apps/dashboard/src/api/amacrin/real.ts index 8a8258a..6028f3d 100644 --- a/apps/dashboard/src/api/amacrin/real.ts +++ b/apps/dashboard/src/api/amacrin/real.ts @@ -3,6 +3,7 @@ import { ApiError, SlugTakenError } from "@/api/http/errors"; import type { Archive } from "@/domain/archive"; import type { Build } from "@/domain/build"; import type { Deployment } from "@/domain/deployment"; +import type { OsaVersion } from "@/domain/osa-version"; import type { Organisation } from "@/domain/organisation"; import type { BuildListItem, OrgMember } from "@/domain/tenant"; import type { Session } from "@/domain/user"; @@ -19,6 +20,7 @@ import { decodeOrganisation, decodeOrganisationList, decodeSession, + decodeOsaVersionList, } from "./wire/decode"; import type { AmacrinService, @@ -148,6 +150,21 @@ export class RealAmacrinService implements AmacrinService { ); } + async listOsaVersions(): Promise { + return decodeOsaVersionList(await this.client.get("/api/v1/osa-versions")); + } + + async upgradeArchive( + archiveId: string, + toVersion: string, + ): Promise { + return decodeDeployment( + await this.client.post(`/api/v1/archives/${archiveId}/upgrade`, { + to_version: toVersion, + }), + ); + } + async destroyArchive( archiveId: string, opts?: { force?: boolean }, diff --git a/apps/dashboard/src/api/amacrin/service.ts b/apps/dashboard/src/api/amacrin/service.ts index d7a71e1..4b37041 100644 --- a/apps/dashboard/src/api/amacrin/service.ts +++ b/apps/dashboard/src/api/amacrin/service.ts @@ -8,6 +8,7 @@ import type { Archive } from "@/domain/archive"; import type { Build } from "@/domain/build"; import type { Deployment } from "@/domain/deployment"; +import type { OsaVersion } from "@/domain/osa-version"; import type { Organisation } from "@/domain/organisation"; import type { BuildListItem, OrgMember } from "@/domain/tenant"; import type { Session } from "@/domain/user"; @@ -64,6 +65,14 @@ export interface AmacrinService { getDeploymentStatus(archiveId: string): Promise; /** GET /archives/{id}/deployments — deployment history, newest first. */ listDeployments(archiveId: string): Promise; + /** GET /osa-versions — the registry, newest first (#222). */ + listOsaVersions(): Promise; + /** + * POST /archives/{id}/upgrade — move the version pin, forward-only (202). + * The pin moves in the same transaction that starts the deployment: a + * failed upgrade deployment retries at the NEW version, never rolls back. + */ + upgradeArchive(archiveId: string, toVersion: string): Promise; /** POST /archives/{id}/destroy — the only removal path (Owner only). */ destroyArchive( archiveId: string, diff --git a/apps/dashboard/src/api/amacrin/wire/decode.ts b/apps/dashboard/src/api/amacrin/wire/decode.ts index 270981a..4cc0098 100644 --- a/apps/dashboard/src/api/amacrin/wire/decode.ts +++ b/apps/dashboard/src/api/amacrin/wire/decode.ts @@ -24,6 +24,7 @@ import type { import type { Deployment, DeploymentStatus } from "@/domain/deployment"; import { type Organisation, isRole } from "@/domain/organisation"; import type { BuildListItem, OrgMember } from "@/domain/tenant"; +import type { OsaVersion } from "@/domain/osa-version"; import type { Session } from "@/domain/user"; import { @@ -40,6 +41,7 @@ import { wireOrgMemberList, wireOrganisation, wireOrganisationList, + wireOsaVersionList, } from "./schemas"; export class DecodeError extends Error { @@ -175,6 +177,7 @@ export function decodeArchive(raw: unknown): Archive { domain: wire.domain, status: toArchiveStatus(wire.status, wire.error_message), orcidAdmins: wire.config.auth?.admins?.orcid ?? [], + osaVersionPin: wire.osa_version_pin, deploymentConfig, createdAt: parseDate(wire.created_at, "archive"), updatedAt: parseDate(wire.updated_at, "archive"), @@ -364,3 +367,24 @@ export function decodeBuildList(raw: unknown): BuildListItem[] { createdAt: parseDate(wire.created_at, "build"), })); } + +// --------------------------------------------------------------------------- +// OSA versions (#222) +// --------------------------------------------------------------------------- + +const OSA_VERSION_STATUSES = ["supported", "deprecated", "withdrawn"] as const; + +export function decodeOsaVersionList(raw: unknown): OsaVersion[] { + return parse(wireOsaVersionList, raw, "osa-versions").map((wire) => { + const status = OSA_VERSION_STATUSES.find((s) => s === wire.status); + if (!status) { + throw new DecodeError(`unknown OSA version status "${wire.status}"`); + } + return { + version: wire.version, + status, + isDefault: wire.is_default, + notesUrl: wire.notes_url ?? null, + }; + }); +} diff --git a/apps/dashboard/src/api/amacrin/wire/schemas.ts b/apps/dashboard/src/api/amacrin/wire/schemas.ts index b693526..315fae5 100644 --- a/apps/dashboard/src/api/amacrin/wire/schemas.ts +++ b/apps/dashboard/src/api/amacrin/wire/schemas.ts @@ -57,6 +57,9 @@ export const wireArchive = z.object({ domain: z.string(), config: wireArchiveConfig, status: z.string(), + /** Desired version (always present); deployed version rides osa_version. */ + osa_version_pin: z.string(), + osa_version: z.string().nullish(), error_message: z.string().nullish(), created_at: z.string(), updated_at: z.string(), @@ -64,6 +67,18 @@ export const wireArchive = z.object({ export const wireArchiveList = z.array(wireArchive); +export const wireOsaVersion = z.object({ + version: z.string(), + image_digest: z.string().nullish(), + status: z.string(), + is_default: z.boolean(), + released_at: z.string().nullish(), + registered_at: z.string(), + notes_url: z.string().nullish(), +}); + +export const wireOsaVersionList = z.array(wireOsaVersion); + export const wireDeployment = z.object({ id: z.string(), archive_id: z.string(), diff --git a/apps/dashboard/src/api/osa/wire/schemas.ts b/apps/dashboard/src/api/osa/wire/schemas.ts index 010a1f4..88d49f9 100644 --- a/apps/dashboard/src/api/osa/wire/schemas.ts +++ b/apps/dashboard/src/api/osa/wire/schemas.ts @@ -154,4 +154,5 @@ export const wireNodeOverview = z.object({ status: z.enum(["ready", "degraded", "unknown"]), records: z.number().nullable(), schemas: z.number(), + latestOsaVersion: z.string().nullable().default(null), }); diff --git a/apps/dashboard/src/app/api/node/route.ts b/apps/dashboard/src/app/api/node/route.ts index 5381c52..8ff4b16 100644 --- a/apps/dashboard/src/app/api/node/route.ts +++ b/apps/dashboard/src/app/api/node/route.ts @@ -1,5 +1,6 @@ import { NextResponse, type NextRequest } from "next/server"; +import { latestOsaVersion } from "@/server/ghcr"; import { osaApiUrl, sessionSecret } from "@/server/env"; import { SESSION_COOKIE, readSession } from "@/server/session"; @@ -38,10 +39,17 @@ export async function GET(req: NextRequest): Promise { const base = osaApiUrl(); const auth = { authorization: `Bearer ${session.osaToken}` }; - const [discRes, readyRes, statsRes] = await Promise.allSettled([ - fetch(`${base}/`, { headers: { accept: "application/json" } }), - fetch(`${base}/api/v1/ready`, { headers: { accept: "application/json" } }), - fetch(`${base}/api/v1/stats`, { headers: { ...auth, accept: "application/json" } }), + // Update awareness (#222) rides along: latest published release from the + // registry, cached ~1h, null on failure — never blocks the overview. + const [[discRes, readyRes, statsRes], latest] = await Promise.all([ + Promise.allSettled([ + fetch(`${base}/`, { headers: { accept: "application/json" } }), + fetch(`${base}/api/v1/ready`, { headers: { accept: "application/json" } }), + fetch(`${base}/api/v1/stats`, { + headers: { ...auth, accept: "application/json" }, + }), + ]), + latestOsaVersion(), ]); const discovery = await json(discRes); @@ -63,5 +71,6 @@ export async function GET(req: NextRequest): Promise { : "unknown", records: typeof stats.records === "number" ? stats.records : null, schemas: Array.isArray(schemas) ? schemas.length : 0, + latestOsaVersion: latest, }); } diff --git a/apps/dashboard/src/app/api/releases/route.test.ts b/apps/dashboard/src/app/api/releases/route.test.ts new file mode 100644 index 0000000..a694f8a --- /dev/null +++ b/apps/dashboard/src/app/api/releases/route.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createPlatformSessionValue } from "@/server/platform-session"; +import { resetReleasesCache } from "@/server/releases"; +import { SESSION_COOKIE } from "@/server/session"; + +import { GET } from "./route"; + +const SECRET = "test-session-secret-test-session-secret!"; + +function githubRelease(tag: string, over?: Record) { + return { + tag_name: tag, + name: `Release ${tag}`, + body: `notes for ${tag}`, + html_url: `https://github.com/opensciencearchive/server/releases/tag/${tag}`, + draft: false, + prerelease: false, + ...over, + }; +} + +async function request(query: string): Promise { + const cookie = await createPlatformSessionValue( + { accessToken: "t", refreshToken: "r" }, + SECRET, + ); + return new NextRequest(`http://dash.test/api/releases?${query}`, { + headers: { cookie: `${SESSION_COOKIE}=${cookie}` }, + }); +} + +describe("GET /api/releases", () => { + beforeEach(() => { + vi.stubEnv("NEXT_PUBLIC_IS_PLATFORM", "true"); + vi.stubEnv("SESSION_SECRET", SECRET); + resetReleasesCache(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("returns releases in (from, to], oldest first, skipping drafts", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + Response.json([ + githubRelease("v0.0.12"), + githubRelease("v0.0.11"), + githubRelease("v0.0.10"), + githubRelease("v0.0.13", { draft: true }), + ]), + ); + + const res = await GET(await request("from=v0.0.10&to=v0.0.12")); + expect(res.status).toBe(200); + const body = (await res.json()) as { releases: { version: string }[] }; + expect(body.releases.map((r) => r.version)).toEqual([ + "v0.0.11", + "v0.0.12", + ]); + }); + + it("follows pagination so older releases in the range are not dropped", async () => { + const page2 = Response.json([githubRelease("v0.0.10")]); + const page1 = new Response( + JSON.stringify([githubRelease("v0.0.12"), githubRelease("v0.0.11")]), + { + headers: { + "content-type": "application/json", + link: '; rel="next", ; rel="last"', + }, + }, + ); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce(page1) + .mockResolvedValueOnce(page2); + + const res = await GET(await request("from=v0.0.9&to=v0.0.12")); + const body = (await res.json()) as { releases: { version: string }[] }; + expect(body.releases.map((r) => r.version)).toEqual([ + "v0.0.10", + "v0.0.11", + "v0.0.12", + ]); + }); + + it("serves from cache on the second call", async () => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(Response.json([githubRelease("v0.0.12")])); + + await GET(await request("from=v0.0.11&to=v0.0.12")); + await GET(await request("from=v0.0.11&to=v0.0.12")); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("degrades to 502 when GitHub is unavailable", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("rate limited", { status: 403 }), + ); + const res = await GET(await request("from=v0.0.10&to=v0.0.12")); + expect(res.status).toBe(502); + }); + + it("rejects an invalid target version", async () => { + const res = await GET(await request("to=latest")); + expect(res.status).toBe(400); + }); + + it("requires a session", async () => { + const res = await GET( + new NextRequest("http://dash.test/api/releases?to=v0.0.12"), + ); + expect(res.status).toBe(401); + }); +}); diff --git a/apps/dashboard/src/app/api/releases/route.ts b/apps/dashboard/src/app/api/releases/route.ts new file mode 100644 index 0000000..3498b21 --- /dev/null +++ b/apps/dashboard/src/app/api/releases/route.ts @@ -0,0 +1,52 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { isPlatformFromEnv } from "@/api/config"; +import { compareOsaVersions } from "@/domain/osa-version"; +import { sessionSecret } from "@/server/env"; +import { readPlatformSession } from "@/server/platform-session"; +import { RELEASE_TAG, publishedReleases } from "@/server/releases"; +import { SESSION_COOKIE, readSession } from "@/server/session"; + +export const runtime = "nodejs"; + +/** + * BFF: release notes for the upgrade dialog (#222). + * + * `GET /api/releases?from=v0.0.10&to=v0.0.12` → the notes of every release in + * `(from, to]`, oldest first, so multi-version jumps read chronologically. + * GitHub failure is a 502 the dialog degrades on (registry `notes_url` + * link-out). + */ + +async function authorized(req: NextRequest): Promise { + const cookie = req.cookies.get(SESSION_COOKIE)?.value; + if (isPlatformFromEnv()) { + return (await readPlatformSession(cookie, sessionSecret())) !== null; + } + return (await readSession(cookie, sessionSecret())) !== null; +} + +export async function GET(req: NextRequest): Promise { + if (!(await authorized(req))) { + return NextResponse.json({ error: "unauthenticated" }, { status: 401 }); + } + + const from = req.nextUrl.searchParams.get("from") ?? ""; + const to = req.nextUrl.searchParams.get("to") ?? ""; + if (!RELEASE_TAG.test(to)) { + return NextResponse.json({ error: "invalid_range" }, { status: 400 }); + } + + try { + const releases = (await publishedReleases()) + .filter( + (r) => + compareOsaVersions(r.version, to) <= 0 && + (from === "" || compareOsaVersions(r.version, from) > 0), + ) + .sort((a, b) => compareOsaVersions(a.version, b.version)); + return NextResponse.json({ releases }); + } catch { + return NextResponse.json({ error: "github_unavailable" }, { status: 502 }); + } +} diff --git a/apps/dashboard/src/domain/archive.ts b/apps/dashboard/src/domain/archive.ts index a4395f2..6337440 100644 --- a/apps/dashboard/src/domain/archive.ts +++ b/apps/dashboard/src/domain/archive.ts @@ -38,6 +38,12 @@ export interface Archive { status: ArchiveStatus; /** Administrator ORCID iDs from the non-secret auth config. */ orcidAdmins: string[]; + /** + * Desired OSA server version (the registry pin). The DEPLOYED version + * lives on the latest Deployment; pin ≠ deployed means an upgrade is in + * flight or its deployment failed (the pin moves first, by design). + */ + osaVersionPin: string; deploymentConfig: ArchiveDeploymentConfig | null; createdAt: Date; updatedAt: Date; diff --git a/apps/dashboard/src/domain/node.ts b/apps/dashboard/src/domain/node.ts index 7c1f09b..812591e 100644 --- a/apps/dashboard/src/domain/node.ts +++ b/apps/dashboard/src/domain/node.ts @@ -10,6 +10,8 @@ export interface NodeOverview { domain: string; description: string; osaVersion: string; + /** Newest published release (registry check), null when unknown. */ + latestOsaVersion: string | null; status: NodeStatus; /** Published record count, or null when stats are unavailable. */ records: number | null; diff --git a/apps/dashboard/src/domain/osa-version.ts b/apps/dashboard/src/domain/osa-version.ts new file mode 100644 index 0000000..1ebd19b --- /dev/null +++ b/apps/dashboard/src/domain/osa-version.ts @@ -0,0 +1,66 @@ +/** + * OSA version registry entries (#222) — what the cloud can provision. + * + * Registered automatically by the cloud's release poller; `status` is the + * support lifecycle. Only `supported` versions are upgrade targets; a + * `withdrawn` pin cannot even be redeployed. + */ + +export type OsaVersionStatus = "supported" | "deprecated" | "withdrawn"; + +export interface OsaVersion { + /** Strict `vX.Y.Z` tag, e.g. `v0.0.12`. */ + version: string; + status: OsaVersionStatus; + /** The version new archives are born with. */ + isDefault: boolean; + /** Release notes link, stamped by the registry poller. */ + notesUrl: string | null; +} + +/** + * Numeric semver comparison, tolerant of a missing `v` prefix (the tenant + * health endpoint reports `0.0.11` while registry tags say `v0.0.11`). + * Returns <0 / 0 / >0 like a comparator. Unparseable versions compare as + * lowest so they never masquerade as an upgrade. + */ +export function compareOsaVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + if (!pa && !pb) return 0; + if (!pa) return -1; + if (!pb) return 1; + for (let i = 0; i < 3; i++) { + const d = (pa[i] ?? 0) - (pb[i] ?? 0); + if (d !== 0) return d; + } + return 0; +} + +function parseVersion(v: string): [number, number, number] | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim()); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +/** + * Versions the archive may move to: `supported` and strictly newer than the + * pin, newest first. Mirrors the cloud's `eligible_upgrade_target()` rule so + * the dialog never offers something the API would 400. + */ +export function eligibleUpgradeTargets( + versions: OsaVersion[], + pin: string, +): OsaVersion[] { + return versions + .filter( + (v) => + v.status === "supported" && compareOsaVersions(v.version, pin) > 0, + ) + .sort((a, b) => compareOsaVersions(b.version, a.version)); +} + +/** GitHub compare link between two release tags. */ +export function releaseCompareUrl(from: string, to: string): string { + return `https://github.com/opensciencearchive/server/compare/${from}...${to}`; +} diff --git a/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx b/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx index 0c528f7..ddf9ee5 100644 --- a/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx +++ b/apps/dashboard/src/features/archive-settings/SettingsPanel.tsx @@ -10,6 +10,8 @@ import { useServices } from "@/api/services"; import { useArchive } from "@/features/archives/useArchives"; import { useSession } from "@/features/auth/useSession"; import { useRedeploy } from "@/features/deployments/useRedeploy"; +import { UpgradeSection } from "@/features/archive-upgrade/UpgradeSection"; +import { SelfHostVersion } from "@/features/archive-upgrade/SelfHostVersion"; import { Button, Card, PageHeader, Skeleton } from "@/ui"; import { blockedReason } from "./blocked"; @@ -65,6 +67,7 @@ function SelfHostSettings() { from the dashboard.

+ ); } @@ -86,6 +89,7 @@ function SettingsSections({ }) { return (
+ diff --git a/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.module.css b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.module.css new file mode 100644 index 0000000..039b7cb --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.module.css @@ -0,0 +1,42 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: var(--space-3); + border-top: 1px solid var(--color-border); + padding-top: var(--space-4); + margin-top: var(--space-4); +} + +.row { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: var(--text-sm); +} + +.label { + color: var(--color-text-subtle); +} + +.hint { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.hintText { + font-size: var(--text-sm); + color: var(--color-text-subtle); + margin: 0; +} + +.command { + display: flex; + align-items: center; + gap: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + background: var(--color-bg); + width: fit-content; +} diff --git a/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.test.tsx b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.test.tsx new file mode 100644 index 0000000..b009e90 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.test.tsx @@ -0,0 +1,69 @@ +import { HttpResponse, http } from "msw"; +import { screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { server } from "@/mocks/server"; +import { makeTestServices, renderWithProviders } from "@/test/render"; + +import { SelfHostVersion } from "./SelfHostVersion"; + +function nodeOverview(over?: Record) { + return { + name: "Pockets", + domain: "pockets.example.org", + description: "", + osaVersion: "0.0.11", + status: "ready", + records: 1, + schemas: 1, + latestOsaVersion: "v0.0.12", + ...over, + }; +} + +function selfHost() { + const services = makeTestServices(); + return { ...services, isPlatform: false as const }; +} + +describe("SelfHostVersion", () => { + it("shows the CLI command when the node is behind the latest release", async () => { + server.use( + http.get("*/api/node", () => HttpResponse.json(nodeOverview())), + ); + renderWithProviders(, { services: selfHost() }); + + expect( + await screen.findByText("Update available → v0.0.12"), + ).toBeInTheDocument(); + // The bare `0.0.11` from /health compares correctly against `v0.0.12`. + expect( + screen.getByText("osa start --osa-version v0.0.12"), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /upgrade/i }), + ).not.toBeInTheDocument(); + }); + + it("shows up-to-date when current matches latest despite prefix mismatch", async () => { + server.use( + http.get("*/api/node", () => + HttpResponse.json(nodeOverview({ latestOsaVersion: "v0.0.11" })), + ), + ); + renderWithProviders(, { services: selfHost() }); + expect(await screen.findByText("Up to date")).toBeInTheDocument(); + }); + + it("renders just the version when the registry check failed", async () => { + server.use( + http.get("*/api/node", () => + HttpResponse.json(nodeOverview({ latestOsaVersion: null })), + ), + ); + renderWithProviders(, { services: selfHost() }); + expect(await screen.findByText("0.0.11")).toBeInTheDocument(); + expect(screen.queryByText(/Update available/)).not.toBeInTheDocument(); + expect(screen.queryByText("Up to date")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.tsx b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.tsx new file mode 100644 index 0000000..ac4ff1e --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/SelfHostVersion.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { compareOsaVersions } from "@/domain/osa-version"; +import { useNodeOverview } from "@/features/archives/useNodeOverview"; +import { Badge, CopyButton } from "@/ui"; + +import styles from "./SelfHostVersion.module.css"; + +/** + * Self-host upgrade awareness (#222, user-decided scope): show the node's + * version and, when a newer release exists, the exact CLI command — the + * dashboard cannot perform the upgrade itself (server and dashboard share + * one OSA_IMAGE_VERSION; upgrading the server restarts this dashboard too). + */ +export function SelfHostVersion() { + const node = useNodeOverview(); + + if (!node.data || !node.data.osaVersion) return null; + + const current = node.data.osaVersion; + const latest = node.data.latestOsaVersion; + const behind = + latest !== null && compareOsaVersions(latest, current) > 0; + + return ( +
+
+ OSA version + {current} + {behind ? ( + + Update available → {latest} + + ) : latest !== null ? ( + Up to date + ) : null} +
+ {behind && ( +
+

+ Run this where the archive is hosted — it restarts the server and + this dashboard: +

+
+ osa start --osa-version {latest} + +
+
+ )} +
+ ); +} diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.module.css b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.module.css new file mode 100644 index 0000000..7ad6b21 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.module.css @@ -0,0 +1,86 @@ +.body { + display: flex; + flex-direction: column; + gap: var(--space-4); + max-width: 34rem; +} + +.versions { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: var(--text-md); +} + +.notes { + max-height: 18rem; + overflow-y: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: var(--space-3) var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.release { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.releaseTitle { + font-size: var(--text-sm); + font-weight: 600; +} + +.prose { + font-size: var(--text-sm); + line-height: 1.55; +} + +.prose :global(h1), +.prose :global(h2), +.prose :global(h3) { + font-size: var(--text-sm); + font-weight: 600; + margin: var(--space-2) 0 var(--space-1); +} + +.prose :global(ul) { + padding-left: var(--space-4); + margin: var(--space-1) 0; +} + +.prose :global(code) { + font-family: var(--font-mono); + font-size: 12.5px; + background: var(--color-bg-subtle, rgba(0, 0, 0, 0.04)); + padding: 0.1em 0.35em; + border-radius: 4px; +} + +.notesFallback { + font-size: var(--text-sm); + color: var(--color-text-subtle); + margin: 0; +} + +.warning { + font-size: var(--text-sm); + color: var(--color-text-subtle); + margin: 0; +} + +.error { + font-size: var(--text-sm); + color: var(--color-danger); + margin: 0; +} + +.actions { + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-4); +} diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.tsx new file mode 100644 index 0000000..5431d82 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/UpgradeDialog.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useCallback, useState } from "react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +import { ApiError } from "@/api/http/errors"; +import type { Archive } from "@/domain/archive"; +import type { OsaVersion } from "@/domain/osa-version"; +import { releaseCompareUrl } from "@/domain/osa-version"; +import { Button, Checkbox, Dialog, Field, Select, Skeleton } from "@/ui"; + +import { useReleaseNotes } from "./useReleaseNotes"; +import { useUpgradeArchive } from "./useUpgradeArchive"; +import styles from "./UpgradeDialog.module.css"; + +/** + * The deliberate half of #222: from → to, what changed, an explicit + * forward-only acknowledgment, then hand off to /deploying/{id}. + */ +export function UpgradeDialog({ + archive, + targets, + open, + onClose, +}: { + archive: Archive; + /** Eligible targets, newest first — guaranteed non-empty by the caller. */ + targets: OsaVersion[]; + open: boolean; + onClose: () => void; +}) { + const newest = targets[0]!; + const [toVersion, setToVersion] = useState(newest.version); + const [acknowledged, setAcknowledged] = useState(false); + const upgrade = useUpgradeArchive(archive.id); + const target = targets.find((t) => t.version === toVersion) ?? newest; + + const notes = useReleaseNotes(archive.osaVersionPin, toVersion, open); + + const upgradeReset = upgrade.reset; + const close = useCallback(() => { + setAcknowledged(false); + upgradeReset(); + onClose(); + }, [upgradeReset, onClose]); + + const submit = () => { + if (!acknowledged || upgrade.isPending) return; + upgrade.mutate(toVersion); + }; + + const errorMessage = + upgrade.error instanceof ApiError + ? upgrade.error.code === "invalid_state" + ? "A deployment is already in progress." + : upgrade.error.message + : upgrade.error + ? "Something went wrong — try again." + : undefined; + + return ( + +
+
+ {archive.osaVersionPin} + + {targets.length > 1 ? ( + + {({ id }) => ( + + )} + + ) : ( + {toVersion} + )} +
+ + + +

+ The archive restarts and runs schema migrations — expect a brief + pause in availability while it comes back up. +

+ + setAcknowledged(e.target.checked)} + /> + + {errorMessage && ( +

+ {errorMessage} +

+ )} +
+ +
+ + +
+
+ ); +} + +function ReleaseNotes({ + notes, + fallbackUrl, + compareUrl, +}: { + notes: ReturnType; + fallbackUrl: string | null; + compareUrl: string; +}) { + if (notes.isPending) { + return ; + } + if (notes.isError || !notes.data) { + // GitHub unreachable — degrade to the registry's link-out. + return ( +

+ {fallbackUrl ? ( + + Read the release notes on GitHub + + ) : ( + "Release notes are unavailable right now." + )}{" "} + ·{" "} + + Full comparison + +

+ ); + } + return ( +
+ {notes.data.map((release) => ( +
+

+ {release.version} + {release.name && release.name !== release.version + ? ` — ${release.name}` + : null} +

+
+ {release.body} +
+
+ ))} +

+ + Full comparison on GitHub + +

+
+ ); +} diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.module.css b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.module.css new file mode 100644 index 0000000..6aa2109 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.module.css @@ -0,0 +1,47 @@ +.card { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.heading { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.title { + font-size: var(--text-md); + font-weight: 600; +} + +.chipRow { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: var(--text-sm); +} + +.failedNote, +.withdrawnNote { + font-size: var(--text-sm); + color: var(--color-danger); + margin: 0; +} + +.deprecatedNote { + font-size: var(--text-sm); + color: var(--color-warning, var(--color-text-subtle)); + margin: 0; +} + +.actions { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.note { + font-size: var(--text-sm); + color: var(--color-text-subtle); +} diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx new file mode 100644 index 0000000..0ce69e4 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.test.tsx @@ -0,0 +1,179 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { makeTestServices, renderWithProviders } from "@/test/render"; +import { buildArchive } from "@/test/factories"; +import { mockRouter } from "@/test/router-mock"; + +import { UpgradeSection } from "./UpgradeSection"; + +// Seeded mock registry: pin v0.0.9; v0.0.10 + v0.0.11 supported (default), +// v0.0.8 deprecated. Seeded archives pin v0.0.9. + +describe("UpgradeSection", () => { + it("shows the pin and an update-available badge when newer versions exist", async () => { + const services = makeTestServices(); + renderWithProviders( + , + { services }, + ); + expect(await screen.findByText("v0.0.9")).toBeInTheDocument(); + expect( + await screen.findByText("Update available → v0.0.11"), + ).toBeInTheDocument(); + }); + + it("shows up-to-date when the pin is the newest supported version", async () => { + const services = makeTestServices(); + renderWithProviders( + , + { services }, + ); + expect(await screen.findByText("Up to date")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Upgrade…" })).toBeDisabled(); + }); + + it("blocks the button with a reason while a deployment is in flight", async () => { + const services = makeTestServices(); + renderWithProviders( + , + { services }, + ); + const button = await screen.findByRole("button", { name: "Upgrade…" }); + await waitFor(() => expect(button).toBeDisabled()); + expect( + screen.getByText("A deployment is already in progress."), + ).toBeInTheDocument(); + }); + + it("shows the registry as unavailable, not up to date, when it errors", async () => { + const services = makeTestServices(); + vi.spyOn(services.amacrin, "listOsaVersions").mockRejectedValue( + new Error("registry down"), + ); + renderWithProviders( + , + { services }, + ); + expect( + await screen.findByText("Version check unavailable"), + ).toBeInTheDocument(); + expect(screen.queryByText("Up to date")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Upgrade…" })).toBeDisabled(); + }); + + it("warns when the pinned version is deprecated", async () => { + const services = makeTestServices(); + renderWithProviders( + , + { services }, + ); + expect( + await screen.findByText(/is deprecated — upgrading is recommended/), + ).toBeInTheDocument(); + }); + + it("opens the dialog and performs an upgrade end to end", async () => { + const services = makeTestServices(); + const upgradeSpy = vi.spyOn(services.amacrin, "upgradeArchive"); + const user = userEvent.setup(); + renderWithProviders( + , + { services }, + ); + + await user.click(await screen.findByRole("button", { name: "Upgrade…" })); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toBeInTheDocument(); + + // The confirm button is gated on the forward-only acknowledgment. + const confirm = screen.getByRole("button", { name: "Upgrade to v0.0.11" }); + expect(confirm).toBeDisabled(); + await user.click( + screen.getByLabelText( + "I understand upgrades are forward-only and can't be rolled back.", + ), + ); + expect(confirm).toBeEnabled(); + + await user.click(confirm); + await waitFor(() => + expect(upgradeSpy).toHaveBeenCalledWith("arch_sky1mag3ry", "v0.0.11"), + ); + await waitFor(() => + expect(mockRouter.push).toHaveBeenCalledWith( + "/deploying/arch_sky1mag3ry", + ), + ); + }); + + it("lets the user pick an intermediate version when several are eligible", async () => { + const services = makeTestServices(); + const upgradeSpy = vi.spyOn(services.amacrin, "upgradeArchive"); + const user = userEvent.setup(); + renderWithProviders( + , + { services }, + ); + + await user.click(await screen.findByRole("button", { name: "Upgrade…" })); + await user.selectOptions( + await screen.findByLabelText("Upgrade to"), + "v0.0.10", + ); + await user.click( + screen.getByLabelText( + "I understand upgrades are forward-only and can't be rolled back.", + ), + ); + await user.click(screen.getByRole("button", { name: "Upgrade to v0.0.10" })); + await waitFor(() => + expect(upgradeSpy).toHaveBeenCalledWith("arch_sky1mag3ry", "v0.0.10"), + ); + }); + + it("surfaces an in-flight conflict as a friendly message", async () => { + const services = makeTestServices(); + // Seeded deploying archive: the mock rejects with invalid_state. + const user = userEvent.setup(); + renderWithProviders( + , + { services }, + ); + + await user.click(await screen.findByRole("button", { name: "Upgrade…" })); + await user.click( + screen.getByLabelText( + "I understand upgrades are forward-only and can't be rolled back.", + ), + ); + await user.click(screen.getByRole("button", { name: "Upgrade to v0.0.11" })); + expect( + await screen.findByText("A deployment is already in progress."), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx new file mode 100644 index 0000000..d5bcf1c --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/UpgradeSection.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; + +import type { Archive } from "@/domain/archive"; +import { isDeployBlocked } from "@/domain/archive"; +import { eligibleUpgradeTargets } from "@/domain/osa-version"; +import { useDeploymentStatus } from "@/features/deployments/useDeploymentStatus"; +import { blockedReason } from "@/features/archive-settings/blocked"; +import { Badge, Button, Card, Skeleton } from "@/ui"; + +import { useOsaVersions } from "./useOsaVersions"; +import { UpgradeDialog } from "./UpgradeDialog"; +import styles from "./UpgradeSection.module.css"; + +/** + * The archive's OSA version: current pin, availability state, and the + * upgrade entry point (#222). Platform mode only — self-host gets a + * read-only version line in SelfHostSettings instead. + */ +export function UpgradeSection({ archive }: { archive: Archive }) { + const versions = useOsaVersions(); + const latestDeployment = useDeploymentStatus(archive.id); + const [dialogOpen, setDialogOpen] = useState(false); + + const blocked = isDeployBlocked(archive.status); + const reason = blockedReason(archive.status); + + const pin = archive.osaVersionPin; + const pinEntry = versions.data?.find((v) => v.version === pin); + const targets = versions.data ? eligibleUpgradeTargets(versions.data, pin) : []; + const newest = targets[0]; + + // The pin moves when an upgrade starts; a failed deployment at the pin + // means the upgrade did not land — and a retry deploys the PIN, never the + // previous version. Say so instead of implying a rollback. + const upgradeFailed = + latestDeployment.data?.status.kind === "failed" && + latestDeployment.data.osaVersion !== pin && + !blocked; + + return ( + +
+

OSA version

+
+ {pin} + {versions.isPending ? ( + + ) : versions.isError ? ( + Version check unavailable + ) : newest ? ( + + Update available → {newest.version} + + ) : ( + Up to date + )} +
+ {upgradeFailed && ( +

+ The upgrade to {pin} failed during + deployment. Retrying deploys {pin} — + upgrades don't roll back. +

+ )} + {pinEntry?.status === "deprecated" && ( +

+ {pin} is deprecated — upgrading is + recommended. +

+ )} + {pinEntry?.status === "withdrawn" && ( +

+ {pin} has been withdrawn: redeploys + of this version are refused. Upgrade required. +

+ )} +
+ +
+ + {blocked && {reason}} +
+ + {newest && ( + setDialogOpen(false)} + /> + )} +
+ ); +} diff --git a/apps/dashboard/src/features/archive-upgrade/keys.ts b/apps/dashboard/src/features/archive-upgrade/keys.ts new file mode 100644 index 0000000..cee2675 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/keys.ts @@ -0,0 +1,5 @@ +export const upgradeKeys = { + versions: ["osa-versions"] as const, + releaseNotes: (from: string, to: string) => + ["release-notes", from, to] as const, +}; diff --git a/apps/dashboard/src/features/archive-upgrade/useOsaVersions.ts b/apps/dashboard/src/features/archive-upgrade/useOsaVersions.ts new file mode 100644 index 0000000..0ea67d1 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/useOsaVersions.ts @@ -0,0 +1,17 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { usePlatformServices } from "@/api/services"; + +import { upgradeKeys } from "./keys"; + +/** GET /osa-versions — the registry, newest first. Platform mode only. */ +export function useOsaVersions() { + const { amacrin } = usePlatformServices(); + return useQuery({ + queryKey: upgradeKeys.versions, + queryFn: () => amacrin.listOsaVersions(), + staleTime: 60_000, + }); +} diff --git a/apps/dashboard/src/features/archive-upgrade/useReleaseNotes.ts b/apps/dashboard/src/features/archive-upgrade/useReleaseNotes.ts new file mode 100644 index 0000000..59512f6 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/useReleaseNotes.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { upgradeKeys } from "./keys"; + +export interface ReleaseNote { + version: string; + name: string; + body: string; + htmlUrl: string; +} + +/** + * Release notes for every version in `(from, to]`, oldest first, via the + * dashboard's cached GitHub proxy. Failure is non-fatal — the dialog + * degrades to the registry's notes_url link. + */ +export function useReleaseNotes(from: string, to: string, enabled: boolean) { + return useQuery({ + queryKey: upgradeKeys.releaseNotes(from, to), + enabled, + staleTime: 10 * 60_000, + retry: false, + queryFn: async (): Promise => { + const res = await fetch( + `/api/releases?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, + ); + if (!res.ok) throw new Error(`release notes unavailable (${res.status})`); + const body = (await res.json()) as { releases: ReleaseNote[] }; + return body.releases; + }, + }); +} diff --git a/apps/dashboard/src/features/archive-upgrade/useUpgradeArchive.ts b/apps/dashboard/src/features/archive-upgrade/useUpgradeArchive.ts new file mode 100644 index 0000000..6763948 --- /dev/null +++ b/apps/dashboard/src/features/archive-upgrade/useUpgradeArchive.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; + +import { usePlatformServices } from "@/api/services"; + +import { archiveKeys } from "../archives/keys"; + +/** + * POST /archives/{id}/upgrade — forward-only. On the 202, the deployment is + * pre-seeded into the status cache (mirroring useCreateArchive) and the user + * is handed to /deploying/{id}, which owns progress from there. + */ +export function useUpgradeArchive(archiveId: string) { + const { amacrin } = usePlatformServices(); + const queryClient = useQueryClient(); + const router = useRouter(); + + return useMutation({ + mutationFn: (toVersion: string) => + amacrin.upgradeArchive(archiveId, toVersion), + onSuccess: async (deployment) => { + queryClient.setQueryData(archiveKeys.status(archiveId), deployment); + await queryClient.invalidateQueries({ + queryKey: archiveKeys.detail(archiveId), + }); + router.push(`/deploying/${archiveId}`); + }, + }); +} diff --git a/apps/dashboard/src/features/archives/local-archive.ts b/apps/dashboard/src/features/archives/local-archive.ts index 63bd019..be7ccbc 100644 --- a/apps/dashboard/src/features/archives/local-archive.ts +++ b/apps/dashboard/src/features/archives/local-archive.ts @@ -16,7 +16,8 @@ export function syntheticLocalArchive(): Archive { slug: SELF_HOST_ARCHIVE_ID, domain: "", status: { kind: "running" }, - orcidAdmins: [], + orcidAdmins: [], + osaVersionPin: "", deploymentConfig: null, createdAt: new Date(0), updatedAt: new Date(0), diff --git a/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx b/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx index d71cafa..9311ea4 100644 --- a/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx +++ b/apps/dashboard/src/features/deployments/DeployingScreen.test.tsx @@ -18,6 +18,7 @@ const ARCHIVE: Archive = { domain: "alpine.amacr.in", status: { kind: "deploying" }, orcidAdmins: [], + osaVersionPin: "v0.0.9", deploymentConfig: null, createdAt: new Date(0), updatedAt: new Date(0), diff --git a/apps/dashboard/src/mocks/fixtures/archive.deploying.json b/apps/dashboard/src/mocks/fixtures/archive.deploying.json index 2068701..2c1f0bf 100644 --- a/apps/dashboard/src/mocks/fixtures/archive.deploying.json +++ b/apps/dashboard/src/mocks/fixtures/archive.deploying.json @@ -10,5 +10,6 @@ }, "status": "deploying", "created_at": "2026-07-25T13:58:00+00:00", - "updated_at": "2026-07-25T13:58:00+00:00" + "updated_at": "2026-07-25T13:58:00+00:00", + "osa_version_pin": "v0.0.9" } diff --git a/apps/dashboard/src/mocks/fixtures/archive.error.json b/apps/dashboard/src/mocks/fixtures/archive.error.json index 43714c1..43abe68 100644 --- a/apps/dashboard/src/mocks/fixtures/archive.error.json +++ b/apps/dashboard/src/mocks/fixtures/archive.error.json @@ -7,13 +7,16 @@ "config": { "auth": { "admins": { - "orcid": ["0000-0002-1825-0097"] + "orcid": [ + "0000-0002-1825-0097" + ] } }, "deployment": null }, "status": "error", - "error_message": "provisioning failed: PersistentVolumeClaim pending after 600s — storage class 'gp3' quota exceeded in eu-west-1", + "error_message": "provisioning failed: PersistentVolumeClaim pending after 600s \u2014 storage class 'gp3' quota exceeded in eu-west-1", "created_at": "2026-07-02T08:30:00+00:00", - "updated_at": "2026-07-24T19:12:44+00:00" + "updated_at": "2026-07-24T19:12:44+00:00", + "osa_version_pin": "v0.0.9" } diff --git a/apps/dashboard/src/mocks/fixtures/archive.running.json b/apps/dashboard/src/mocks/fixtures/archive.running.json index 10022c9..226b99d 100644 --- a/apps/dashboard/src/mocks/fixtures/archive.running.json +++ b/apps/dashboard/src/mocks/fixtures/archive.running.json @@ -7,7 +7,10 @@ "config": { "auth": { "admins": { - "orcid": ["0000-0002-1825-0097", "0000-0001-5109-3700"] + "orcid": [ + "0000-0002-1825-0097", + "0000-0001-5109-3700" + ] } }, "deployment": { @@ -18,5 +21,7 @@ }, "status": "running", "created_at": "2026-07-14T10:02:11+00:00", - "updated_at": "2026-07-25T14:06:00+00:00" + "updated_at": "2026-07-25T14:06:00+00:00", + "osa_version_pin": "v0.0.9", + "osa_version": "v0.0.9" } diff --git a/apps/dashboard/src/mocks/fixtures/osa-versions.json b/apps/dashboard/src/mocks/fixtures/osa-versions.json new file mode 100644 index 0000000..71cb87b --- /dev/null +++ b/apps/dashboard/src/mocks/fixtures/osa-versions.json @@ -0,0 +1,29 @@ +[ + { + "version": "v0.0.11", + "status": "supported", + "is_default": true, + "registered_at": "2026-08-16T12:00:00+00:00", + "notes_url": "https://github.com/opensciencearchive/server/releases/tag/v0.0.11" + }, + { + "version": "v0.0.10", + "status": "supported", + "is_default": false, + "registered_at": "2026-07-31T21:00:00+00:00", + "notes_url": "https://github.com/opensciencearchive/server/releases/tag/v0.0.10" + }, + { + "version": "v0.0.9", + "status": "supported", + "is_default": false, + "registered_at": "2026-07-31T17:00:00+00:00", + "notes_url": "https://github.com/opensciencearchive/server/releases/tag/v0.0.9" + }, + { + "version": "v0.0.8", + "status": "deprecated", + "is_default": false, + "registered_at": "2026-07-20T09:00:00+00:00" + } +] diff --git a/apps/dashboard/src/mocks/handlers.ts b/apps/dashboard/src/mocks/handlers.ts index a81c6da..3beddd1 100644 --- a/apps/dashboard/src/mocks/handlers.ts +++ b/apps/dashboard/src/mocks/handlers.ts @@ -20,6 +20,7 @@ import deploymentSucceeded from "./fixtures/deployment.succeeded.json"; import me from "./fixtures/me.json"; import members from "./fixtures/members.json"; import organisations from "./fixtures/organisations.json"; +import osaVersions from "./fixtures/osa-versions.json"; const ARCHIVES = [archiveRunning, archiveError, archiveDeploying]; @@ -101,6 +102,7 @@ export const handlers = [ }), // ── archives ──────────────────────────────────────────────────────── + http.get("*/api/v1/osa-versions", () => HttpResponse.json(osaVersions)), http.get("*/api/v1/archives", () => HttpResponse.json(ARCHIVES)), http.get("*/api/v1/archives/:id", ({ params }) => { const archive = ARCHIVES.find((a) => a.id === params["id"]); @@ -108,6 +110,9 @@ export const handlers = [ ? HttpResponse.json(archive) : jsonError(404, "not_found", `archive '${String(params["id"])}' not found`); }), + http.post("*/api/v1/archives/:id/upgrade", () => + HttpResponse.json(deploymentPending, { status: 202 }), + ), http.post("*/api/v1/archives/:id/deploy", ({ params }) => HttpResponse.json( { diff --git a/apps/dashboard/src/server/ghcr.test.ts b/apps/dashboard/src/server/ghcr.test.ts new file mode 100644 index 0000000..9e403bd --- /dev/null +++ b/apps/dashboard/src/server/ghcr.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { latestOsaVersion, resetLatestOsaVersionCache } from "./ghcr"; + +describe("latestOsaVersion", () => { + beforeEach(() => resetLatestOsaVersionCache()); + afterEach(() => vi.restoreAllMocks()); + + it("passes an abort signal so a stalled registry cannot block /api/node", async () => { + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(Response.json({ token: "t" })) + .mockResolvedValueOnce(Response.json({ tags: ["v0.0.11", "v0.0.9"] })); + + await expect(latestOsaVersion()).resolves.toBe("v0.0.11"); + for (const call of spy.mock.calls) { + expect(call[1]?.signal).toBeInstanceOf(AbortSignal); + } + }); + + it("degrades to null when the lookup aborts", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue( + new DOMException("The operation timed out.", "TimeoutError"), + ); + await expect(latestOsaVersion()).resolves.toBeNull(); + }); +}); diff --git a/apps/dashboard/src/server/ghcr.ts b/apps/dashboard/src/server/ghcr.ts new file mode 100644 index 0000000..6f5843b --- /dev/null +++ b/apps/dashboard/src/server/ghcr.ts @@ -0,0 +1,70 @@ +/** + * Latest published OSA release, straight from the container registry. + * + * The registry (not GitHub releases) is the availability signal: a tag in + * ghcr is pullable by definition. Same flow as the SDK's + * `fetch_latest_osa_version` — anonymous pull-scope token, paginated + * tags/list, highest strict `vX.Y.Z` client-side. Cached in-module (~1h); + * `null` on any failure so callers degrade instead of breaking. + */ + +import { nextPageUrl } from "./link-header"; + +const GHCR_IMAGE = "opensciencearchive/osa"; +const CACHE_TTL_MS = 60 * 60 * 1000; +const MAX_PAGES = 50; +// One deadline for the whole lookup: /api/node awaits this, and the version +// hint is optional — a slow registry must degrade to null, not stall the +// overview behind its loading skeleton. +const DEADLINE_MS = 4_000; +const RELEASE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; + +let cached: { value: string | null; at: number } | null = null; + +export async function latestOsaVersion(): Promise { + if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.value; + const value = await fetchLatest().catch(() => null); + // Cache failures too — a down registry shouldn't be re-probed per render. + cached = { value, at: Date.now() }; + return value; +} + +/** Test hook: drop the module cache. */ +export function resetLatestOsaVersionCache(): void { + cached = null; +} + +async function fetchLatest(): Promise { + const signal = AbortSignal.timeout(DEADLINE_MS); + const tokenRes = await fetch( + `https://ghcr.io/token?scope=repository:${GHCR_IMAGE}:pull`, + { signal }, + ); + if (!tokenRes.ok) return null; + const token = (await tokenRes.json()).token as string | undefined; + if (!token) return null; + + const tags: string[] = []; + let url: string | null = `https://ghcr.io/v2/${GHCR_IMAGE}/tags/list?n=1000`; + for (let page = 0; url !== null && page < MAX_PAGES; page++) { + const res: Response = await fetch(url, { + headers: { authorization: `Bearer ${token}` }, + signal, + }); + if (!res.ok) return null; + const body = (await res.json()) as { tags?: string[] }; + tags.push(...(body.tags ?? [])); + url = nextPageUrl(res.headers.get("link"), "https://ghcr.io"); + } + + const releases = tags + .map((t) => ({ tag: t, m: RELEASE_TAG.exec(t) })) + .filter((x): x is { tag: string; m: RegExpExecArray } => x.m !== null) + .sort( + (a, b) => + Number(a.m[1]) - Number(b.m[1]) || + Number(a.m[2]) - Number(b.m[2]) || + Number(a.m[3]) - Number(b.m[3]), + ); + return releases.at(-1)?.tag ?? null; +} diff --git a/apps/dashboard/src/server/link-header.ts b/apps/dashboard/src/server/link-header.ts new file mode 100644 index 0000000..0b50630 --- /dev/null +++ b/apps/dashboard/src/server/link-header.ts @@ -0,0 +1,13 @@ +/** Extract the `rel="next"` URL from an RFC 5988 Link header, or null. */ +export function nextPageUrl(link: string | null, base: string): string | null { + if (!link) return null; + for (const part of link.split(",")) { + if (!/rel="next"/.test(part)) continue; + const start = part.indexOf("<"); + const end = part.indexOf(">", start); + if (start === -1 || end === -1) return null; + const url = part.slice(start + 1, end); + return url.startsWith("http") ? url : `${base}${url}`; + } + return null; +} diff --git a/apps/dashboard/src/server/releases.ts b/apps/dashboard/src/server/releases.ts new file mode 100644 index 0000000..a213d39 --- /dev/null +++ b/apps/dashboard/src/server/releases.ts @@ -0,0 +1,67 @@ +/** + * Cached GitHub release notes for the upgrade dialog (#222). + * + * Proxied server-side because GitHub's unauthenticated API is rate-limited + * per IP — one cached fetch here instead of one per browser. + */ + +import { nextPageUrl } from "./link-header"; + +const REPO = "opensciencearchive/server"; +const CACHE_TTL_MS = 10 * 60 * 1000; +// 10 × 50 releases ≫ any plausible upgrade range; the cap only guards +// against a pathological Link-header loop. +const MAX_PAGES = 10; + +export const RELEASE_TAG = /^v\d+\.\d+\.\d+$/; + +export interface Release { + version: string; + name: string; + body: string; + htmlUrl: string; +} + +let cached: { releases: Release[]; at: number } | null = null; + +/** Test hook: drop the module cache. */ +export function resetReleasesCache(): void { + cached = null; +} + +/** All published (non-draft, non-prerelease) releases. Throws on GitHub failure. */ +export async function publishedReleases(): Promise { + if (cached && Date.now() - cached.at < CACHE_TTL_MS) return cached.releases; + const releases: Release[] = []; + let url: string | null = + `https://api.github.com/repos/${REPO}/releases?per_page=50`; + for (let page = 0; url !== null && page < MAX_PAGES; page++) { + const res: Response = await fetch(url, { + headers: { accept: "application/vnd.github+json" }, + }); + if (!res.ok) throw new Error(`github releases: ${res.status}`); + const body = (await res.json()) as { + tag_name: string; + name: string | null; + body: string | null; + html_url: string; + draft: boolean; + prerelease: boolean; + }[]; + releases.push( + ...body + .filter( + (r) => !r.draft && !r.prerelease && RELEASE_TAG.test(r.tag_name), + ) + .map((r) => ({ + version: r.tag_name, + name: r.name ?? r.tag_name, + body: r.body ?? "", + htmlUrl: r.html_url, + })), + ); + url = nextPageUrl(res.headers.get("link"), "https://api.github.com"); + } + cached = { releases, at: Date.now() }; + return releases; +} diff --git a/apps/dashboard/src/test/factories.ts b/apps/dashboard/src/test/factories.ts index 7c88f6a..684d953 100644 --- a/apps/dashboard/src/test/factories.ts +++ b/apps/dashboard/src/test/factories.ts @@ -23,6 +23,7 @@ export function buildArchive(over?: Partial): Archive { domain: "alpine-climate.amacr.in", status: { kind: "running" }, orcidAdmins: ["0000-0002-1825-0097"], + osaVersionPin: "v0.0.9", deploymentConfig: { provider: "aws_eks", region: "eu-west-1", volumeSizeGb: 5 }, createdAt: new Date("2026-07-14T10:02:11Z"), updatedAt: new Date("2026-07-25T14:06:00Z"), diff --git a/apps/dashboard/src/ui/checkbox/Checkbox.module.css b/apps/dashboard/src/ui/checkbox/Checkbox.module.css new file mode 100644 index 0000000..b1b5f39 --- /dev/null +++ b/apps/dashboard/src/ui/checkbox/Checkbox.module.css @@ -0,0 +1,27 @@ +.wrapper { + display: inline-flex; + align-items: flex-start; + gap: var(--space-2); + cursor: pointer; + user-select: none; +} + +.box { + margin-top: 3px; + width: 15px; + height: 15px; + accent-color: var(--color-accent); + cursor: pointer; + flex-shrink: 0; +} + +.box:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.label { + font-size: var(--text-sm); + color: var(--color-text); + line-height: 1.45; +} diff --git a/apps/dashboard/src/ui/checkbox/Checkbox.tsx b/apps/dashboard/src/ui/checkbox/Checkbox.tsx new file mode 100644 index 0000000..e9e831b --- /dev/null +++ b/apps/dashboard/src/ui/checkbox/Checkbox.tsx @@ -0,0 +1,20 @@ +import styles from "./Checkbox.module.css"; + +export interface CheckboxProps + extends Omit, "type"> { + /** The clickable label text rendered beside the box. */ + label: React.ReactNode; +} + +/** + * A labelled checkbox — acknowledgment gates ("I understand…"), option + * toggles. The whole label is the hit target. + */ +export function Checkbox({ label, className, ...rest }: CheckboxProps) { + return ( + + ); +} diff --git a/apps/dashboard/src/ui/index.ts b/apps/dashboard/src/ui/index.ts index d3ade05..da586ca 100644 --- a/apps/dashboard/src/ui/index.ts +++ b/apps/dashboard/src/ui/index.ts @@ -2,6 +2,7 @@ export { Badge } from "./badge/Badge"; export { BarChart } from "./bar-chart/BarChart"; export { Button } from "./button/Button"; export { Card, CardFooter } from "./card/Card"; +export { Checkbox } from "./checkbox/Checkbox"; export { CopyButton } from "./copy-button/CopyButton"; export { DataTable, type Column } from "./data-table/DataTable"; export { Dialog } from "./dialog/Dialog";