multica/apps/web/features/runtimes/components/ping-section.tsx
Jiayuan ceba8556f5 refactor(runtimes): split monolithic page, add zustand store, time range selector, and delete support
- Split 1189-line runtimes-page.tsx into focused sub-components (list, detail, ping, usage, 5 chart files, shared UI, utils)
- Add useRuntimeStore zustand store for shared runtime state across pages (agents page now uses it too)
- Add 7d/30d/90d time range selector to usage charts
- Add full-stack runtime delete: SQL query, Go handler, API route, frontend with confirmation dialog
- Remove unused daemon:heartbeat WS listener (server never broadcasts it)
2026-03-29 17:02:25 +08:00

120 lines
3.9 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { api } from "@/shared/api";
import type { RuntimePingStatus } from "@/shared/types";
const pingStatusConfig: Record<
RuntimePingStatus,
{ label: string; icon: typeof Loader2; color: string }
> = {
pending: { label: "Waiting for daemon...", icon: Loader2, color: "text-muted-foreground" },
running: { label: "Running test...", icon: Loader2, color: "text-info" },
completed: { label: "Connected", icon: CheckCircle2, color: "text-success" },
failed: { label: "Failed", icon: XCircle, color: "text-destructive" },
timeout: { label: "Timeout", icon: XCircle, color: "text-warning" },
};
export function PingSection({ runtimeId }: { runtimeId: string }) {
const [status, setStatus] = useState<RuntimePingStatus | null>(null);
const [output, setOutput] = useState("");
const [error, setError] = useState("");
const [durationMs, setDurationMs] = useState<number | null>(null);
const [testing, setTesting] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cleanup = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
useEffect(() => cleanup, [cleanup]);
const handleTest = async () => {
cleanup();
setTesting(true);
setStatus("pending");
setOutput("");
setError("");
setDurationMs(null);
try {
const ping = await api.pingRuntime(runtimeId);
pollRef.current = setInterval(async () => {
try {
const result = await api.getPingResult(runtimeId, ping.id);
setStatus(result.status as RuntimePingStatus);
if (result.status === "completed") {
setOutput(result.output ?? "");
setDurationMs(result.duration_ms ?? null);
setTesting(false);
cleanup();
} else if (result.status === "failed" || result.status === "timeout") {
setError(result.error ?? "Unknown error");
setDurationMs(result.duration_ms ?? null);
setTesting(false);
cleanup();
}
} catch {
// ignore poll errors
}
}, 2000);
} catch {
setStatus("failed");
setError("Failed to initiate test");
setTesting(false);
}
};
const config = status ? pingStatusConfig[status] : null;
const Icon = config?.icon;
const isActive = status === "pending" || status === "running";
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
onClick={handleTest}
disabled={testing}
>
{testing ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Zap className="h-3 w-3" />
)}
{testing ? "Testing..." : "Test Connection"}
</Button>
{config && Icon && (
<span className={`inline-flex items-center gap-1 text-xs ${config.color}`}>
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
{config.label}
{durationMs != null && (
<span className="text-muted-foreground">
({(durationMs / 1000).toFixed(1)}s)
</span>
)}
</span>
)}
</div>
{status === "completed" && output && (
<div className="rounded-lg border bg-success/5 px-3 py-2">
<pre className="text-xs font-mono whitespace-pre-wrap">{output}</pre>
</div>
)}
{(status === "failed" || status === "timeout") && error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
</div>
);
}