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.
This commit is contained in:
2026-10-27 16:39:38 +07:00
parent df15789982
commit 1c6dfea5b5
49 changed files with 5154 additions and 1971 deletions
+175 -96
View File
@@ -1,6 +1,6 @@
/**
* @file Sessions.tsx
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus.
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; columns include the AI tool and (server-mode admins) the owning user, and the last column holds the row's actions (open in workspace, `SessionRowMenu` — also on right-click — and details). Sessions are updated in real-time based on events received from the event bus.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
@@ -66,6 +66,8 @@ import {
Server,
Download,
AlertCircle,
MoreHorizontal,
SquareArrowOutUpRight,
} from "lucide-react";
import { api } from "../lib/api";
import type { RemoteSource, RunHandle } from "../lib/api";
@@ -80,7 +82,8 @@ import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { useDataScope } from "../lib/dataScope";
import { SessionStatusBadge } from "../components/StatusBadge";
import { SessionToolBadge } from "../components/SessionToolBadge";
import { AiToolIcon } from "../components/AiToolIcon";
import { UserAvatar } from "../components/users/UserAvatar";
import { EmptyState } from "../components/EmptyState";
import { TableRowSkeleton } from "../components/Skeleton";
import { useContextMenu } from "../components/ContextMenu";
@@ -91,12 +94,16 @@ import type { Session, DashboardEvent } from "../lib/types";
import { usePermissions } from "../components/AuthGate";
const PAGE_SIZE = 10;
const TH =
"px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider whitespace-nowrap";
const ACTION_ICON =
"inline-flex items-center justify-center w-7 h-7 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
const navigate = useNavigate();
const rowCtx = useContextMenu<Session>();
const { t } = useTranslation("sessions");
const { tools } = useAiTools();
const { tools, label: toolLabel } = useAiTools();
const { lanes } = useLanes();
// Filters, sort and page live in the URL (replace, no history spam), so a
// reload, the back button and a shared link all land on the same view.
@@ -185,7 +192,11 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
// Load remote-source labels so remote-origin rows can show a friendly badge
// instead of a raw `src_…` id. Refreshed when a source's status changes.
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
const { seesAll } = usePermissions();
const { seesAll, isAdmin, local } = usePermissions();
// Who owns each session: an admin's column only, and only where there are
// accounts (server mode; the API sends no owner in local mode).
const showUser = isAdmin && !local;
const columns = showUser ? 10 : 9;
const loadSourceLabels = useCallback(() => {
if (!seesAll) return;
api.remoteSources
@@ -581,31 +592,19 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
role="region"
aria-label={t("tableRegion")}
>
<table className="w-full min-w-[800px]">
<table className="w-full min-w-[960px]">
<thead>
<tr className="border-b border-border text-left">
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableSession")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableStatus")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableLastActive")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDuration")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableAgents")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableCost")}
</th>
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
{t("tableDirectory")}
</th>
<th className="w-10"></th>
<tr className="border-b border-border text-left bg-surface-2/40">
<th className={TH}>{t("tableSession")}</th>
{showUser && <th className={TH}>{t("tableUser")}</th>}
<th className={TH}>{t("tableTool")}</th>
<th className={TH}>{t("tableStatus")}</th>
<th className={TH}>{t("tableLastActive")}</th>
<th className={`${TH} text-right`}>{t("tableDuration")}</th>
<th className={`${TH} text-right`}>{t("tableAgents")}</th>
<th className={`${TH} text-right`}>{t("tableCost")}</th>
<th className={TH}>{t("tableDirectory")}</th>
<th className={`${TH} text-right`}>{t("tableActions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -613,94 +612,174 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
? Array.from({ length: 8 }).map((_, i) => (
<TableRowSkeleton
key={`sk-${i}`}
columns={8}
widths={["w-40", "w-20", "w-28", "w-20", "w-10", "w-16", "w-44", "w-4"]}
columns={columns}
widths={[
"w-40",
...(showUser ? ["w-24"] : []),
"w-20",
"w-20",
"w-28",
"w-16",
"w-8",
"w-14",
"w-40",
"w-20",
]}
/>
))
: null}
{paged.map((session) => (
<tr
key={session.id}
// Mouse convenience; the session name below is the real,
// focusable link (a click on a link or button inside wins).
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-4 transition-colors cursor-pointer group"
>
<td className="px-5 py-4">
<div>
<div className="flex items-center gap-2">
{paged.map((session) => {
const toolId = session.tool || "claude";
// A live dashboard pane holds it: open that pane; else its
// workspace's replay, when a workspace owns it.
const live = runs.some((r) => r.sessionId === session.id);
const href = workspaceHref(session, runs);
const owner = session.owner_display_name || session.owner_name;
return (
<tr
key={session.id}
// Mouse convenience; the session name below is the real,
// focusable link (a click on a link or button inside wins).
onClick={(e) => {
if ((e.target as HTMLElement).closest("a,button")) return;
navigate(`/sessions/${session.id}`);
}}
onContextMenu={(e) => rowCtx.open(e, session)}
className="hover:bg-surface-3/60 transition-colors cursor-pointer group"
>
<td className="px-4 py-3 max-w-[22rem]">
<div className="flex items-center gap-2 min-w-0">
<Link
to={`/sessions/${session.id}`}
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
title={session.name || undefined}
className="truncate text-sm font-medium text-fg-primary hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
>
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
</Link>
<SessionToolBadge tool={session.tool} />
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
className="inline-flex flex-shrink-0 items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
{sourceLabels.get(session.source) || session.source}
</span>
)}
{(() => {
// A live dashboard pane holds it: open that pane.
const run = runs.find((r) => r.sessionId === session.id);
const href = run ? workspaceHref(session, runs) : null;
return href ? (
<Link
to={href}
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-2.5 h-2.5" />
{t("openInWorkspace")}
</Link>
) : null;
})()}
</div>
<p className="text-[11px] text-fg-muted font-mono">
{session.id.slice(0, 12)}
</p>
</div>
</td>
<td className="px-5 py-4">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary">
{session.agent_count ?? "-"}
</td>
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-5 py-4 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-3 py-4">
<ChevronRight className="w-4 h-4 text-fg-muted group-hover:text-fg-secondary transition-colors" />
</td>
</tr>
))}
</td>
{showUser && (
<td className="px-4 py-3">
{owner ? (
<div
className="flex items-center gap-2 min-w-0"
title={session.owner_name || undefined}
>
<UserAvatar
name={owner}
userId={session.owner_id ?? undefined}
avatarAt={session.owner_avatar_at}
className="h-6 w-6 text-[10px]"
/>
<span className="truncate text-sm text-fg-secondary max-w-[9rem]">
{owner}
</span>
</div>
) : (
<span className="text-xs text-fg-muted" title={t("noOwner")}>
-
</span>
)}
</td>
)}
<td className="px-4 py-3">
<div
className="flex items-center gap-2"
title={t("toolBadgeTitle", { tool: toolLabel(toolId) })}
>
<AiToolIcon
tool={{ id: toolId, label: toolLabel(toolId) }}
className="h-6 w-6"
/>
<span className="text-sm text-fg-secondary whitespace-nowrap">
{toolLabel(toolId)}
</span>
</div>
</td>
<td className="px-4 py-3">
<SessionStatusBadge
status={effectiveSessionStatus(session)}
reason={sessionAwaitingReason(session)}
/>
</td>
<td className="px-4 py-3 text-sm text-fg-secondary whitespace-nowrap">
{formatDateTime(session.last_activity || session.started_at)}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right whitespace-nowrap">
{session.ended_at
? formatDuration(session.started_at, session.ended_at)
: t("common:running")}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary text-right tabular-nums">
{session.agent_count ?? "-"}
</td>
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right">
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
</td>
<td
className="px-4 py-3 text-[11px] text-fg-muted font-mono"
title={session.cwd || undefined}
>
{session.cwd ? truncate(session.cwd, 30) : "-"}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
{href &&
(live ? (
<Link
to={href}
className="inline-flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-2 py-1 rounded-md transition-colors"
title={t("openInWorkspaceTitle")}
>
<Play className="w-3 h-3" />
{t("openInWorkspace")}
</Link>
) : (
<Link
to={href}
className={ACTION_ICON}
title={t("menu.inWorkspace")}
aria-label={t("menu.inWorkspace")}
>
<SquareArrowOutUpRight className="w-4 h-4" />
</Link>
))}
<button
type="button"
onClick={(e) => rowCtx.open(e, session)}
className={ACTION_ICON}
title={t("moreActions")}
aria-label={t("moreActions")}
aria-haspopup="menu"
>
<MoreHorizontal className="w-4 h-4" />
</button>
<Link
to={`/sessions/${session.id}`}
className={ACTION_ICON}
title={t("viewDetails")}
aria-label={t("viewDetails")}
tabIndex={-1}
>
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>