feat: enhance agent management with session handling and UI improvements

This commit is contained in:
Abhimanyu Saharan
2026-02-04 14:58:14 +05:30
parent f6105fa0d2
commit a33c539860
16 changed files with 1181 additions and 472 deletions

View File

@@ -0,0 +1,183 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
type Agent = {
id: string;
name: string;
status: string;
};
const statusOptions = [
{ value: "online", label: "Online" },
{ value: "busy", label: "Busy" },
{ value: "offline", label: "Offline" },
];
export default function EditAgentPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const params = useParams();
const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
const [agent, setAgent] = useState<Agent | null>(null);
const [name, setName] = useState("");
const [status, setStatus] = useState("online");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAgent = async () => {
if (!isSignedIn || !agentId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load agent.");
}
const data = (await response.json()) as Agent;
setAgent(data);
setName(data.name);
setStatus(data.status);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadAgent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn, agentId]);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn || !agentId) return;
const trimmed = name.trim();
if (!trimmed) {
setError("Agent name is required.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents/${agentId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, status }),
});
if (!response.ok) {
throw new Error("Unable to update agent.");
}
router.push(`/agents/${agentId}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center lg:col-span-2">
<p className="text-sm text-muted">Sign in to edit agents.</p>
<SignInButton
mode="modal"
afterSignInUrl={`/agents/${agentId}/edit`}
afterSignUpUrl={`/agents/${agentId}/edit`}
forceRedirectUrl={`/agents/${agentId}/edit`}
signUpForceRedirectUrl={`/agents/${agentId}/edit`}
>
<Button>Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col justify-center rounded-2xl surface-panel p-8">
<div className="mb-6 space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Edit agent
</p>
<h1 className="text-2xl font-semibold text-strong">
{agent?.name ?? "Agent"}
</h1>
<p className="text-sm text-muted">
Update the agent name and status.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Agent name</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Status</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Saving…" : "Save changes"}
</Button>
</form>
<Button
variant="outline"
className="mt-4"
onClick={() => router.push(`/agents/${agentId}`)}
>
Back to agent
</Button>
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -0,0 +1,357 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { StatusPill } from "@/components/atoms/StatusPill";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
type Agent = {
id: string;
name: string;
status: string;
openclaw_session_id?: string | null;
last_seen_at: string;
created_at: string;
updated_at: string;
};
type ActivityEvent = {
id: string;
event_type: string;
message?: string | null;
agent_id?: string | null;
created_at: string;
};
const formatTimestamp = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatRelative = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
const diff = Date.now() - date.getTime();
const minutes = Math.round(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
};
export default function AgentDetailPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const params = useParams();
const agentIdParam = params?.agentId;
const agentId = Array.isArray(agentIdParam) ? agentIdParam[0] : agentIdParam;
const [agent, setAgent] = useState<Agent | null>(null);
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const agentEvents = useMemo(() => {
if (!agent) return [];
return events.filter((event) => event.agent_id === agent.id);
}, [events, agent]);
const loadAgent = async () => {
if (!isSignedIn || !agentId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [agentResponse, activityResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/agents/${agentId}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
fetch(`${apiBase}/api/v1/activity?limit=200`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
]);
if (!agentResponse.ok) {
throw new Error("Unable to load agent.");
}
if (!activityResponse.ok) {
throw new Error("Unable to load activity.");
}
const agentData = (await agentResponse.json()) as Agent;
const eventsData = (await activityResponse.json()) as ActivityEvent[];
setAgent(agentData);
setEvents(eventsData);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadAgent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn, agentId]);
const handleDelete = async () => {
if (!agent || !isSignedIn) return;
setIsDeleting(true);
setDeleteError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents/${agent.id}`, {
method: "DELETE",
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to delete agent.");
}
router.push("/agents");
} catch (err) {
setDeleteError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsDeleting(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
<p className="text-sm text-muted">Sign in to view agents.</p>
<SignInButton
mode="modal"
afterSignInUrl="/agents"
afterSignUpUrl="/agents"
forceRedirectUrl="/agents"
signUpForceRedirectUrl="/agents"
>
<Button>Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Agents
</p>
<h1 className="text-2xl font-semibold text-strong">
{agent?.name ?? "Agent"}
</h1>
<p className="text-sm text-muted">
Review agent health, session binding, and recent activity.
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => router.push("/agents")}
>
Back to agents
</Button>
{agent ? (
<Link
href={`/agents/${agent.id}/edit`}
className="inline-flex h-10 items-center justify-center rounded-xl border border-[color:var(--border)] px-4 text-sm font-semibold text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
) : null}
{agent ? (
<Button variant="outline" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
) : null}
</div>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Loading agent details
</div>
) : agent ? (
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Overview
</p>
<p className="mt-1 text-lg font-semibold text-strong">
{agent.name}
</p>
</div>
<StatusPill status={agent.status} />
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agent ID
</p>
<p className="mt-1 text-sm text-muted">{agent.id}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session key
</p>
<p className="mt-1 text-sm text-muted">
{agent.openclaw_session_id ?? "—"}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Last seen
</p>
<p className="mt-1 text-sm text-strong">
{formatRelative(agent.last_seen_at)}
</p>
<p className="text-xs text-quiet">
{formatTimestamp(agent.last_seen_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Updated
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.updated_at)}
</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Created
</p>
<p className="mt-1 text-sm text-muted">
{formatTimestamp(agent.created_at)}
</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-5">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Health
</p>
<StatusPill status={agent.status} />
</div>
<div className="mt-4 grid gap-3 text-sm text-muted">
<div className="flex items-center justify-between">
<span>Heartbeat window</span>
<span>{formatRelative(agent.last_seen_at)}</span>
</div>
<div className="flex items-center justify-between">
<span>Session binding</span>
<span>{agent.openclaw_session_id ? "Bound" : "Unbound"}</span>
</div>
<div className="flex items-center justify-between">
<span>Status</span>
<span className="text-strong">{agent.status}</span>
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Activity
</p>
<p className="text-xs text-quiet">
{agentEvents.length} events
</p>
</div>
<div className="space-y-3">
{agentEvents.length === 0 ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
No activity yet for this agent.
</div>
) : (
agentEvents.map((event) => (
<div
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
</p>
<p className="mt-1 text-xs text-quiet">
{formatTimestamp(event.created_at)}
</p>
</div>
))
)}
</div>
</div>
</div>
) : (
<div className="flex flex-1 items-center justify-center text-sm text-muted">
Agent not found.
</div>
)}
</div>
</SignedIn>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent aria-label="Delete agent">
<DialogHeader>
<DialogTitle>Delete agent</DialogTitle>
<DialogDescription>
This will remove {agent?.name}. This action cannot be undone.
</DialogDescription>
</DialogHeader>
{deleteError ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{deleteError}
</div>
) : null}
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
Cancel
</Button>
<Button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardShell>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
type Agent = {
id: string;
name: string;
};
const statusOptions = [
{ value: "online", label: "Online" },
{ value: "busy", label: "Busy" },
{ value: "offline", label: "Offline" },
];
export default function NewAgentPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const [name, setName] = useState("");
const [status, setStatus] = useState("online");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
const trimmed = name.trim();
if (!trimmed) {
setError("Agent name is required.");
return;
}
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, status }),
});
if (!response.ok) {
throw new Error("Unable to create agent.");
}
const created = (await response.json()) as Agent;
router.push(`/agents/${created.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center lg:col-span-2">
<p className="text-sm text-muted">Sign in to create an agent.</p>
<SignInButton
mode="modal"
afterSignInUrl="/agents/new"
afterSignUpUrl="/agents/new"
forceRedirectUrl="/agents/new"
signUpForceRedirectUrl="/agents/new"
>
<Button>Sign in</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col justify-center rounded-2xl surface-panel p-8">
<div className="mb-6 space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
New agent
</p>
<h1 className="text-2xl font-semibold text-strong">
Register an agent.
</h1>
<p className="text-sm text-muted">
Add an agent to your mission control roster.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Agent name</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deploy bot"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Status</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{error}
</div>
) : null}
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating…" : "Create agent"}
</Button>
</form>
<Button
variant="outline"
className="mt-4"
onClick={() => router.push("/agents")}
>
Back to agents
</Button>
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -1,9 +1,18 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
type ColumnDef,
type SortingState,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { StatusPill } from "@/components/atoms/StatusPill";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
@@ -17,28 +26,19 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
type Agent = {
id: string;
name: string;
status: string;
openclaw_session_id?: string | null;
last_seen_at: string;
};
type ActivityEvent = {
id: string;
event_type: string;
message?: string | null;
created_at: string;
updated_at: string;
};
type GatewayStatus = {
@@ -49,16 +49,6 @@ type GatewayStatus = {
error?: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const statusOptions = [
{ value: "online", label: "Online" },
{ value: "busy", label: "Busy" },
{ value: "offline", label: "Offline" },
];
const formatTimestamp = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
@@ -83,72 +73,47 @@ const formatRelative = (value: string) => {
return `${days}d ago`;
};
const getSessionKey = (
session: Record<string, unknown>,
index: number
) => {
const key = session.key;
if (typeof key === "string" && key.length > 0) {
return key;
}
const sessionId = session.sessionId;
if (typeof sessionId === "string" && sessionId.length > 0) {
return sessionId;
}
return `session-${index}`;
const truncate = (value?: string | null, max = 18) => {
if (!value) return "—";
if (value.length <= max) return value;
return `${value.slice(0, max)}`;
};
export default function AgentsPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const [agents, setAgents] = useState<Agent[]>([]);
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [sorting, setSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null);
const [gatewaySessions, setGatewaySessions] = useState<
Record<string, unknown>[]
>([]);
const [gatewayError, setGatewayError] = useState<string | null>(null);
const [selectedSession, setSelectedSession] = useState<
Record<string, unknown> | null
>(null);
const [sessionHistory, setSessionHistory] = useState<unknown[]>([]);
const [message, setMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [name, setName] = useState("");
const [status, setStatus] = useState("online");
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Agent | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const sortedAgents = useMemo(
() => [...agents].sort((a, b) => a.name.localeCompare(b.name)),
[agents],
);
const sortedAgents = useMemo(() => [...agents], [agents]);
const loadData = async () => {
const loadAgents = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [agentsResponse, activityResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/agents`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
fetch(`${apiBase}/api/v1/activity`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
]);
if (!agentsResponse.ok || !activityResponse.ok) {
throw new Error("Unable to load operational data.");
const response = await fetch(`${apiBase}/api/v1/agents`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!response.ok) {
throw new Error("Unable to load agents.");
}
const agentsData = (await agentsResponse.json()) as Agent[];
const eventsData = (await activityResponse.json()) as ActivityEvent[];
setAgents(agentsData);
setEvents(eventsData);
const data = (await response.json()) as Agent[];
setAgents(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
@@ -156,7 +121,7 @@ export default function AgentsPage() {
}
};
const loadGateway = async () => {
const loadGatewayStatus = async () => {
if (!isSignedIn) return;
setGatewayError(null);
try {
@@ -169,119 +134,136 @@ export default function AgentsPage() {
}
const statusData = (await response.json()) as GatewayStatus;
setGatewayStatus(statusData);
setGatewaySessions(statusData.sessions ?? []);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
}
};
const loadSessionHistory = async (sessionId: string) => {
if (!isSignedIn) return;
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/gateway/sessions/${sessionId}/history`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
}
);
if (!response.ok) {
throw new Error("Unable to load session history.");
}
const data = (await response.json()) as { history?: unknown[] };
setSessionHistory(data.history ?? []);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
}
};
useEffect(() => {
loadData();
loadGateway();
loadAgents();
loadGatewayStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const resetForm = () => {
setName("");
setStatus("online");
setCreateError(null);
};
const handleCreate = async () => {
if (!isSignedIn) return;
const trimmed = name.trim();
if (!trimmed) {
setCreateError("Agent name is required.");
return;
}
setIsCreating(true);
setCreateError(null);
const handleDelete = async () => {
if (!deleteTarget || !isSignedIn) return;
setIsDeleting(true);
setDeleteError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents`, {
method: "POST",
const response = await fetch(`${apiBase}/api/v1/agents/${deleteTarget.id}`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, status }),
});
if (!response.ok) {
throw new Error("Unable to create agent.");
throw new Error("Unable to delete agent.");
}
const created = (await response.json()) as Agent;
setAgents((prev) => [created, ...prev]);
setIsDialogOpen(false);
resetForm();
setAgents((prev) => prev.filter((agent) => agent.id !== deleteTarget.id));
setDeleteTarget(null);
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Something went wrong.");
setDeleteError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsCreating(false);
setIsDeleting(false);
}
};
const handleSendMessage = async () => {
if (!isSignedIn || !selectedSession) return;
const content = message.trim();
if (!content) return;
setIsSending(true);
setGatewayError(null);
try {
const token = await getToken();
const sessionId = selectedSession.key as string | undefined;
if (!sessionId) {
throw new Error("Missing session id.");
}
const response = await fetch(
`${apiBase}/api/v1/gateway/sessions/${sessionId}/message`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ content }),
}
);
if (!response.ok) {
throw new Error("Unable to send message.");
}
setMessage("");
loadSessionHistory(sessionId);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsSending(false);
}
};
const columns = useMemo<ColumnDef<Agent>[]>(
() => [
{
accessorKey: "name",
header: "Agent",
cell: ({ row }) => (
<div>
<p className="font-medium text-strong">{row.original.name}</p>
<p className="text-xs text-quiet">ID {row.original.id}</p>
</div>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusPill status={row.original.status} />,
},
{
accessorKey: "openclaw_session_id",
header: "Session",
cell: ({ row }) => (
<span className="text-xs text-muted">
{truncate(row.original.openclaw_session_id)}
</span>
),
},
{
accessorKey: "last_seen_at",
header: "Last seen",
cell: ({ row }) => (
<div className="text-xs text-muted">
<p className="font-medium text-strong">
{formatRelative(row.original.last_seen_at)}
</p>
<p className="text-quiet">{formatTimestamp(row.original.last_seen_at)}</p>
</div>
),
},
{
accessorKey: "updated_at",
header: "Updated",
cell: ({ row }) => (
<span className="text-xs text-muted">
{formatTimestamp(row.original.updated_at)}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div
className="flex items-center justify-end gap-2"
onClick={(event) => event.stopPropagation()}
>
<Link
href={`/agents/${row.original.id}`}
className="inline-flex h-8 items-center justify-center rounded-lg border border-[color:var(--border)] px-3 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
View
</Link>
<Link
href={`/agents/${row.original.id}/edit`}
className="inline-flex h-8 items-center justify-center rounded-lg border border-[color:var(--border)] px-3 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
Edit
</Link>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTarget(row.original)}
>
Delete
</Button>
</div>
),
},
],
[]
);
const table = useReactTable({
data: sortedAgents,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center">
<p className="text-sm text-muted">
Sign in to view operational status.
</p>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-2xl surface-panel p-10 text-center lg:col-span-2">
<p className="text-sm text-muted">Sign in to view agents.</p>
<SignInButton
mode="modal"
afterSignInUrl="/agents"
@@ -296,25 +278,18 @@ export default function AgentsPage() {
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-2xl surface-panel p-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-quiet">
Operations
</p>
<h1 className="text-2xl font-semibold text-strong">Agents</h1>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-strong">Agents</h2>
<p className="text-sm text-muted">
Live status and heartbeat activity across all agents.
{agents.length} agent{agents.length === 1 ? "" : "s"} total.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => loadData()}
disabled={isLoading}
>
<Button variant="outline" onClick={loadAgents} disabled={isLoading}>
Refresh
</Button>
<Button onClick={() => setIsDialogOpen(true)}>
<Button onClick={() => router.push("/agents/new")}>
New agent
</Button>
</div>
@@ -326,282 +301,114 @@ export default function AgentsPage() {
</div>
) : null}
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
{agents.length === 0 && !isLoading ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-[color:var(--border)] bg-[color:var(--surface-muted)] p-6 text-center text-sm text-muted">
No agents yet. Create your first agent to get started.
</div>
) : (
<div className="overflow-hidden rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)]">
<div className="flex items-center justify-between border-b border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Agents
</p>
<p className="text-xs text-quiet">
{sortedAgents.length} total
</p>
</div>
<div className="divide-y divide-[color:var(--border)] text-sm">
{sortedAgents.length === 0 && !isLoading ? (
<div className="p-6 text-sm text-muted">
No agents yet. Add one or wait for a heartbeat.
</div>
) : (
sortedAgents.map((agent) => (
<div
key={agent.id}
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"
>
<div>
<p className="font-medium text-strong">{agent.name}</p>
<p className="text-xs text-quiet">
Last seen {formatRelative(agent.last_seen_at)}
</p>
</div>
<div className="flex items-center gap-3">
<StatusPill status={agent.status} />
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/boards`)}
>
View work
</Button>
</div>
</div>
))
)}
</div>
</div>
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-5">
<Tabs defaultValue="activity">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="gateway">Gateway</TabsTrigger>
</TabsList>
</div>
<TabsContent value="activity">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Activity feed
</p>
<p className="text-xs text-quiet">
{events.length} events
</p>
</div>
<div className="space-y-3">
{events.length === 0 && !isLoading ? (
<div className="rounded-lg border border-dashed border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
No activity yet.
</div>
) : (
events.map((event) => (
<div
key={event.id}
className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted"
>
<p className="font-medium text-strong">
{event.message ?? event.event_type}
</p>
<p className="mt-1 text-xs text-quiet">
{formatTimestamp(event.created_at)}
</p>
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="gateway">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
OpenClaw Gateway
</p>
<Button
variant="outline"
size="sm"
onClick={() => loadGateway()}
>
Refresh
</Button>
</div>
<div className="space-y-4">
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
<div className="flex items-center justify-between">
<p className="font-medium text-strong">
{gatewayStatus?.connected ? "Connected" : "Not connected"}
</p>
<StatusPill
status={gatewayStatus?.connected ? "online" : "offline"}
/>
</div>
<p className="mt-1 text-xs text-quiet">
{gatewayStatus?.gateway_url ?? "Gateway URL not set"}
</p>
{gatewayStatus?.error ? (
<p className="mt-2 text-xs text-[color:var(--danger)]">
{gatewayStatus.error}
</p>
) : null}
</div>
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)]">
<div className="flex items-center justify-between border-b border-[color:var(--border)] px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
<span>Sessions</span>
<span>{gatewaySessions.length}</span>
</div>
<div className="max-h-56 divide-y divide-[color:var(--border)] overflow-y-auto text-sm">
{gatewaySessions.length === 0 ? (
<div className="p-4 text-sm text-muted">
No sessions found.
</div>
) : (
gatewaySessions.map((session, index) => {
const sessionId = session.key as string | undefined;
const display =
(session.displayName as string | undefined) ??
(session.label as string | undefined) ??
sessionId ??
"Session";
return (
<button
key={getSessionKey(session, index)}
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm transition hover:bg-[color:var(--surface-muted)]"
onClick={() => {
setSelectedSession(session);
if (sessionId) {
loadSessionHistory(sessionId);
}
}}
>
<div>
<p className="font-medium text-strong">{display}</p>
<p className="text-xs text-quiet">
{session.status ?? "active"}
</p>
</div>
<span className="text-xs text-quiet">Open</span>
</button>
);
})
)}
</div>
</div>
{selectedSession ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
<div className="mb-3 space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Session details
</p>
<p className="font-medium text-strong">
{selectedSession.displayName ??
selectedSession.label ??
selectedSession.key ??
"Session"}
</p>
</div>
<div className="mb-4 max-h-40 space-y-2 overflow-y-auto rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{sessionHistory.length === 0 ? (
<p>No history loaded.</p>
) : (
sessionHistory.map((item, index) => (
<pre key={index} className="whitespace-pre-wrap">
{JSON.stringify(item, null, 2)}
</pre>
))
)}
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Send message
</label>
<Input
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Type a message to the session"
className="h-10"
/>
<Button
className="w-full"
onClick={handleSendMessage}
disabled={isSending}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[color:var(--border)] text-sm">
<thead className="bg-[color:var(--surface-muted)]">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-[0.22em] text-quiet"
>
{isSending ? "Sending…" : "Send to session"}
</Button>
</div>
</div>
) : null}
{gatewayError ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] p-3 text-xs text-[color:var(--danger)]">
{gatewayError}
</div>
) : null}
</div>
</TabsContent>
</Tabs>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-[color:var(--border)] bg-[color:var(--surface)]">
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="cursor-pointer transition hover:bg-[color:var(--surface-muted)]"
onClick={() => router.push(`/agents/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 align-top">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="rounded-2xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 text-sm text-muted">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-quiet">
Gateway status
</p>
<p className="mt-1 text-sm text-strong">
{gatewayStatus?.gateway_url ?? "Gateway URL not set"}
</p>
</div>
<div className="flex items-center gap-3">
<StatusPill status={gatewayStatus?.connected ? "online" : "offline"} />
<span className="text-xs text-quiet">
{gatewayStatus?.sessions_count ?? 0} sessions
</span>
</div>
</div>
{gatewayStatus?.error ? (
<p className="mt-3 text-xs text-[color:var(--danger)]">
{gatewayStatus.error}
</p>
) : null}
{gatewayError ? (
<p className="mt-3 text-xs text-[color:var(--danger)]">
{gatewayError}
</p>
) : null}
</div>
</div>
</SignedIn>
<Dialog
open={isDialogOpen}
open={!!deleteTarget}
onOpenChange={(nextOpen) => {
setIsDialogOpen(nextOpen);
if (!nextOpen) {
resetForm();
setDeleteTarget(null);
setDeleteError(null);
}
}}
>
<DialogContent aria-label="New agent">
<DialogContent aria-label="Delete agent">
<DialogHeader>
<DialogTitle>New agent</DialogTitle>
<DialogTitle>Delete agent</DialogTitle>
<DialogDescription>
Add a manual agent entry for tracking and monitoring.
This will remove {deleteTarget?.name}. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Agent name</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deployment bot"
className="h-11"
/>
{deleteError ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{deleteError}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-strong">Status</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createError ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-xs text-muted">
{createError}
</div>
) : null}
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={isCreating}
>
{isCreating ? "Creating…" : "Create agent"}
<Button onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>