Files
AgentHub/client/src/components/users/MemberTmux.tsx
T
nntrivi2001 1c6dfea5b5 feat(health): System Health in three groups, a score that sees lag; admin user detail
Dashboard → System Health is grouped into AgentHub, Machine and Agent
activity. The machine (GET /api/host, every signed-in user) is drawn by
components/health/HostHealth.tsx: host card beside the CPU/memory history,
then CPU (threads as compact cells + temperatures/GPUs), disks & network,
busiest processes, and per-user tiles. Concurrency bars each show their own
agent type on hover.

The Health Score (client/src/lib/healthScore.ts) is now 100 minus what each
check costs, between an amber and a red threshold: event-loop p99
(server/lib/loop-delay.js, shared with /api/metrics), Linux PSI pressure for
CPU, memory and disk I/O (/api/host `pressure`; load per core without PSI),
RAM, the database's disk, V8 heap, hooks silent while agents work, and agent
errors in the last 24 h. All-time success rate, API errors and the transcript
cache hit rate no longer count. /api/settings/info adds server.event_loop and
activity {last_event_at, working_agents, agents_24h}.

Administration: a user row opens its detail in place (GET
/api/users/:id/details — spend, account pool share, live panes, workspaces,
newest sessions); the server panel is gone, member tmux folded into the table;
the Sessions list shows each row's owner in server mode.
2026-10-27 16:39:38 +07:00

221 lines
7.5 KiB
TypeScript

/**
* @file MemberTmux.tsx
* @description Each member's own tmux server as part of the Administration
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
* older than the installed one, its sessions, whether an agent of theirs is
* working — and `MemberTmuxBar` above the table, 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 { 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 { 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`;
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
export function useMemberTmux(enabled = true) {
const [data, setData] = useState<MemberTmux | null>(null);
const reload = useCallback(
() =>
api.users
.tmux()
.then(setData)
.catch(() => setData(null)),
[]
);
useEffect(() => {
if (enabled) void reload();
}, [enabled, reload]);
return { data, reload };
}
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
const { t } = useTranslation("users");
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
return (
<div className="text-sm text-fg-secondary">
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
<div
className="text-[11px] text-fg-muted"
title={
row.startedAt
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
: undefined
}
>
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
</div>
</div>
);
}
/**
* Above the users table: the installed tmux, the not-yet-installed tmux-member
* warning, and (an admin) "Restart N idle members". Nothing without S3.
*/
export function MemberTmuxBar({
data,
isAdmin,
onRestart,
}: {
data: MemberTmux | null;
isAdmin: boolean;
onRestart: (target: "idle") => void;
}) {
const { t } = useTranslation("users");
if (!data?.available) return null;
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
return (
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
<span className="inline-flex items-center gap-1.5 text-fg-muted">
<TerminalSquare className="w-3.5 h-3.5" />
{t("tmux.description", { version: data.installed ?? "?" })}
</span>
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
{isAdmin && idle.length > 0 && (
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
</button>
)}
</div>
);
}
/** An admin's restart of one member's tmux, or of every idle outdated one. */
export function TmuxRestartDialog({
target,
data,
onClose,
onDone,
}: {
target: MemberTmuxRow | "idle";
data: MemberTmux | null;
onClose: () => void;
onDone: (message: string) => void;
}) {
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
}
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>
);
}