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 });
}
}
+63 -6
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";
let sessionKey: string;
@@ -175,6 +183,7 @@ function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { pl
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
return (
<div className="flex items-center gap-1.5">
<a
href={sessionUrl}
target="_blank"
@@ -193,6 +202,16 @@ function PlatformBadge({ platform, agentId, gatewayPort, gatewayToken, t }: { pl
)}
<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>
)}
</div>
);
}
@@ -221,7 +240,7 @@ function ModelBadge({ model }: { model: string }) {
}
// 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`;
let sessionUrl = `http://localhost:${gatewayPort}/chat?session=${encodeURIComponent(sessionKey)}`;
if (gatewayToken) sessionUrl += `&token=${encodeURIComponent(gatewayToken)}`;
@@ -272,9 +291,13 @@ function AgentCard({ agent, gatewayPort, gatewayToken, t, testResult }: { agent:
<div>
<span className="text-xs text-[var(--text-muted)] block mb-1">{t("agent.platform")}</span>
<div className="flex flex-col gap-1">
{agent.platforms.map((p, i) => (
<PlatformBadge key={i} platform={p} agentId={agent.id} gatewayPort={gatewayPort} gatewayToken={gatewayToken} t={t} />
))}
{agent.platforms.map((p, i) => {
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>
@@ -339,6 +362,8 @@ export default function Home() {
const timerRef = useRef<NodeJS.Timeout | null>(null);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; text?: string; error?: string; elapsed: number }> | null>(null);
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") };
@@ -389,6 +414,31 @@ export default function Home() {
.finally(() => setTesting(false));
}, [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(() => {
if (timerRef.current) clearInterval(timerRef.current);
@@ -462,13 +512,20 @@ export default function Home() {
>
{testing ? t("home.testingAll") : t("home.testAll")}
</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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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>
+4
View File
@@ -61,6 +61,8 @@ const translations: Record<Locale, Record<string, string>> = {
"home.discordChannel": "Discord 频道",
"home.bots": "个机器人",
"home.noResponseData": "暂无响应时间数据",
"home.testPlatforms": "🧪 测试平台连通",
"home.testingPlatforms": "⏳ 测试平台中...",
// agent card
"agent.model": "模型",
@@ -221,6 +223,8 @@ const translations: Record<Locale, Record<string, string>> = {
"home.discordChannel": "Discord Channel",
"home.bots": "bots",
"home.noResponseData": "No response time data",
"home.testPlatforms": "🧪 Test Platforms",
"home.testingPlatforms": "⏳ Testing Platforms...",
// agent card
"agent.model": "Model",
+4
View File
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}