c83ddf62a0
A member's tmux server keeps the version it started with until their last pane closes, but the dashboard asked it with TMUX_BIN, the newest tmux: a 3.8 client on a 3.6 server spun it at 100% CPU (three members needed a manual kill on 2026-10-15). - /opt/agenthub/libexec/tmux-member (install-system, in sudoers): runs a member's tmux call with the binary of THEIR running server (/proc/<pid>/exe, found from the socket in /proc/net/unix), else TMUX_BIN. It runs as the member and execs only a process they own. sudoArgv uses it once installed (linux-accounts readConf), TMUX_BIN until then. - Users → Members' tmux (GET /api/users/tmux): each member server's version beside the installed one, sessions, agents working. An admin restarts one (POST /api/users/:id/tmux-restart) or every idle outdated one (POST /api/users/tmux-restart-idle), with their password again, audited per member; a server that will not stop answers 409 ETMUXSTUCK naming its pid for root. - server-s3-system.test.js: `delete` is a provision action since Delete user, so the "unknown action" case now uses `purge`. Operators: run `sudo agenthub server install-system` once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
9.6 KiB
TypeScript
268 lines
9.6 KiB
TypeScript
/**
|
|
* @file MemberTmuxCard.tsx
|
|
* @description The Users page's tmux card (one Linux user per account only,
|
|
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
|
* beside the installed one, its sessions, whether an agent of theirs is
|
|
* working — so an admin sees who still runs an old tmux after an upgrade.
|
|
* An admin restarts one member's server, or every idle outdated one at once
|
|
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
|
* time with their own password again; a restart ends that member's panes,
|
|
* and the next start runs the installed tmux. An analyst only reads.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TFunction } from "i18next";
|
|
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
|
|
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
|
import { formatDateTime } from "../../lib/format";
|
|
import { toast } from "../../lib/toast";
|
|
import { isSubmitEnter } from "../../lib/keys";
|
|
import { useSpin } from "../../hooks/useSpin";
|
|
import { ConfirmModal } from "../ConfirmModal";
|
|
|
|
function errorText(t: TFunction<"users">, e: unknown): string {
|
|
const err = e as Error & { code?: string };
|
|
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
|
? t(`tmux.errors.${err.code}`)
|
|
: err.message;
|
|
}
|
|
|
|
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
|
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
|
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
|
|
|
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
|
|
const { t } = useTranslation("users");
|
|
const [data, setData] = useState<MemberTmux | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// A member row = restart that one; "idle" = every idle outdated member.
|
|
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
|
|
|
const load = useCallback(
|
|
() =>
|
|
api.users
|
|
.tmux()
|
|
.then((r) => {
|
|
setData(r);
|
|
setError(null);
|
|
})
|
|
.catch((e: Error) => setError(e.message)),
|
|
[]
|
|
);
|
|
const [spinning, refresh] = useSpin(load);
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
if (!data?.available) return null;
|
|
const rows = data.users.filter((u) => u.running);
|
|
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
|
|
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
|
|
|
return (
|
|
<section className="card" data-testid="member-tmux">
|
|
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
|
<TerminalSquare className="w-4 h-4 text-fg-muted" />
|
|
<div className="flex-1 min-w-0">
|
|
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
|
|
<p className="text-xs text-fg-muted">
|
|
{t("tmux.description", { version: data.installed ?? "?" })}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={refresh}
|
|
disabled={spinning}
|
|
aria-busy={spinning}
|
|
title={t("tmux.refresh")}
|
|
aria-label={t("tmux.refresh")}
|
|
className="btn-icon"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
|
</button>
|
|
{isAdmin && idleOutdated.length > 0 && (
|
|
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
|
|
<RotateCcw className="w-4 h-4" />{" "}
|
|
{t("tmux.restartIdle", { count: idleOutdated.length })}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{!data.matched && (
|
|
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
|
|
{t("tmux.notMatched")}
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{rows.length === 0 ? (
|
|
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[560px]">
|
|
<thead>
|
|
<tr className="border-b border-border text-left">
|
|
<th className={th}>{t("tmux.user")}</th>
|
|
<th className={th}>{t("tmux.version")}</th>
|
|
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
|
|
<th className={th}>{t("tmux.agents")}</th>
|
|
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{rows.map((u) => (
|
|
<tr key={u.id}>
|
|
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
|
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
|
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
|
|
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
|
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
|
{u.startedAt && (
|
|
<div className="text-[11px] text-fg-muted">
|
|
{t("tmux.since", {
|
|
at: formatDateTime(new Date(u.startedAt).toISOString()),
|
|
})}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
|
{u.sessions ?? "—"}
|
|
</td>
|
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
|
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
|
|
</td>
|
|
{isAdmin && (
|
|
<td className="px-5 py-2 text-right">
|
|
<button
|
|
type="button"
|
|
onClick={() => setRestarting(u)}
|
|
title={t("tmux.restart", { name: u.username })}
|
|
aria-label={t("tmux.restart", { name: u.username })}
|
|
className="btn-icon"
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{restarting && (
|
|
<RestartDialog
|
|
target={restarting}
|
|
idle={idleOutdated}
|
|
onClose={() => setRestarting(null)}
|
|
onDone={(msg) => {
|
|
toast.success(msg);
|
|
setRestarting(null);
|
|
void load();
|
|
}}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RestartDialog({
|
|
target,
|
|
idle,
|
|
onClose,
|
|
onDone,
|
|
}: {
|
|
target: MemberTmuxRow | "idle";
|
|
idle: MemberTmuxRow[];
|
|
onClose: () => void;
|
|
onDone: (message: string) => void;
|
|
}) {
|
|
const { t } = useTranslation("users");
|
|
const [current, setCurrent] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const one = target === "idle" ? null : target;
|
|
|
|
const submit = async () => {
|
|
if (!current || busy) return;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
if (one) {
|
|
await api.users.tmuxRestart(one.id, current);
|
|
onDone(t("tmux.done", { name: one.username }));
|
|
} else {
|
|
const r = await api.users.tmuxRestartIdle(current);
|
|
// A server that would not stop needs root: say which, the rest went.
|
|
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
|
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
|
}
|
|
} catch (e) {
|
|
setError(errorText(t, e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ConfirmModal
|
|
open
|
|
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
|
message={
|
|
one
|
|
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
|
name: one.username,
|
|
count: one.sessions ?? 0,
|
|
})
|
|
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
|
}
|
|
confirmLabel={t("tmux.restartButton")}
|
|
cancelLabel={t("common:cancel")}
|
|
busy={busy}
|
|
disabled={!current}
|
|
onConfirm={() => void submit()}
|
|
onCancel={onClose}
|
|
>
|
|
<div
|
|
className="mt-3 space-y-3"
|
|
onKeyDown={(e) => {
|
|
if (isSubmitEnter(e)) {
|
|
e.preventDefault();
|
|
void submit();
|
|
}
|
|
}}
|
|
>
|
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
|
<input
|
|
type="password"
|
|
className="input w-full text-sm"
|
|
value={current}
|
|
onChange={(e) => setCurrent(e.target.value)}
|
|
autoComplete="current-password"
|
|
aria-label={t("yourPassword")}
|
|
/>
|
|
</Field>
|
|
{error && (
|
|
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</ConfirmModal>
|
|
);
|
|
}
|
|
|
|
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
|
<div className="mt-1">{children}</div>
|
|
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
|
</label>
|
|
);
|
|
}
|