feat(webui): version update check with badge and download proxy
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
# Version Update Check & Download for OriginEvoScientist
|
||||
|
||||
Date: 2026-08-03
|
||||
Status: Implemented (2026-08-06)
|
||||
Inspired by: sub2api `UpdateService` + `VersionBadge` (see `~/Projects/EvoSci/sub2api/sub2api`)
|
||||
|
||||
Implementation deviations from the original draft:
|
||||
|
||||
- "Latest" is the max semver across `/releases?limit=20` **and** `/tags?limit=20`
|
||||
(not `releases/latest`): Gitea orders `releases/latest` by tag *creation
|
||||
date*, so v0.2.2 (tagged 2026-07-11) ranked below v0.1.19 (tagged 2026-07-13).
|
||||
- `release.sh` builds in a temporary `git worktree` at the tag so a dirty
|
||||
working tree can't leak into artifacts, and supports version == pyproject
|
||||
(reuses the existing tag) for baseline cuts.
|
||||
- Backend routes live in `langgraph_dev/http.py` behind the model-registry
|
||||
`BffAuthenticator` with a new `system:read` scope (added to both WebUI
|
||||
roles in `src/lib/server/delegation.ts`).
|
||||
- The Gitea checker extends the existing `EvoScientist/update_check.py`
|
||||
(PyPI startup check untouched).
|
||||
- No component render test for VersionBadge — the repo has no jsdom/testing
|
||||
-library harness (node-env vitest only); verified in-browser via Playwright.
|
||||
|
||||
## Context
|
||||
|
||||
Port sub2api's "version badge + update prompt" to OriginEvoScientist, using the
|
||||
self-hosted Gitea instance **git.foksai.com** (verified: Gitea 1.26.4, anonymous
|
||||
read access, GitHub-compatible release schema) as the release source.
|
||||
|
||||
Verified facts (2026-08-03):
|
||||
|
||||
- `GET https://git.foksai.com/api/v1/repos/ouyangbo/EvoScientist/releases/latest`
|
||||
→ 200, `tag_name=v0.1.19`, schema matches GitHub (`tag_name/name/body/html_url/
|
||||
draft/prerelease/tarball_url/assets`).
|
||||
- Latest git tag is `v0.1.20` (tag without a release exists).
|
||||
- `ouyangbo/EvoScientist-WebUI` has **no releases and no tags** (404 from
|
||||
`releases/latest` — must be treated as "never published", not an error).
|
||||
- Local `pyproject.toml` is already `0.2.2` → release baseline lags behind the
|
||||
code; Part 1 must cut a fresh release first or `has_update` can never fire.
|
||||
- Asset/attachment URLs returned by the API use the `git.foksai.com:8443` host
|
||||
(instance's HTTPS port); the download allowlist must compare host, ignoring port
|
||||
or allowing `:8443` explicitly.
|
||||
|
||||
Non-goal: **automatic in-place upgrade**. EvoScientist runs from source
|
||||
(Python venv + Next.js build), so sub2api's atomic-binary-swap does not
|
||||
translate. We download + verify + present the install command; the operator
|
||||
applies it.
|
||||
|
||||
## Decisions
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| What counts as "latest"? | `releases/latest`; if 404, fall back to max semver tag via `/tags?limit=20` |
|
||||
| Which repo/component? | `ouyangbo/EvoScientist` (backend) only; WebUI deferred until it starts tagging |
|
||||
| Current version source | `importlib.metadata.version("EvoScientist")` (pyproject is the single source) |
|
||||
| Release creation | Local `scripts/release.sh` + Gitea API token; Gitea Actions later (instance capability unverified) |
|
||||
| Auto-apply | No — download to staging dir, show install command |
|
||||
|
||||
Env config (backend):
|
||||
|
||||
- `EVOSCIENTIST_UPDATE_BASE_URL` — default `https://git.foksai.com`
|
||||
- `EVOSCIENTIST_UPDATE_REPO` — default `ouyangbo/EvoScientist`
|
||||
- `EVOSCIENTIST_UPDATE_CHECK_DISABLED` — set to `1` to disable outbound checks
|
||||
- `GITEA_TOKEN` — only needed by `release.sh` (writes), not by the checker
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Publishing new versions (`EvoScientist` repo)
|
||||
|
||||
**Task 1.1: `scripts/release.sh`**
|
||||
|
||||
Inputs: `scripts/release.sh 0.2.3 "Release notes text"` (version arg required;
|
||||
script refuses if it doesn't differ from pyproject).
|
||||
|
||||
Steps:
|
||||
1. `sed` bump `version = "X.Y.Z"` in `pyproject.toml`; `uv lock` (if lockfile
|
||||
tracks version); commit `chore(release): vX.Y.Z`; `git tag vX.Y.Z`;
|
||||
`git push origin main --tags`.
|
||||
2. `uv build` → `dist/EvoScientist-X.Y.Z.tar.gz`, `dist/evoscientist-X.Y.Z-*.whl`.
|
||||
3. `shasum -a 256 dist/* > dist/checksums.txt`.
|
||||
4. Create release via API:
|
||||
`POST $BASE/api/v1/repos/ouyangbo/EvoScientist/releases`
|
||||
with `{tag_name, name: "Release vX.Y.Z", body}`; header
|
||||
`Authorization: token $GITEA_TOKEN`.
|
||||
5. Upload each asset:
|
||||
`POST /repos/.../releases/{id}/assets?name=<fname>` (binary body).
|
||||
6. Verify: `GET releases/latest` returns the new tag.
|
||||
|
||||
**Task 1.2: cut baseline release v0.2.2** — run the script once so the checker
|
||||
has a meaningful comparison target.
|
||||
|
||||
**Task 1.3 (deferred): Gitea Actions** — probe
|
||||
`GET /api/v1/repos/ouyangbo/EvoScientist/actions/tasks` (or instance admin)
|
||||
to see if Actions is enabled; if yes, port `.github/workflows/build.yml` into
|
||||
`.gitea/workflows/release.yml` triggered on `push: tags: ["v*"]` doing steps
|
||||
2–5. Not required for v1.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Update check
|
||||
|
||||
**Task 2.1: backend module `EvoScientist/update_check.py`** (new file)
|
||||
|
||||
```python
|
||||
class UpdateInfo(TypedDict):
|
||||
current_version: str
|
||||
latest_version: str
|
||||
has_update: bool
|
||||
release_url: str | None
|
||||
release_notes: str | None
|
||||
published_at: str | None
|
||||
cached: bool
|
||||
warning: str | None
|
||||
|
||||
def get_update_info(*, force: bool = False) -> UpdateInfo
|
||||
```
|
||||
|
||||
- Current version: `importlib.metadata.version("EvoScientist")`, fallback
|
||||
`"0.0.0-dev"` on `PackageNotFoundError`.
|
||||
- Latest: `GET {base}/api/v1/repos/{repo}/releases/latest` (10s timeout).
|
||||
- 404 → `GET {base}/api/v1/repos/{repo}/tags?limit=20`, take max semver.
|
||||
- Other network/HTTP error → return last cached value with `warning` set;
|
||||
if no cache, `latest = current`, `has_update = False`, `warning` set.
|
||||
- Version compare: strip leading `v`, split `.`, int-compare 3 segments
|
||||
(mirror sub2api `compareVersions`, `update_service.go:641`); non-numeric
|
||||
segments compare as 0.
|
||||
- Cache: module-level `{"info": ..., "fetched_at": ...}`, TTL 1200s; `force`
|
||||
bypasses. Mirror sub2api TTL (`update_service.go:32`).
|
||||
- Disabled switch: `EVOSCIENTIST_UPDATE_CHECK_DISABLED=1` → immediately return
|
||||
current==latest, no network.
|
||||
- HTTP client: `httpx` (already a dependency).
|
||||
|
||||
**Task 2.2: backend route** in `EvoScientist/langgraph_dev/http.py` (alongside
|
||||
the existing `/internal/workspace-scopes/*` routes, ~line 878):
|
||||
|
||||
- `GET /internal/system/version?force=true` → JSON from `get_update_info`.
|
||||
|
||||
**Task 2.3: WebUI BFF** `src/app/api/system/version/route.ts` (new)
|
||||
|
||||
- `GET` proxies to `{backend}/internal/system/version`, forwards `force`
|
||||
query param, returns backend JSON; on backend failure → 502 with the
|
||||
existing error-shape used by other BFF routes (mirror
|
||||
`src/app/api/skills/route.ts`).
|
||||
|
||||
**Task 2.4: WebUI component `src/app/components/VersionBadge.tsx`** (new)
|
||||
|
||||
Modeled on sub2api `VersionBadge.vue` and our `ErrorLogBell.tsx`:
|
||||
|
||||
- Button showing `v{current_version}`; when `has_update`: amber background +
|
||||
ping-dot indicator.
|
||||
- Dropdown: current version, latest version, "View release notes" link
|
||||
(`release_url`, opens new tab), refresh button (force=true), and the
|
||||
download section from Part 3.
|
||||
- SWR over `/api/system/version`, fetch on mount + on dropdown open; no
|
||||
aggressive polling (server caches 20 min anyway).
|
||||
- Mount in `src/app/page.tsx` header next to `<ErrorLogBell />`.
|
||||
- All strings English.
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Update download
|
||||
|
||||
**Task 3.1: backend download** — extend `update_check.py`:
|
||||
|
||||
```python
|
||||
def download_update(version: str | None = None) -> DownloadResult
|
||||
```
|
||||
|
||||
1. Resolve target release: `releases/latest` or
|
||||
`GET /repos/{repo}/releases/tags/{version}` for a specific version.
|
||||
2. Pick asset, in priority order:
|
||||
a. `evoscientist-X.Y.Z-py3-none-any.whl` (from `assets[].name`,
|
||||
`browser_download_url`)
|
||||
b. sdist `EvoScientist-X.Y.Z.tar.gz`
|
||||
c. `tarball_url` fallback
|
||||
3. **Host allowlist**: parsed asset URL host must equal the update base URL's
|
||||
host (allow port `:8443`); reject everything else (SSRF guard, mirror
|
||||
`validateDownloadURL` at `update_service.go:447`).
|
||||
4. Stream-download with 200 MB cap to
|
||||
`{workspace}/.evoscientist/updates/v{X.Y.Z}/` (create dir; `workspace` =
|
||||
`EVOSCIENTIST_WORKSPACE_DIR`).
|
||||
5. If the release has a `checksums.txt` asset, download it and verify sha256
|
||||
of the chosen file; fail hard on mismatch, delete the file.
|
||||
6. Return `{version, file, path, suggested_command}` where
|
||||
`suggested_command = f"uv pip install {path} # then restart the backend"`.
|
||||
|
||||
- Route: `POST /internal/system/version/download`, body `{"version": optional}`,
|
||||
timeout 120s.
|
||||
|
||||
**Task 3.2: WebUI** — BFF `src/app/api/system/version/download/route.ts`
|
||||
(POST proxy), and in `VersionBadge` dropdown a "Download update" button
|
||||
(disabled while running; shows spinner, then success path + copyable install
|
||||
command; `errorToast` on failure, mirroring ErrorLogBell patterns).
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
Backend (`EvoScientist/tests/` or colocated, pytest):
|
||||
|
||||
- semver compare: `v0.1.19 < 0.2.2`, equal, malformed segments
|
||||
- 404 on `releases/latest` → falls back to tags → still 404 → latest=current
|
||||
- cache: second call within TTL does not hit network (mock httpx)
|
||||
- `force=True` bypasses cache
|
||||
- download: asset priority (wheel over sdist), host allowlist rejects
|
||||
`evil.com`, size cap aborts, checksum mismatch deletes file
|
||||
- disabled env var short-circuits
|
||||
|
||||
WebUI (vitest, node env — same as existing `route.test.ts` files):
|
||||
|
||||
- BFF GET proxies + forwards `force`
|
||||
- BFF POST download proxies body
|
||||
- Badge renders amber/dot only when `has_update`
|
||||
|
||||
## Rollout order
|
||||
|
||||
1. Part 1 script + baseline release v0.2.2 (validates the publishing path)
|
||||
2. Task 2.1 + 2.2 (backend check) with tests
|
||||
3. Task 2.3 + 2.4 (WebUI badge), browser-verify with playwright
|
||||
4. Task 3.1 + 3.2 (download), verify a real v0.2.2 download end-to-end
|
||||
|
||||
## Open risks
|
||||
|
||||
- Gitea instance may require auth for API reads in the future → checker should
|
||||
accept optional `EVOSCIENTIST_UPDATE_TOKEN` header from the start.
|
||||
- Release URLs embed `:8443`; if the instance later moves to 443, allowlist
|
||||
logic must not hard-code the port.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })),
|
||||
configApiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/server/actor", () => ({
|
||||
requireActor: mocks.requireActor,
|
||||
ActorError: class ActorError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/server/evoscientistConfigClient", () => ({
|
||||
configApiFetch: mocks.configApiFetch,
|
||||
ConfigApiError: class ConfigApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string | null = null,
|
||||
readonly details: unknown[] = []
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { POST } = await import("./route");
|
||||
|
||||
const RESULT = {
|
||||
version: "0.2.2",
|
||||
file: "evoscientist-0.2.2-py3-none-any.whl",
|
||||
path: "/ws/.evoscientist/updates/v0.2.2/evoscientist-0.2.2-py3-none-any.whl",
|
||||
suggested_command: "uv pip install /ws/.evoscientist/updates/v0.2.2/...",
|
||||
};
|
||||
|
||||
function req(body?: unknown): NextRequest {
|
||||
return new NextRequest(new URL("http://localhost/api/system/version/download"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/system/version/download", () => {
|
||||
it("proxies an empty body when none is given", async () => {
|
||||
mocks.configApiFetch.mockResolvedValue({ status: 200, body: RESULT });
|
||||
const res = await POST(req());
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual(RESULT);
|
||||
expect(mocks.configApiFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: "tester" }),
|
||||
"/internal/system/version/download",
|
||||
{ method: "POST", body: {} }
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an explicit version", async () => {
|
||||
mocks.configApiFetch.mockResolvedValue({ status: 200, body: RESULT });
|
||||
await POST(req({ version: "0.2.2" }));
|
||||
expect(mocks.configApiFetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"/internal/system/version/download",
|
||||
{ method: "POST", body: { version: "0.2.2" } }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { requireActor, type Actor } from "@/lib/server/actor";
|
||||
import { configApiFetch } from "@/lib/server/evoscientistConfigClient";
|
||||
import { isCrossOrigin } from "@/lib/server/workspace";
|
||||
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export interface SystemVersionDownloadResult {
|
||||
version: string;
|
||||
file: string;
|
||||
path: string;
|
||||
suggested_command: string;
|
||||
}
|
||||
|
||||
/** Stage a release artifact on the backend host; never auto-installs. */
|
||||
export async function POST(request: NextRequest) {
|
||||
let actor: Actor | undefined;
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
|
||||
{ status: 403, headers: NO_STORE }
|
||||
);
|
||||
}
|
||||
actor = requireActor(request);
|
||||
const body: unknown = await request.json().catch(() => null);
|
||||
const version =
|
||||
body && typeof body === "object" && "version" in body
|
||||
? (body as { version: unknown }).version
|
||||
: undefined;
|
||||
const { body: result } =
|
||||
await configApiFetch<SystemVersionDownloadResult>(
|
||||
actor,
|
||||
"/internal/system/version/download",
|
||||
{
|
||||
method: "POST",
|
||||
body: typeof version === "string" ? { version } : {},
|
||||
}
|
||||
);
|
||||
return NextResponse.json(result, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return routeErrorResponse(error, actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })),
|
||||
configApiFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/server/actor", () => ({
|
||||
requireActor: mocks.requireActor,
|
||||
ActorError: class ActorError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/server/evoscientistConfigClient", () => ({
|
||||
configApiFetch: mocks.configApiFetch,
|
||||
ConfigApiError: class ConfigApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
readonly code: string | null = null,
|
||||
readonly details: unknown[] = []
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const { GET } = await import("./route");
|
||||
const { ConfigApiError } = await import("@/lib/server/evoscientistConfigClient");
|
||||
|
||||
const INFO = {
|
||||
current_version: "0.2.2",
|
||||
latest_version: "0.2.2",
|
||||
has_update: false,
|
||||
release_url: null,
|
||||
release_notes: null,
|
||||
published_at: null,
|
||||
cached: false,
|
||||
warning: null,
|
||||
};
|
||||
|
||||
function req(url: string): NextRequest {
|
||||
return new NextRequest(new URL(url, "http://localhost"));
|
||||
}
|
||||
|
||||
describe("GET /api/system/version", () => {
|
||||
it("proxies the backend version endpoint", async () => {
|
||||
mocks.configApiFetch.mockResolvedValue({ status: 200, body: INFO });
|
||||
const res = await GET(req("http://localhost/api/system/version"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual(INFO);
|
||||
expect(mocks.configApiFetch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sub: "tester" }),
|
||||
"/internal/system/version"
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards force=true", async () => {
|
||||
mocks.configApiFetch.mockResolvedValue({ status: 200, body: INFO });
|
||||
await GET(req("http://localhost/api/system/version?force=true"));
|
||||
expect(mocks.configApiFetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"/internal/system/version?force=true"
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces backend failures", async () => {
|
||||
mocks.configApiFetch.mockRejectedValue(
|
||||
new ConfigApiError("backend down", 502, "UPSTREAM")
|
||||
);
|
||||
const res = await GET(req("http://localhost/api/system/version"));
|
||||
expect(res.status).toBe(502);
|
||||
expect(await res.json()).toMatchObject({ code: "UPSTREAM" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { requireActor, type Actor } from "@/lib/server/actor";
|
||||
import { configApiFetch } from "@/lib/server/evoscientistConfigClient";
|
||||
import { isCrossOrigin } from "@/lib/server/workspace";
|
||||
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
has_update: boolean;
|
||||
release_url: string | null;
|
||||
release_notes: string | null;
|
||||
published_at: string | null;
|
||||
cached: boolean;
|
||||
warning: string | null;
|
||||
}
|
||||
|
||||
/** Installed vs latest published EvoScientist version. Any authenticated
|
||||
* user may call this; the backend enforces the system:read scope. The
|
||||
* backend caches upstream checks for 20 minutes; `?force=true` bypasses. */
|
||||
export async function GET(request: NextRequest) {
|
||||
let actor: Actor | undefined;
|
||||
try {
|
||||
if (isCrossOrigin(request)) {
|
||||
return NextResponse.json(
|
||||
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
|
||||
{ status: 403, headers: NO_STORE }
|
||||
);
|
||||
}
|
||||
actor = requireActor(request);
|
||||
const force = request.nextUrl.searchParams.get("force") === "true";
|
||||
const { body } = await configApiFetch<SystemVersionInfo>(
|
||||
actor,
|
||||
`/internal/system/version${force ? "?force=true" : ""}`
|
||||
);
|
||||
return NextResponse.json(body, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
return routeErrorResponse(error, actor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { Copy, Download, ExternalLink, RefreshCw, Tag } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { errorToast } from "@/lib/errorReporter";
|
||||
import type { SystemVersionInfo } from "@/app/api/system/version/route";
|
||||
import type { SystemVersionDownloadResult } from "@/app/api/system/version/download/route";
|
||||
|
||||
async function fetchVersion(url: string): Promise<SystemVersionInfo> {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(
|
||||
body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `Version check failed (${res.status}).`
|
||||
);
|
||||
}
|
||||
return (await res.json()) as SystemVersionInfo;
|
||||
}
|
||||
|
||||
export function VersionBadge() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [downloaded, setDownloaded] =
|
||||
useState<SystemVersionDownloadResult | null>(null);
|
||||
const { data, error, mutate } = useSWR<SystemVersionInfo>(
|
||||
"/api/system/version",
|
||||
fetchVersion,
|
||||
{ revalidateOnFocus: false, shouldRetryOnError: false }
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const info = await fetchVersion("/api/system/version?force=true");
|
||||
await mutate(info, { revalidate: false });
|
||||
} catch (e) {
|
||||
errorToast(
|
||||
"version.refresh",
|
||||
e instanceof Error ? e.message : "Couldn't check for updates."
|
||||
);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const res = await fetch("/api/system/version/download", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const body = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
body && typeof body.message === "string"
|
||||
? body.message
|
||||
: body && typeof body.error === "string"
|
||||
? body.error
|
||||
: `Download failed (${res.status}).`
|
||||
);
|
||||
}
|
||||
setDownloaded(body as SystemVersionDownloadResult);
|
||||
} catch (e) {
|
||||
errorToast(
|
||||
"version.download",
|
||||
e instanceof Error ? e.message : "Couldn't download the update."
|
||||
);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) setDownloaded(null);
|
||||
};
|
||||
|
||||
const hasUpdate = data?.has_update === true;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label="Version"
|
||||
title={data ? `EvoScientist v${data.current_version}` : "Version"}
|
||||
className={`relative h-8 gap-1 px-2 text-xs ${
|
||||
hasUpdate
|
||||
? "bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 dark:text-amber-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Tag className="size-3.5" aria-hidden="true" />
|
||||
{data ? `v${data.current_version}` : "…"}
|
||||
{hasUpdate && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-500 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
||||
<div className="border-b border-border px-3 py-2 text-sm font-medium">
|
||||
Version
|
||||
</div>
|
||||
{error && !data ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Couldn't load version info.
|
||||
</p>
|
||||
) : !data ? (
|
||||
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 px-3 py-3 text-sm">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Installed</span>
|
||||
<span className="font-mono">v{data.current_version}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-muted-foreground">Latest</span>
|
||||
<span
|
||||
className={`font-mono ${
|
||||
hasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
|
||||
}`}
|
||||
>
|
||||
v{data.latest_version}
|
||||
</span>
|
||||
</div>
|
||||
{hasUpdate && (
|
||||
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
|
||||
A new version of EvoScientist is available.
|
||||
</p>
|
||||
)}
|
||||
{data.warning && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last check failed: {data.warning}
|
||||
</p>
|
||||
)}
|
||||
{hasUpdate && !downloaded && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={downloading}
|
||||
onClick={() => void handleDownload()}
|
||||
>
|
||||
<Download
|
||||
className={`mr-1 size-3.5 ${downloading ? "animate-bounce" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{downloading
|
||||
? "Downloading..."
|
||||
: `Download v${data.latest_version}`}
|
||||
</Button>
|
||||
)}
|
||||
{downloaded && (
|
||||
<div className="rounded-md border border-border p-2 text-xs">
|
||||
<p className="break-all font-mono text-muted-foreground">
|
||||
{downloaded.path}
|
||||
</p>
|
||||
<div className="mt-1 flex items-start gap-1">
|
||||
<code className="flex-1 break-all rounded bg-muted px-1.5 py-1">
|
||||
{downloaded.suggested_command}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 shrink-0"
|
||||
aria-label="Copy install command"
|
||||
title="Copy install command"
|
||||
onClick={() =>
|
||||
void navigator.clipboard.writeText(
|
||||
downloaded.suggested_command
|
||||
)
|
||||
}
|
||||
>
|
||||
<Copy className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Run this on the backend host, then restart it.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 border-t border-border p-2">
|
||||
{data?.release_url && (
|
||||
<Button variant="ghost" size="sm" className="flex-1" asChild>
|
||||
<a href={data.release_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="mr-1 size-3.5" aria-hidden="true" />
|
||||
Release notes
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
disabled={refreshing}
|
||||
onClick={() => void handleRefresh()}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1 size-3.5 ${refreshing ? "animate-spin" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{refreshing ? "Checking..." : "Check now"}
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const SCOPE_MODEL_CONFIG_WRITE = "model_config:write";
|
||||
export const SCOPE_MODEL_CONFIG_TEST = "model_config:test";
|
||||
export const SCOPE_MODEL_SELECT = "model:select";
|
||||
export const SCOPE_RUN_CREATE = "run:create";
|
||||
export const SCOPE_SYSTEM_READ = "system:read";
|
||||
|
||||
const ADMIN_SCOPES = [
|
||||
SCOPE_MODEL_CONFIG_READ,
|
||||
@@ -32,9 +33,10 @@ const ADMIN_SCOPES = [
|
||||
SCOPE_MODEL_CONFIG_TEST,
|
||||
SCOPE_MODEL_SELECT,
|
||||
SCOPE_RUN_CREATE,
|
||||
SCOPE_SYSTEM_READ,
|
||||
] as const;
|
||||
|
||||
const USER_SCOPES = [SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE] as const;
|
||||
const USER_SCOPES = [SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE, SCOPE_SYSTEM_READ] as const;
|
||||
|
||||
/** Fixed role → scopes mapping (design doc 7.2). */
|
||||
export function scopesForRole(role: UserRole): string[] {
|
||||
|
||||
Reference in New Issue
Block a user