0f17efe3b4
- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc, .credentials.json…) is always refused, but a word like token/secret/ password/credential only in a data file's name — design-tokens.md or count_tokens.py is skill content, not a key. - System Health: off Linux the History, CPU threads and Busiest processes cards (all read from /proc) are left out instead of shown empty; the CPU bar without a sample and Windows' always-zero Load row go too. - Users pick their own pool account per tool (/api/me/pool-accounts, Settings → AI tools), within their groups, refused while pinned. - Admin user detail, 9router update, session replay and Antigravity catalog changes from parallel sessions.
243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
/**
|
|
* @file NineRouterUpdate.tsx
|
|
* @description **Update 9router** on the AI Proxy page (every user'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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
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 <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
|
|
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
|
|
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
|
|
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
|
|
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
|
|
}
|
|
|
|
export function NineRouterUpdate() {
|
|
const { t } = useTranslation("settings");
|
|
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [starting, setStarting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(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 (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={openDialog}
|
|
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<ArrowUpCircle className="h-3.5 w-3.5" />
|
|
)}
|
|
{t("aiProxy.update9router")}
|
|
{status.updateAvailable && !running && (
|
|
<span
|
|
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
|
|
aria-label={t("aiProxy.update9routerAvailable")}
|
|
/>
|
|
)}
|
|
</button>
|
|
|
|
{open && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="nine-router-update-title"
|
|
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
|
>
|
|
<h3
|
|
id="nine-router-update-title"
|
|
className="mb-3 text-sm font-semibold text-fg-primary"
|
|
>
|
|
{t("aiProxy.update9router")}
|
|
</h3>
|
|
|
|
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
|
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
|
|
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
|
|
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
|
|
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
|
|
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
|
|
<dd className="text-fg-primary">
|
|
{status.running
|
|
? status.managedBy === "systemd"
|
|
? t("aiProxy.update9routerRunningUnit")
|
|
: t("aiProxy.update9routerRunning")
|
|
: t("aiProxy.update9routerStopped")}
|
|
</dd>
|
|
</dl>
|
|
|
|
<p className="mb-3 text-[11px] text-fg-muted">
|
|
{status.running
|
|
? t("aiProxy.update9routerExplain")
|
|
: t("aiProxy.update9routerExplainStopped")}
|
|
</p>
|
|
|
|
{job && (
|
|
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
|
|
{job.steps.map((s) => (
|
|
<li key={s.id} className="flex items-start gap-2 text-xs">
|
|
<span className="mt-0.5">
|
|
<StepIcon status={s.status} />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="text-fg-primary">
|
|
{t(`aiProxy.update9routerStep.${s.id}`)}
|
|
</span>
|
|
{s.message && (
|
|
<span className="block break-words text-[11px] text-fg-muted">
|
|
{s.message}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
{job && job.status !== "running" && (
|
|
<p
|
|
className={`mb-3 text-xs ${
|
|
job.status === "done" ? "text-status-success" : "text-status-danger"
|
|
}`}
|
|
>
|
|
{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 ?? "—" })}
|
|
</p>
|
|
)}
|
|
{job?.log && (
|
|
<details className="mb-3">
|
|
<summary className="cursor-pointer text-[11px] text-fg-muted">
|
|
{t("aiProxy.update9routerLog")}
|
|
</summary>
|
|
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
|
|
{job.log}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
|
|
{blocker && !running && blocker.code !== "EUPTODATE" && (
|
|
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
|
|
<p>{blocker.message}</p>
|
|
{blocker.command && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
|
|
{blocker.command}
|
|
</code>
|
|
<button
|
|
type="button"
|
|
onClick={() => void copyText(blocker.command!)}
|
|
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
|
|
aria-label={t("aiProxy.update9routerCopy")}
|
|
title={t("aiProxy.update9routerCopy")}
|
|
>
|
|
<Copy className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{blocker?.code === "EUPTODATE" && !running && (
|
|
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
|
|
)}
|
|
{error && (
|
|
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(false)}
|
|
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
|
|
>
|
|
{t("aiProxy.update9routerClose")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void start()}
|
|
disabled={Boolean(blocker) || running || starting}
|
|
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
|
|
>
|
|
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
|
{t("aiProxy.update9routerStart")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|