diff --git a/client/src/components/settings/NineRouterUpdate.tsx b/client/src/components/settings/NineRouterUpdate.tsx new file mode 100644 index 00000000..14acbb1f --- /dev/null +++ b/client/src/components/settings/NineRouterUpdate.tsx @@ -0,0 +1,242 @@ +/** + * @file NineRouterUpdate.tsx + * @description **Update 9router** on the AI Proxy page (an admin's; beside + * Config 9router): a button — dotted when npm has a newer 9router — and a + * dialog that runs the server's three-step job (`server/lib/nine-router-update.js`): + * disconnect and shut 9router down, `npm install -g 9router@latest`, start it + * again hidden (tray, no browser) — the last only when it was running before. + * The dialog shows why an update cannot run right now (the server's `blocker`, + * with the command to run by hand when sudo needs a password) and follows the + * job by polling until it ends. Closing the dialog does not stop the job. + * @author Nguyễn Ngọc Trí Vĩ + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react"; +import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api"; +import { copyText } from "../../lib/clipboard"; +import { useDialog } from "../../hooks/useDialog"; + +const POLL_MS = 1000; + +function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) { + if (status === "running") return ; + if (status === "done") return ; + if (status === "failed") return ; + if (status === "skipped") return ; + return ; +} + +export function NineRouterUpdate() { + const { t } = useTranslation("settings"); + const [status, setStatus] = useState(null); + const [open, setOpen] = useState(false); + const [starting, setStarting] = useState(false); + const [error, setError] = useState(null); + const dialogRef = useRef(null); + useDialog(open, dialogRef, () => setOpen(false)); + + const load = useCallback(async (fresh = false) => { + try { + setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const running = status?.job?.status === "running"; + // Follow a running job — also one started from another tab. + useEffect(() => { + if (!running) return; + const timer = window.setInterval(() => void load(), POLL_MS); + return () => window.clearInterval(timer); + }, [running, load]); + + const openDialog = () => { + setError(null); + setOpen(true); + void load(true); + }; + + const start = async () => { + setStarting(true); + setError(null); + try { + const job = await api.aiProxies.nineRouterUpdate(); + setStatus((s) => (s ? { ...s, job } : s)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + void load(true); + } finally { + setStarting(false); + } + }; + + if (!status?.installed) return null; + const job = status.job; + const blocker = status.blocker; + + return ( + <> + + + {open && ( +
+
+

+ {t("aiProxy.update9router")} +

+ +
+
{t("aiProxy.update9routerCurrent")}
+
{status.version ?? "—"}
+
{t("aiProxy.update9routerLatest")}
+
{status.latest ?? "—"}
+
{t("aiProxy.update9routerState")}
+
+ {status.running + ? status.managedBy === "systemd" + ? t("aiProxy.update9routerRunningUnit") + : t("aiProxy.update9routerRunning") + : t("aiProxy.update9routerStopped")} +
+
+ +

+ {status.running + ? t("aiProxy.update9routerExplain") + : t("aiProxy.update9routerExplainStopped")} +

+ + {job && ( +
    + {job.steps.map((s) => ( +
  1. + + + + + + {t(`aiProxy.update9routerStep.${s.id}`)} + + {s.message && ( + + {s.message} + + )} + +
  2. + ))} +
+ )} + {job && job.status !== "running" && ( +

+ {job.status !== "done" + ? t("aiProxy.update9routerFailed") + : job.steps.find((s) => s.id === "relaunch")?.status === "skipped" + ? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" }) + : t("aiProxy.update9routerDone", { version: job.to ?? "—" })} +

+ )} + {job?.log && ( +
+ + {t("aiProxy.update9routerLog")} + +
+                  {job.log}
+                
+
+ )} + + {blocker && !running && blocker.code !== "EUPTODATE" && ( +
+

{blocker.message}

+ {blocker.command && ( +
+ + {blocker.command} + + +
+ )} +
+ )} + {blocker?.code === "EUPTODATE" && !running && ( +

{t("aiProxy.update9routerUpToDate")}

+ )} + {error && ( +

+ {error} +

+ )} + +
+ + +
+
+
+ )} + + ); +} diff --git a/client/src/components/settings/__tests__/NineRouterUpdate.test.tsx b/client/src/components/settings/__tests__/NineRouterUpdate.test.tsx new file mode 100644 index 00000000..ed8c1cea --- /dev/null +++ b/client/src/components/settings/__tests__/NineRouterUpdate.test.tsx @@ -0,0 +1,95 @@ +/** + * @file Tests for **Update 9router** on the AI Proxy page: hidden without a + * 9router, a dialog that shows versions and starts the job, the server's + * blocker (with the manual command) disabling the start, and the job's steps. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { NineRouterUpdate } from "../NineRouterUpdate"; +import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api"; + +vi.mock("../../../lib/api", () => ({ + api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus); +const startMock = vi.mocked(api.aiProxies.nineRouterUpdate); + +const base: NineRouterUpdateStatus = { + installed: true, + version: "0.5.104", + latest: "0.5.109", + updateAvailable: true, + running: true, + managedBy: "systemd", + blocker: null, + job: null, +}; + +const job: NineRouterUpdateJob = { + status: "running", + startedAt: "2026-10-26T00:00:00Z", + finishedAt: null, + from: "0.5.104", + to: null, + managedBy: "systemd", + install: "direct", + steps: [ + { id: "shutdown", status: "done", message: null }, + { id: "update", status: "running", message: null }, + { id: "relaunch", status: "pending", message: null }, + ], + log: "", +}; + +beforeEach(() => { + statusMock.mockReset(); + startMock.mockReset(); +}); + +describe("NineRouterUpdate", () => { + it("renders nothing when 9router is not installed", async () => { + statusMock.mockResolvedValue({ ...base, installed: false }); + const { container } = render(); + await waitFor(() => expect(statusMock).toHaveBeenCalled()); + expect(container).toBeEmptyDOMElement(); + }); + + it("opens with both versions and starts the three-step job", async () => { + statusMock.mockResolvedValue(base); + startMock.mockResolvedValue(job); + render(); + await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ })); + expect(statusMock).toHaveBeenLastCalledWith(true); + const dialog = await screen.findByRole("dialog"); + expect(dialog).toHaveTextContent("0.5.104"); + expect(dialog).toHaveTextContent("0.5.109"); + await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" })); + expect(startMock).toHaveBeenCalledTimes(1); + expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument(); + expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument(); + expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument(); + }); + + it("shows the blocker with its manual command and keeps the start disabled", async () => { + statusMock.mockResolvedValue({ + ...base, + blocker: { + code: "ENOPERM", + message: "/usr/local is not writable by this account and sudo needs a password", + command: "sudo npm install -g 9router@latest", + }, + }); + render(); + await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ })); + expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled(); + }); +}); diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 272074a1..df7257fb 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -759,6 +759,29 @@ "description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.", "add": "Add profile", "config9router": "Config 9router", + "update9router": "Update 9router", + "update9routerAvailable": "A newer 9router is available", + "update9routerCurrent": "Installed", + "update9routerLatest": "Latest", + "update9routerState": "State", + "update9routerRunning": "Running", + "update9routerRunningUnit": "Running (9router.service)", + "update9routerStopped": "Not running", + "update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).", + "update9routerExplainStopped": "9router is not running: it is updated with npm and left off.", + "update9routerStep": { + "shutdown": "Disconnect and shut down 9router", + "update": "Run the update (npm install -g 9router@latest)", + "relaunch": "Start 9router again, hidden" + }, + "update9routerDone": "9router {{version}} is running.", + "update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.", + "update9routerFailed": "The update did not finish — see the steps and the log.", + "update9routerLog": "npm output", + "update9routerUpToDate": "9router is already the latest version.", + "update9routerCopy": "Copy command", + "update9routerClose": "Close", + "update9routerStart": "Update now", "edit": "Edit", "remove": "Remove", "empty": "No proxy profile saved yet. Native runs against the vendor's own API.", diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index c09636f2..6d1f6b51 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -747,6 +747,29 @@ "description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.", "add": "Thêm profile", "config9router": "Config 9router", + "update9router": "Cập nhật 9router", + "update9routerAvailable": "Đã có bản 9router mới", + "update9routerCurrent": "Đang cài", + "update9routerLatest": "Mới nhất", + "update9routerState": "Trạng thái", + "update9routerRunning": "Đang chạy", + "update9routerRunningUnit": "Đang chạy (9router.service)", + "update9routerStopped": "Không chạy", + "update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).", + "update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.", + "update9routerStep": { + "shutdown": "Ngắt kết nối và tắt 9router", + "update": "Chạy cập nhật (npm install -g 9router@latest)", + "relaunch": "Mở lại 9router ở chế độ ẩn" + }, + "update9routerDone": "9router {{version}} đang chạy.", + "update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.", + "update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.", + "update9routerLog": "Output của npm", + "update9routerUpToDate": "9router đã là bản mới nhất.", + "update9routerCopy": "Sao chép lệnh", + "update9routerClose": "Đóng", + "update9routerStart": "Cập nhật ngay", "edit": "Sửa", "remove": "Xoá", "empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index fe15c7c3..38d72d35 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -3281,6 +3281,12 @@ export const api = { list: () => request<{ items: AiProxy[] }>("/ai-proxies"), /** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */ nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"), + /** GET /api/ai-proxies/9router/update - versions, whether it runs, why an update would be refused, the job. */ + nineRouterUpdateStatus: (fresh = false) => + request(`/ai-proxies/9router/update${fresh ? "?fresh=1" : ""}`), + /** POST /api/ai-proxies/9router/update - shut 9router down, update it, start it again hidden (admin). */ + nineRouterUpdate: () => + request("/ai-proxies/9router/update", { method: "POST" }), /** POST /api/ai-proxies - save a new profile. */ create: (body: AiProxyInput) => request("/ai-proxies", { method: "POST", body: JSON.stringify(body) }), @@ -4836,6 +4842,38 @@ export interface AiProxy { * error is still a successful HTTP call: `ok` is the PROXY's verdict, `status` * is its HTTP code (null when nothing answered at all), and `message` is its * own words — truncated server-side, never the key. */ +/** One step of the 9router update job, in order: shutdown → update → relaunch. */ +export interface NineRouterUpdateStep { + id: "shutdown" | "update" | "relaunch"; + status: "pending" | "running" | "done" | "failed" | "skipped"; + message: string | null; +} + +/** The server's one 9router update job (in memory; null before the first). */ +export interface NineRouterUpdateJob { + status: "running" | "done" | "failed"; + startedAt: string; + finishedAt: string | null; + from: string | null; + to: string | null; + managedBy: "systemd" | "process" | null; + install: "direct" | "sudo"; + steps: NineRouterUpdateStep[]; + log: string; +} + +/** GET /api/ai-proxies/9router/update. `blocker` is why a start would be refused now. */ +export interface NineRouterUpdateStatus { + installed: boolean; + version: string | null; + latest: string | null; + updateAvailable: boolean; + running: boolean; + managedBy: "systemd" | "process" | null; + blocker: { code: string; message: string; command?: string } | null; + job: NineRouterUpdateJob | null; +} + export interface AiProxyProbe { ok: boolean; status: number | null; diff --git a/client/src/pages/AiProxyConfig.tsx b/client/src/pages/AiProxyConfig.tsx index 2393822a..cd8e6f2c 100644 --- a/client/src/pages/AiProxyConfig.tsx +++ b/client/src/pages/AiProxyConfig.tsx @@ -30,6 +30,8 @@ * — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the * viewer's own localhost: a teammate on the LAN has no 9router of their own. * It is shown only when the server finds a `9router` binary installed. + * Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it + * down, `npm install -g 9router@latest`, start it again hidden. * * **Test** asks the endpoint itself, once as Claude Code would * (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each. @@ -54,6 +56,7 @@ import { confirmDialog } from "../lib/confirm"; import { copyText } from "../lib/clipboard"; import { useDialog } from "../hooks/useDialog"; import { useAuth } from "../components/AuthGate"; +import { NineRouterUpdate } from "../components/settings/NineRouterUpdate"; const NINE_ROUTER_PORT = 20128; @@ -271,6 +274,7 @@ export function AiProxyConfig() { {t("aiProxy.config9router")} )} + {nineRouter && isAdmin && }