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

@@ -4,8 +4,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import desc
from sqlmodel import Session, col, select
from app.api.deps import require_admin_auth
from app.core.auth import AuthContext
from app.api.deps import ActorContext, require_admin_or_agent
from app.db.session import get_session
from app.models.activity_events import ActivityEvent
from app.schemas.activity_events import ActivityEventRead
@@ -18,11 +17,13 @@ def list_activity(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[ActivityEvent]:
statement = select(ActivityEvent)
if actor.actor_type == "agent" and actor.agent:
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
statement = (
select(ActivityEvent)
.order_by(desc(col(ActivityEvent.created_at)))
statement.order_by(desc(col(ActivityEvent.created_at)))
.offset(offset)
.limit(limit)
)

View File

@@ -5,13 +5,22 @@ from datetime import datetime, timedelta
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from sqlmodel import Session, col, select
from sqlalchemy import update
from app.api.deps import require_admin_auth
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext
from app.core.config import settings
from app.db.session import get_session
from app.integrations.openclaw_gateway import OpenClawGatewayError, openclaw_call
from app.integrations.openclaw_gateway import (
OpenClawGatewayError,
delete_session,
ensure_session,
send_message,
)
from app.models.agents import Agent
from app.models.activity_events import ActivityEvent
from app.schemas.agents import (
AgentCreate,
AgentHeartbeat,
@@ -40,7 +49,7 @@ def _build_session_key(agent_name: str) -> str:
async def _ensure_gateway_session(agent_name: str) -> tuple[str, str | None]:
session_key = _build_session_key(agent_name)
try:
await openclaw_call("sessions.patch", {"key": session_key, "label": agent_name})
await ensure_session(session_key, label=agent_name)
return session_key, None
except OpenClawGatewayError as exc:
return session_key, str(exc)
@@ -87,6 +96,8 @@ async def create_agent(
auth: AuthContext = Depends(require_admin_auth),
) -> Agent:
agent = Agent.model_validate(payload)
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name)
agent.openclaw_session_id = session_key
session.add(agent)
@@ -108,7 +119,7 @@ async def create_agent(
)
session.commit()
try:
await send_provisioning_message(agent)
await send_provisioning_message(agent, raw_token)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
session.commit()
@@ -155,11 +166,13 @@ def heartbeat_agent(
agent_id: str,
payload: AgentHeartbeat,
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Agent:
agent = session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if payload.status:
agent.status = payload.status
agent.last_seen_at = datetime.utcnow()
@@ -175,11 +188,15 @@ def heartbeat_agent(
async def heartbeat_or_create_agent(
payload: AgentHeartbeatCreate,
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Agent:
agent = session.exec(select(Agent).where(Agent.name == payload.name)).first()
if agent is None:
if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
agent = Agent(name=payload.name, status=payload.status or "online")
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session_key, session_error = await _ensure_gateway_session(agent.name)
agent.openclaw_session_id = session_key
session.add(agent)
@@ -201,7 +218,23 @@ async def heartbeat_or_create_agent(
)
session.commit()
try:
await send_provisioning_message(agent)
await send_provisioning_message(agent, raw_token)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_provisioning_failure(session, agent, str(exc))
session.commit()
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
elif agent.agent_token_hash is None and actor.actor_type == "user":
raw_token = generate_agent_token()
agent.agent_token_hash = hash_agent_token(raw_token)
session.add(agent)
session.commit()
session.refresh(agent)
try:
await send_provisioning_message(agent, raw_token)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
session.commit()
@@ -226,14 +259,6 @@ async def heartbeat_or_create_agent(
agent_id=agent.id,
)
session.commit()
try:
await send_provisioning_message(agent)
except OpenClawGatewayError as exc:
_record_provisioning_failure(session, agent, str(exc))
session.commit()
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_provisioning_failure(session, agent, str(exc))
session.commit()
if payload.status:
agent.status = payload.status
agent.last_seen_at = datetime.utcnow()
@@ -253,6 +278,41 @@ def delete_agent(
) -> dict[str, bool]:
agent = session.get(Agent, agent_id)
if agent:
async def _gateway_cleanup() -> None:
if agent.openclaw_session_id:
await delete_session(agent.openclaw_session_id)
main_session = settings.openclaw_main_session_key
if main_session:
workspace_root = settings.openclaw_workspace_root or "~/.openclaw/workspaces"
workspace_path = f"{workspace_root.rstrip('/')}/{_slugify(agent.name)}"
cleanup_message = (
"Cleanup request for deleted agent.\n\n"
f"Agent name: {agent.name}\n"
f"Agent id: {agent.id}\n"
f"Session key: {agent.openclaw_session_id or _build_session_key(agent.name)}\n"
f"Workspace path: {workspace_path}\n\n"
"Actions:\n"
"1) Remove the workspace directory.\n"
"2) Delete any lingering session artifacts.\n"
"Reply NO_REPLY."
)
await ensure_session(main_session, label="Main Agent")
await send_message(cleanup_message, session_key=main_session, deliver=False)
try:
import asyncio
asyncio.run(_gateway_cleanup())
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway cleanup failed: {exc}",
) from exc
session.execute(
update(ActivityEvent)
.where(col(ActivityEvent.agent_id) == agent.id)
.values(agent_id=None)
)
session.delete(agent)
session.commit()
return {"ok": True}

View File

@@ -3,7 +3,12 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.api.deps import get_board_or_404, require_admin_auth
from app.api.deps import (
ActorContext,
get_board_or_404,
require_admin_auth,
require_admin_or_agent,
)
from app.core.auth import AuthContext
from app.db.session import get_session
from app.models.boards import Board
@@ -15,7 +20,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
@router.get("", response_model=list[BoardRead])
def list_boards(
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[Board]:
return list(session.exec(select(Board)))
@@ -36,7 +41,7 @@ def create_board(
@router.get("/{board_id}", response_model=BoardRead)
def get_board(
board: Board = Depends(get_board_or_404),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Board:
return board

View File

@@ -1,12 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from fastapi import Depends, HTTPException, status
from sqlmodel import Session
from app.core.auth import AuthContext, get_auth_context
from app.core.agent_auth import AgentAuthContext, get_agent_auth_context_optional
from app.core.auth import AuthContext, get_auth_context, get_auth_context_optional
from app.db.session import get_session
from app.models.agents import Agent
from app.models.boards import Board
from app.models.tasks import Task
from app.models.users import User
from app.services.admin_access import require_admin
@@ -15,6 +21,25 @@ def require_admin_auth(auth: AuthContext = Depends(get_auth_context)) -> AuthCon
return auth
@dataclass
class ActorContext:
actor_type: Literal["user", "agent"]
user: User | None = None
agent: Agent | None = None
def require_admin_or_agent(
auth: AuthContext | None = Depends(get_auth_context_optional),
agent_auth: AgentAuthContext | None = Depends(get_agent_auth_context_optional),
) -> ActorContext:
if auth is not None:
require_admin(auth)
return ActorContext(actor_type="user", user=auth.user)
if agent_auth is not None:
return ActorContext(actor_type="agent", agent=agent_auth.agent)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
def get_board_or_404(
board_id: str,
session: Session = Depends(get_session),

View File

@@ -7,6 +7,7 @@ from app.core.auth import AuthContext
from app.core.config import settings
from app.integrations.openclaw_gateway import (
OpenClawGatewayError,
ensure_session,
get_chat_history,
openclaw_call,
send_message,
@@ -24,11 +25,24 @@ async def gateway_status(auth: AuthContext = Depends(require_admin_auth)) -> dic
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
main_session = settings.openclaw_main_session_key
main_session_entry: object | None = None
main_session_error: str | None = None
if main_session:
try:
ensured = await ensure_session(main_session, label="Main Agent")
if isinstance(ensured, dict):
main_session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError as exc:
main_session_error = str(exc)
return {
"connected": True,
"gateway_url": gateway_url,
"sessions_count": len(sessions_list),
"sessions": sessions_list,
"main_session_key": main_session,
"main_session": main_session_entry,
"main_session_error": main_session_error,
}
except OpenClawGatewayError as exc:
return {
@@ -45,8 +59,21 @@ async def list_sessions(auth: AuthContext = Depends(require_admin_auth)) -> dict
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if isinstance(sessions, dict):
return {"sessions": list(sessions.get("sessions") or [])}
return {"sessions": list(sessions or [])}
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
main_session = settings.openclaw_main_session_key
main_session_entry: object | None = None
if main_session:
try:
ensured = await ensure_session(main_session, label="Main Agent")
if isinstance(ensured, dict):
main_session_entry = ensured.get("entry") or ensured
except OpenClawGatewayError:
main_session_entry = None
return {"sessions": sessions_list, "main_session_key": main_session, "main_session": main_session_entry}
@router.get("/sessions/{session_id}")
@@ -61,7 +88,27 @@ async def get_session(
sessions_list = list(sessions.get("sessions") or [])
else:
sessions_list = list(sessions or [])
main_session = settings.openclaw_main_session_key
if main_session and not any(
session.get("key") == main_session for session in sessions_list
):
try:
await ensure_session(main_session, label="Main Agent")
refreshed = await openclaw_call("sessions.list")
if isinstance(refreshed, dict):
sessions_list = list(refreshed.get("sessions") or [])
else:
sessions_list = list(refreshed or [])
except OpenClawGatewayError:
pass
session = next((item for item in sessions_list if item.get("key") == session_id), None)
if session is None and main_session and session_id == main_session:
try:
ensured = await ensure_session(main_session, label="Main Agent")
if isinstance(ensured, dict):
session = ensured.get("entry") or ensured
except OpenClawGatewayError:
session = None
if session is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
return {"session": session}
@@ -92,6 +139,9 @@ async def send_session_message(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="content is required"
)
try:
main_session = settings.openclaw_main_session_key
if main_session and session_id == main_session:
await ensure_session(main_session, label="Main Agent")
await send_message(content, session_key=session_id)
except OpenClawGatewayError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc

View File

@@ -2,10 +2,16 @@ from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.api.deps import get_board_or_404, get_task_or_404, require_admin_auth
from app.api.deps import (
ActorContext,
get_board_or_404,
get_task_or_404,
require_admin_auth,
require_admin_or_agent,
)
from app.core.auth import AuthContext
from app.db.session import get_session
from app.models.boards import Board
@@ -20,7 +26,7 @@ router = APIRouter(prefix="/boards/{board_id}/tasks", tags=["tasks"])
def list_tasks(
board: Board = Depends(get_board_or_404),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> list[Task]:
return list(session.exec(select(Task).where(Task.board_id == board.id)))
@@ -55,10 +61,14 @@ def update_task(
payload: TaskUpdate,
task: Task = Depends(get_task_or_404),
session: Session = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Task:
previous_status = task.status
updates = payload.model_dump(exclude_unset=True)
if actor.actor_type == "agent":
allowed_fields = {"status"}
if not set(updates).issubset(allowed_fields):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
for key, value in updates.items():
setattr(task, key, value)
task.updated_at = datetime.utcnow()
@@ -73,7 +83,13 @@ def update_task(
else:
event_type = "task.updated"
message = f"Task updated: {task.title}."
record_activity(session, event_type=event_type, task_id=task.id, message=message)
record_activity(
session,
event_type=event_type,
task_id=task.id,
message=message,
agent_id=actor.agent.id if actor.actor_type == "agent" and actor.agent else None,
)
session.commit()
return task

View File

@@ -95,3 +95,46 @@ async def get_auth_context(
actor_type="user",
user=user,
)
async def get_auth_context_optional(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(security),
session: Session = Depends(get_session),
) -> AuthContext | None:
if credentials is None:
return None
try:
guard = _build_clerk_http_bearer(auto_error=False)
clerk_credentials = await guard(request)
except (RuntimeError, ValueError) as exc:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) from exc
except HTTPException as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
auth_data = _resolve_clerk_auth(request, clerk_credentials)
try:
clerk_user_id = _parse_subject(auth_data)
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) from exc
if not clerk_user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
user = session.exec(select(User).where(User.clerk_user_id == clerk_user_id)).first()
if user is None:
claims = auth_data.decoded if auth_data and auth_data.decoded else {}
user = User(
clerk_user_id=clerk_user_id,
email=claims.get("email"),
name=claims.get("name"),
)
session.add(user)
session.commit()
session.refresh(user)
return AuthContext(
actor_type="user",
user=user,
)

View File

@@ -126,3 +126,14 @@ async def get_chat_history(session_key: str, limit: int | None = None) -> Any:
if limit is not None:
params["limit"] = limit
return await openclaw_call("chat.history", params)
async def delete_session(session_key: str) -> Any:
return await openclaw_call("sessions.delete", {"key": session_key})
async def ensure_session(session_key: str, label: str | None = None) -> Any:
params: dict[str, Any] = {"key": session_key}
if label:
params["label"] = label
return await openclaw_call("sessions.patch", params)

View File

@@ -13,6 +13,7 @@ class Agent(SQLModel, table=True):
name: str = Field(index=True)
status: str = Field(default="online", index=True)
openclaw_session_id: str | None = Field(default=None, index=True)
agent_token_hash: str | None = Field(default=None, index=True)
last_seen_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)

View File

@@ -7,7 +7,7 @@ from uuid import uuid4
from jinja2 import Environment, FileSystemLoader, StrictUndefined
from app.core.config import settings
from app.integrations.openclaw_gateway import send_message
from app.integrations.openclaw_gateway import ensure_session, send_message
from app.models.agents import Agent
TEMPLATE_FILES = [
@@ -68,12 +68,11 @@ def _workspace_path(agent_name: str) -> str:
return f"{root}/{_slugify(agent_name)}"
def build_provisioning_message(agent: Agent) -> str:
def build_provisioning_message(agent: Agent, auth_token: str) -> str:
agent_id = str(agent.id)
workspace_path = _workspace_path(agent.name)
session_key = agent.openclaw_session_id or ""
base_url = settings.base_url or "REPLACE_WITH_BASE_URL"
auth_token = "REPLACE_WITH_AUTH_TOKEN"
context = {
"agent_name": agent.name,
@@ -114,9 +113,10 @@ def build_provisioning_message(agent: Agent) -> str:
)
async def send_provisioning_message(agent: Agent) -> None:
async def send_provisioning_message(agent: Agent, auth_token: str) -> None:
main_session = settings.openclaw_main_session_key
if not main_session:
return
message = build_provisioning_message(agent)
await ensure_session(main_session, label="Main Agent")
message = build_provisioning_message(agent, auth_token)
await send_message(message, session_key=main_session, deliver=False)

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>

View File

@@ -4,14 +4,14 @@ If this file is empty, skip heartbeat work.
## Required inputs
- BASE_URL (e.g. http://localhost:8000)
- AUTH_TOKEN (Bearer token)
- AUTH_TOKEN (agent token)
- AGENT_NAME
## On every heartbeat
1) Check in:
```bash
curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "'$AGENT_NAME'", "status": "online"}'
```
@@ -19,13 +19,13 @@ curl -s -X POST "$BASE_URL/api/v1/agents/heartbeat" \
2) List boards:
```bash
curl -s "$BASE_URL/api/v1/boards" \
-H "Authorization: Bearer $AUTH_TOKEN"
-H "X-Agent-Token: $AUTH_TOKEN"
```
3) For each board, list tasks:
```bash
curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks" \
-H "Authorization: Bearer $AUTH_TOKEN"
-H "X-Agent-Token: $AUTH_TOKEN"
```
4) Claim next task (FIFO):
@@ -33,7 +33,7 @@ curl -s "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks" \
- Claim it by moving it to "in_progress":
```bash
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
```
@@ -43,7 +43,7 @@ curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
- When complete, move to "review":
```bash
curl -s -X PATCH "$BASE_URL/api/v1/boards/{BOARD_ID}/tasks/{TASK_ID}" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "review"}'
```

View File

@@ -5,4 +5,3 @@ Agent ID: {{ agent_id }}
Creature: AI
Vibe: calm, precise, helpful
Emoji: :gear:
Avatar: avatars/{{ agent_id }}.png