feat: 新增平台连通性测试按钮,每个平台标签右侧显示测试结果

This commit is contained in:
xmanrui
2026-02-21 16:25:24 +08:00
parent e461eb3abb
commit f3967992e7
4 changed files with 267 additions and 23 deletions
+179
View File
@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
interface TestRequest {
agentId: string;
platform: string;
sessionKey: string;
}
async function testPlatformSession(req: TestRequest): Promise<{
agentId: string;
platform: string;
ok: boolean;
reply?: string;
error?: string;
elapsed: number;
}> {
const startTime = Date.now();
try {
const result = execSync(
`openclaw agent --agent ${req.agentId} --session-id "${req.sessionKey}" --message "Platform health check: reply with OK" --timeout 30 --json`,
{ timeout: 40000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
);
const elapsed = Date.now() - startTime;
const lines = result.split("\n");
const jsonStartIdx = lines.findIndex(l => l.trimStart().startsWith("{"));
if (jsonStartIdx === -1) {
return { agentId: req.agentId, platform: req.platform, ok: false, error: "No JSON in CLI output", elapsed };
}
const jsonStr = lines.slice(jsonStartIdx).join("\n");
const data = JSON.parse(jsonStr);
const payloads = data?.result?.payloads || [];
const reply = payloads[0]?.text || "";
const durationMs = data?.result?.meta?.durationMs || elapsed;
const ok = data.status === "ok";
return {
agentId: req.agentId,
platform: req.platform,
ok,
reply: reply ? reply.slice(0, 200) : (ok ? "(no reply)" : ""),
error: ok ? undefined : "Agent returned error status",
elapsed: durationMs,
};
} catch (execErr: any) {
const elapsed = Date.now() - startTime;
const isTimeout = execErr.killed || execErr.signal === "SIGTERM";
return {
agentId: req.agentId,
platform: req.platform,
ok: false,
error: isTimeout
? "Timeout: platform not responding (30s)"
: (execErr.stderr || execErr.message || "Unknown error").slice(0, 300),
elapsed,
};
}
}
export async function POST() {
try {
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const config = JSON.parse(raw);
const defaults = config.agents?.defaults || {};
const defaultModel = typeof defaults.model === "string"
? defaults.model
: defaults.model?.primary || "unknown";
const bindings = config.bindings || [];
const channels = config.channels || {};
const feishuAccounts = channels.feishu?.accounts || {};
let agentList = config.agents?.list || [];
if (agentList.length === 0) {
try {
const agentsDir = path.join(OPENCLAW_HOME, "agents");
const dirs = fs.readdirSync(agentsDir, { withFileTypes: true });
agentList = dirs
.filter((d: any) => d.isDirectory() && !d.name.startsWith("."))
.map((d: any) => ({ id: d.name }));
} catch {}
if (agentList.length === 0) {
agentList = [{ id: "main" }];
}
}
// Build test requests for each agent's platforms
const testRequests: TestRequest[] = [];
for (const agent of agentList) {
const id = agent.id;
// Find feishu sessions
const feishuBinding = bindings.find(
(b: any) => b.agentId === id && b.match?.channel === "feishu"
);
const hasFeishuBinding = !!feishuBinding;
const hasFeishuAccount = !!feishuAccounts[id];
if (hasFeishuBinding || hasFeishuAccount) {
// Find the most recent feishu DM session for this agent
try {
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${id}/sessions/sessions.json`);
const sessRaw = fs.readFileSync(sessionsPath, "utf-8");
const sessions = JSON.parse(sessRaw);
let bestKey: string | null = null;
let bestTime = 0;
for (const [key, val] of Object.entries(sessions)) {
if (key.match(/^agent:[^:]+:feishu:direct:/)) {
const updatedAt = (val as any).updatedAt || 0;
if (updatedAt > bestTime) { bestTime = updatedAt; bestKey = key; }
}
}
if (bestKey) {
testRequests.push({ agentId: id, platform: "feishu", sessionKey: bestKey });
}
} catch {}
}
// main agent special: also check discord
if (id === "main" && channels.discord?.enabled) {
try {
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${id}/sessions/sessions.json`);
const sessRaw = fs.readFileSync(sessionsPath, "utf-8");
const sessions = JSON.parse(sessRaw);
let bestKey: string | null = null;
let bestTime = 0;
for (const [key, val] of Object.entries(sessions)) {
if (key.match(/^agent:[^:]+:discord:direct:/)) {
const updatedAt = (val as any).updatedAt || 0;
if (updatedAt > bestTime) { bestTime = updatedAt; bestKey = key; }
}
}
if (bestKey) {
testRequests.push({ agentId: id, platform: "discord", sessionKey: bestKey });
}
} catch {}
}
// Non-main agents with discord bindings
if (id !== "main") {
const discordBinding = bindings.find(
(b: any) => b.agentId === id && b.match?.channel === "discord"
);
if (discordBinding) {
try {
const sessionsPath = path.join(OPENCLAW_HOME, `agents/${id}/sessions/sessions.json`);
const sessRaw = fs.readFileSync(sessionsPath, "utf-8");
const sessions = JSON.parse(sessRaw);
let bestKey: string | null = null;
let bestTime = 0;
for (const [key, val] of Object.entries(sessions)) {
if (key.match(/^agent:[^:]+:discord:direct:/)) {
const updatedAt = (val as any).updatedAt || 0;
if (updatedAt > bestTime) { bestTime = updatedAt; bestKey = key; }
}
}
if (bestKey) {
testRequests.push({ agentId: id, platform: "discord", sessionKey: bestKey });
}
} catch {}
}
}
}
// Run all tests in parallel
const results = await Promise.all(testRequests.map(testPlatformSession));
return NextResponse.json({ results });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+80 -23
View File
@@ -159,8 +159,16 @@ function ResponseTrendChart({ data, height = 180, t }: { data: DayStat[]; height
); );
} }
// 平台测试结果类型
interface PlatformTestResult {
ok: boolean;
reply?: string;
error?: string;
elapsed: number;
}
// 平台标签颜色 // 平台标签颜色
function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; t: TFunc }) { function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t, testResult }: { platform: Platform; agentId: string; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: PlatformTestResult | null }) {
const isFeishu = platform.name === "feishu"; const isFeishu = platform.name === "feishu";
let sessionKey: string; let sessionKey: string;
@@ -175,24 +183,35 @@ function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { pl
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`; if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
return ( return (
<a <div className="flex items-center gap-1.5">
href={sessionUrl} <a
target="_blank" href={sessionUrl}
rel="noopener noreferrer" target="_blank"
onClick={(e) => e.stopPropagation()} rel="noopener noreferrer"
title={t("agent.openChat")} onClick={(e) => e.stopPropagation()}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md ${ title={t("agent.openChat")}
isFeishu className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium cursor-pointer transition-all hover:scale-105 hover:shadow-md ${
? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400" isFeishu
: "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400" ? "bg-blue-500/20 text-blue-300 border border-blue-500/30 hover:bg-blue-500/40 hover:border-blue-400"
}`} : "bg-purple-500/20 text-purple-300 border border-purple-500/30 hover:bg-purple-500/40 hover:border-purple-400"
> }`}
{isFeishu ? t("platform.feishu") : t("platform.discord")} >
{platform.accountId && ( {isFeishu ? t("platform.feishu") : t("platform.discord")}
<span className="opacity-60">({platform.accountId})</span> {platform.accountId && (
<span className="opacity-60">({platform.accountId})</span>
)}
<span className="opacity-50 text-[10px]"></span>
</a>
{testResult === undefined ? (
<span className="text-xs text-[var(--text-muted)]">--</span>
) : testResult === null ? (
<span className="text-xs text-[var(--text-muted)] animate-pulse"></span>
) : testResult.ok ? (
<span className="text-green-400 text-sm cursor-help" title={`${testResult.elapsed}ms${testResult.reply ? ' · ' + testResult.reply : ''}`}></span>
) : (
<span className="text-red-400 text-sm cursor-help" title={testResult.error || ''}></span>
)} )}
<span className="opacity-50 text-[10px]"></span> </div>
</a>
); );
} }
@@ -221,7 +240,7 @@ function ModelBadge({ model }: { model: string }) {
} }
// Agent 卡片 // Agent 卡片
function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult }: { agent: Agent; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: { ok: boolean; text?: string; error?: string; elapsed: number } | null }) { function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult, platformTestResults }: { agent: Agent; gatewayPort: number; gatewayToken?: string; t: TFunc; testResult?: { ok: boolean; text?: string; error?: string; elapsed: number } | null; platformTestResults?: Record<string, PlatformTestResult | null> }) {
const sessionKey = `agent:${agent.id}:main`; const sessionKey = `agent:${agent.id}:main`;
let sessionUrl = `http://localhost:${gatewayPort}/chat?session=${encodeURIComponent(sessionKey)}`; let sessionUrl = `http://localhost:${gatewayPort}/chat?session=${encodeURIComponent(sessionKey)}`;
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`; if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
@@ -272,9 +291,13 @@ function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult }: { agent:
<div> <div>
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.platform")}</span> <span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.platform")}</span>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{agent.platforms.map((p, i) => ( {agent.platforms.map((p, i) => {
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} /> const pKey = `${agent.id}:${p.name}`;
))} const pResult = platformTestResults ? platformTestResults[pKey] : undefined;
return (
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} testResult={pResult} />
);
})}
</div> </div>
</div> </div>
@@ -339,6 +362,8 @@ export default function Home() {
const timerRef = useRef<NodeJS.Timeout | null>(null); const timerRef = useRef<NodeJS.Timeout | null>(null);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; text?: string; error?: string; elapsed: number }> | null>(null); const [testResults, setTestResults] = useState<Record<string, { ok: boolean; text?: string; error?: string; elapsed: number }> | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [platformTestResults, setPlatformTestResults] = useState<Record<string, PlatformTestResult | null> | null>(null);
const [testingPlatforms, setTestingPlatforms] = useState(false);
const RANGE_LABELS: Record<TimeRange, string> = { daily: t("range.daily"), weekly: t("range.weekly"), monthly: t("range.monthly") }; const RANGE_LABELS: Record<TimeRange, string> = { daily: t("range.daily"), weekly: t("range.weekly"), monthly: t("range.monthly") };
@@ -389,6 +414,31 @@ export default function Home() {
.finally(() => setTesting(false)); .finally(() => setTesting(false));
}, [data]); }, [data]);
const testAllPlatforms = useCallback(() => {
setTestingPlatforms(true);
// Set all agent:platform combos to null (⏳)
const pending: Record<string, any> = {};
if (data) {
for (const a of data.agents) {
for (const p of a.platforms) {
pending[`${a.id}:${p.name}`] = null;
}
}
}
setPlatformTestResults(pending);
fetch("/api/test-platforms", { method: "POST" })
.then((r) => r.json())
.then((resp) => {
if (resp.results) {
const map: Record<string, PlatformTestResult> = {};
for (const r of resp.results) map[`${r.agentId}:${r.platform}`] = r;
setPlatformTestResults(map);
}
})
.catch(() => {})
.finally(() => setTestingPlatforms(false));
}, [data]);
// 定时刷新 // 定时刷新
useEffect(() => { useEffect(() => {
if (timerRef.current) clearInterval(timerRef.current); if (timerRef.current) clearInterval(timerRef.current);
@@ -462,13 +512,20 @@ export default function Home() {
> >
{testing ? t("home.testingAll") : t("home.testAll")} {testing ? t("home.testingAll") : t("home.testAll")}
</button> </button>
<button
onClick={testAllPlatforms}
disabled={testingPlatforms}
className="px-4 py-2 rounded-lg bg-[var(--card)] border border-[var(--border)] text-[var(--text)] text-sm font-medium hover:border-[var(--accent)] transition disabled:opacity-50 cursor-pointer"
>
{testingPlatforms ? t("home.testingPlatforms") : t("home.testPlatforms")}
</button>
</div> </div>
</div> </div>
{/* 卡片墙 */} {/* 卡片墙 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.agents.map((agent) => ( {data.agents.map((agent) => (
<AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} t={t} testResult={testResults?.[agent.id]} /> <AgentCard key={agent.id} agent={agent} gatewayPort={data.gateway?.port || 18789} gatewayToken={data.gateway?.token} t={t} testResult={testResults?.[agent.id]} platformTestResults={platformTestResults || undefined} />
))} ))}
</div> </div>
+4
View File
@@ -61,6 +61,8 @@ const translations: Record<Locale, Record<string, string>> = {
"home.discordChannel": "Discord 频道", "home.discordChannel": "Discord 频道",
"home.bots": "个机器人", "home.bots": "个机器人",
"home.noResponseData": "暂无响应时间数据", "home.noResponseData": "暂无响应时间数据",
"home.testPlatforms": "🧪 测试平台连通",
"home.testingPlatforms": "⏳ 测试平台中...",
// agent card // agent card
"agent.model": "模型", "agent.model": "模型",
@@ -221,6 +223,8 @@ const translations: Record<Locale, Record<string, string>> = {
"home.discordChannel": "Discord Channel", "home.discordChannel": "Discord Channel",
"home.bots": "bots", "home.bots": "bots",
"home.noResponseData": "No response time data", "home.noResponseData": "No response time data",
"home.testPlatforms": "🧪 Test Platforms",
"home.testingPlatforms": "⏳ Testing Platforms...",
// agent card // agent card
"agent.model": "Model", "agent.model": "Model",
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}