234 lines
9.0 KiB
TypeScript
234 lines
9.0 KiB
TypeScript
'use client';
|
||
|
||
import { useState } from 'react';
|
||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { toast } from 'sonner';
|
||
import { RefreshCw, Boxes, CheckCircle, AlertCircle, Clock, XCircle, StopCircle } from 'lucide-react';
|
||
|
||
interface AgentInstance {
|
||
id: string;
|
||
userId: string;
|
||
name: string;
|
||
agentType: string;
|
||
poolServerId?: string;
|
||
serverHost: string;
|
||
hostPort: number;
|
||
containerName: string;
|
||
status: 'deploying' | 'running' | 'stopped' | 'error' | 'removed';
|
||
errorMessage?: string;
|
||
config: Record<string, unknown>;
|
||
hasToken: boolean;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
const API = '/api/proxy/api/v1/agent/instances';
|
||
|
||
async function fetchInstances(): Promise<AgentInstance[]> {
|
||
const res = await fetch(API);
|
||
if (!res.ok) throw new Error('Failed to load instances');
|
||
return res.json();
|
||
}
|
||
|
||
async function stopInstance(id: string): Promise<void> {
|
||
const res = await fetch(`${API}/${id}/stop`, { method: 'PUT' });
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error((err as any).message ?? 'Failed to stop instance');
|
||
}
|
||
}
|
||
|
||
async function removeInstance(id: string): Promise<void> {
|
||
const res = await fetch(`${API}/${id}`, { method: 'DELETE' });
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error((err as any).message ?? 'Failed to remove instance');
|
||
}
|
||
}
|
||
|
||
const STATUS_CONFIG = {
|
||
deploying: { icon: <Clock className="w-4 h-4 text-blue-400" />, label: '部署中', color: 'text-blue-400' },
|
||
running: { icon: <CheckCircle className="w-4 h-4 text-green-500" />, label: '运行中', color: 'text-green-500' },
|
||
stopped: { icon: <StopCircle className="w-4 h-4 text-yellow-500" />, label: '已停止', color: 'text-yellow-500' },
|
||
error: { icon: <AlertCircle className="w-4 h-4 text-red-500" />, label: '错误', color: 'text-red-500' },
|
||
removed: { icon: <XCircle className="w-4 h-4 text-muted-foreground" />, label: '已移除', color: 'text-muted-foreground' },
|
||
};
|
||
|
||
function StatusBadge({ status }: { status: AgentInstance['status'] }) {
|
||
const cfg = STATUS_CONFIG[status];
|
||
return (
|
||
<div className={`flex items-center gap-1.5 text-xs font-medium ${cfg.color}`}>
|
||
{cfg.icon}
|
||
{cfg.label}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatDate(iso: string) {
|
||
return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||
}
|
||
|
||
export default function OpenClawInstancesPage() {
|
||
const queryClient = useQueryClient();
|
||
const [statusFilter, setStatusFilter] = useState<AgentInstance['status'] | 'all'>('all');
|
||
|
||
const { data: instances = [], isLoading, error, refetch } = useQuery<AgentInstance[]>({
|
||
queryKey: ['openclaw-instances'],
|
||
queryFn: fetchInstances,
|
||
refetchInterval: 15_000, // Auto-refresh every 15s to catch status changes
|
||
});
|
||
|
||
const filtered = statusFilter === 'all'
|
||
? instances
|
||
: instances.filter((i) => i.status === statusFilter);
|
||
|
||
const counts = instances.reduce((acc, i) => {
|
||
acc[i.status] = (acc[i.status] ?? 0) + 1;
|
||
return acc;
|
||
}, {} as Record<string, number>);
|
||
|
||
const handleStop = async (inst: AgentInstance) => {
|
||
if (!confirm(`确定要停止实例「${inst.name}」吗?`)) return;
|
||
try {
|
||
await stopInstance(inst.id);
|
||
toast.success('实例已停止');
|
||
queryClient.invalidateQueries({ queryKey: ['openclaw-instances'] });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : '操作失败');
|
||
}
|
||
};
|
||
|
||
const handleRemove = async (inst: AgentInstance) => {
|
||
if (!confirm(`确定要移除实例「${inst.name}」吗?容器将被销毁,数据卷保留。`)) return;
|
||
try {
|
||
await removeInstance(inst.id);
|
||
toast.success('实例已移除');
|
||
queryClient.invalidateQueries({ queryKey: ['openclaw-instances'] });
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : '移除失败');
|
||
}
|
||
};
|
||
|
||
const FILTER_TABS: { value: AgentInstance['status'] | 'all'; label: string }[] = [
|
||
{ value: 'all', label: `全部 (${instances.length})` },
|
||
{ value: 'running', label: `运行中 (${counts.running ?? 0})` },
|
||
{ value: 'deploying', label: `部署中 (${counts.deploying ?? 0})` },
|
||
{ value: 'stopped', label: `已停止 (${counts.stopped ?? 0})` },
|
||
{ value: 'error', label: `错误 (${counts.error ?? 0})` },
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-foreground">OpenClaw 实例</h1>
|
||
<p className="text-sm text-muted-foreground mt-0.5">
|
||
跨租户查看所有用户的 OpenClaw 智能体实例
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => refetch()}
|
||
disabled={isLoading}
|
||
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-sm bg-accent text-foreground hover:bg-accent/80 disabled:opacity-50"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||
刷新
|
||
</button>
|
||
</div>
|
||
|
||
{/* Status filter tabs */}
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{FILTER_TABS.map(({ value, label }) => (
|
||
<button
|
||
key={value}
|
||
onClick={() => setStatusFilter(value)}
|
||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||
statusFilter === value
|
||
? 'bg-primary text-primary-foreground'
|
||
: 'bg-accent text-muted-foreground hover:text-foreground hover:bg-accent/80'
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Error */}
|
||
{error && (
|
||
<div className="rounded-lg bg-destructive/10 border border-destructive/30 p-4 text-red-400 text-sm">
|
||
加载失败:{error instanceof Error ? error.message : String(error)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Loading */}
|
||
{isLoading && (
|
||
<div className="space-y-3">
|
||
{[1, 2, 3].map((i) => (
|
||
<div key={i} className="bg-card rounded-lg border p-5 animate-pulse h-20" />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Instance list */}
|
||
{!isLoading && !error && (
|
||
<div className="space-y-3">
|
||
{filtered.length === 0 ? (
|
||
<div className="bg-card rounded-lg border p-12 text-center">
|
||
<Boxes className="w-10 h-10 text-muted-foreground mx-auto mb-3" />
|
||
<p className="text-muted-foreground text-sm">暂无实例数据</p>
|
||
</div>
|
||
) : (
|
||
filtered.map((inst) => (
|
||
<div key={inst.id} className={`bg-card rounded-lg border p-5 ${inst.status === 'removed' ? 'opacity-50' : ''}`}>
|
||
<div className="flex items-start justify-between">
|
||
<div className="space-y-1.5 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<StatusBadge status={inst.status} />
|
||
<span className="font-medium text-sm">{inst.name}</span>
|
||
<span className="px-1.5 py-0.5 rounded text-[10px] bg-accent text-muted-foreground uppercase">{inst.agentType}</span>
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
|
||
<span>容器: <code className="text-foreground">{inst.containerName}</code></span>
|
||
<span>地址: <code className="text-foreground">{inst.serverHost}:{inst.hostPort}</code></span>
|
||
<span>用户ID: <code className="text-foreground">{inst.userId.slice(0, 8)}…</code></span>
|
||
<span>创建: {formatDate(inst.createdAt)}</span>
|
||
</div>
|
||
{inst.errorMessage && (
|
||
<p className="text-xs text-red-400 bg-red-500/10 rounded px-2 py-1 mt-1">
|
||
{inst.errorMessage}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
{inst.status !== 'removed' && (
|
||
<div className="flex items-center gap-1.5 ml-4 shrink-0">
|
||
{inst.status === 'running' && (
|
||
<button
|
||
onClick={() => handleStop(inst)}
|
||
className="px-2.5 py-1.5 rounded text-xs bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500/20 transition-colors"
|
||
>
|
||
停止
|
||
</button>
|
||
)}
|
||
{(inst.status === 'stopped' || inst.status === 'error') && (
|
||
<button
|
||
onClick={() => handleRemove(inst)}
|
||
className="px-2.5 py-1.5 rounded text-xs bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors"
|
||
>
|
||
移除
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|