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:
+175
-96
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user