feat(ai-proxy): Update 9router button — shut down, npm update, relaunch hidden

Settings → AI Proxy gains an admin-only Update 9router button beside
Config 9router. One in-memory job (server/lib/nine-router-update.js) stops
9router (its user unit, else this account's own 9router processes), runs
npm install -g --prefix <its prefix> 9router@latest (sudo -n only when
sudoers already allows it, probed before anything stops), then relaunches
it hidden only if it was running. A failed update still relaunches the old
version. Nothing in the request names a command, path or package.
This commit is contained in:
2026-10-26 17:00:33 +07:00
parent 631ea96f19
commit 569b68ec98
13 changed files with 1318 additions and 2 deletions
+38
View File
@@ -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<NineRouterUpdateStatus>(`/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<NineRouterUpdateJob>("/ai-proxies/9router/update", { method: "POST" }),
/** POST /api/ai-proxies - save a new profile. */
create: (body: AiProxyInput) =>
request<AiProxy>("/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;