it0/it0-web-admin/src/app/(admin)/openclaw-instances/page.tsx

234 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}