mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 14:52:09 +03:00
refactor(dashboard): mover toggle/transport para páginas MCP e A2A, limpar abas duplicadas
- /dashboard/mcp: adiciona ServiceToggle (ON/OFF), TransportSelector (stdio/SSE/streamable-http) e DisabledPanel - /dashboard/a2a: adiciona ServiceToggle (ON/OFF) e DisabledPanel - /dashboard/endpoint: remove abas MCP, A2A e API Endpoints (agora têm páginas próprias), renderiza só EndpointPageClient - ApiEndpointsTab: remove sub-aba Webhooks (migrada para /dashboard/webhooks)
This commit is contained in:
@@ -1,7 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import A2ADashboardPage from "../endpoint/components/A2ADashboard";
|
||||
|
||||
type ServiceStatus = { online: boolean; loading: boolean };
|
||||
|
||||
function ServiceToggle({
|
||||
label,
|
||||
status,
|
||||
enabled,
|
||||
onToggle,
|
||||
toggling,
|
||||
}: {
|
||||
label: string;
|
||||
status: ServiceStatus;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
toggling: boolean;
|
||||
}) {
|
||||
const online = enabled && status.online;
|
||||
const loading = enabled && status.loading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border"
|
||||
style={{
|
||||
borderColor: loading
|
||||
? "var(--color-border)"
|
||||
: online
|
||||
? "rgba(34,197,94,0.3)"
|
||||
: "rgba(239,68,68,0.3)",
|
||||
background: loading
|
||||
? "transparent"
|
||||
: online
|
||||
? "rgba(34,197,94,0.1)"
|
||||
: "rgba(239,68,68,0.1)",
|
||||
color: loading ? "var(--color-text-muted)" : online ? "rgb(34,197,94)" : "rgb(239,68,68)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background: loading
|
||||
? "var(--color-text-muted)"
|
||||
: online
|
||||
? "rgb(34,197,94)"
|
||||
: "rgb(239,68,68)",
|
||||
animation: online ? "pulse 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
{loading ? "..." : online ? "Online" : "Offline"}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={toggling}
|
||||
className="relative inline-flex items-center h-7 w-[52px] rounded-full transition-all duration-300 focus:outline-none border"
|
||||
style={{
|
||||
background: enabled ? "rgb(34,197,94)" : "var(--color-bg-tertiary)",
|
||||
borderColor: enabled ? "rgba(34,197,94,0.5)" : "var(--color-border)",
|
||||
opacity: toggling ? 0.6 : 1,
|
||||
cursor: toggling ? "wait" : "pointer",
|
||||
}}
|
||||
title={enabled ? `Disable ${label}` : `Enable ${label}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
|
||||
style={{
|
||||
transform: enabled ? "translateX(26px)" : "translateX(3px)",
|
||||
background: enabled ? "#fff" : "var(--color-text-muted)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="text-xs font-medium min-w-[24px]"
|
||||
style={{ color: enabled ? "rgb(34,197,94)" : "var(--color-text-muted)" }}
|
||||
>
|
||||
{toggling ? "..." : enabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledPanel() {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
style={{ background: "var(--color-bg-tertiary)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative block size-5 rounded-full border-2"
|
||||
style={{ borderColor: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-1/2 top-[-3px] h-3 w-0.5 -translate-x-1/2 rounded-full"
|
||||
style={{ background: "var(--color-text-muted)" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
A2A is disabled
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Enable A2A above to view task telemetry, agent details, and validation tools.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function A2APage() {
|
||||
return <A2ADashboardPage />;
|
||||
const [a2aStatus, setA2aStatus] = useState<ServiceStatus>({ online: false, loading: true });
|
||||
const [a2aEnabled, setA2aEnabled] = useState(false);
|
||||
const [a2aToggling, setA2aToggling] = useState(false);
|
||||
|
||||
const patchSetting = useCallback(async (body: Record<string, unknown>) => {
|
||||
return fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setA2aEnabled(!!data.a2aEnabled);
|
||||
}
|
||||
} catch {
|
||||
// defaults stay
|
||||
}
|
||||
};
|
||||
void fetchSettings();
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setA2aStatus((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const res = await fetch("/api/a2a/status");
|
||||
const data = res.ok ? await res.json() : null;
|
||||
setA2aStatus({ online: data?.status === "ok", loading: false });
|
||||
} catch {
|
||||
setA2aStatus({ online: false, loading: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
const interval = setInterval(() => void refreshStatus(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshStatus]);
|
||||
|
||||
const toggleA2a = useCallback(async () => {
|
||||
const newValue = !a2aEnabled;
|
||||
setA2aToggling(true);
|
||||
try {
|
||||
const res = await patchSetting({ a2aEnabled: newValue });
|
||||
if (res.ok) setA2aEnabled(newValue);
|
||||
} catch {
|
||||
// keep current
|
||||
} finally {
|
||||
setA2aToggling(false);
|
||||
}
|
||||
}, [a2aEnabled, patchSetting]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-end">
|
||||
<ServiceToggle
|
||||
label="A2A"
|
||||
status={a2aStatus}
|
||||
enabled={a2aEnabled}
|
||||
onToggle={() => void toggleA2a()}
|
||||
toggling={a2aToggling}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{a2aEnabled ? <A2ADashboardPage /> : <DisabledPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,19 +25,6 @@ interface CatalogData {
|
||||
schemas: string[];
|
||||
}
|
||||
|
||||
interface WebhookItem {
|
||||
id: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string | null;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
created_at: string;
|
||||
last_triggered_at: string | null;
|
||||
last_status: number | null;
|
||||
failure_count: number;
|
||||
}
|
||||
|
||||
interface TryItResult {
|
||||
status: number;
|
||||
statusText: string;
|
||||
@@ -55,22 +42,12 @@ const METHOD_COLORS: Record<string, string> = {
|
||||
DELETE: "bg-red-500/15 text-red-500 border-red-500/30",
|
||||
};
|
||||
|
||||
const WEBHOOK_EVENTS = [
|
||||
"request.completed",
|
||||
"request.failed",
|
||||
"provider.error",
|
||||
"provider.recovered",
|
||||
"quota.exceeded",
|
||||
"combo.switched",
|
||||
];
|
||||
|
||||
/* ─── Main Component ─────────────────────────────────── */
|
||||
export default function ApiEndpointsTab() {
|
||||
const baseUrl = useDisplayBaseUrl();
|
||||
const [catalog, setCatalog] = useState<CatalogData | null>(null);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [section, setSection] = useState<"catalog" | "webhooks">("catalog");
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
@@ -81,16 +58,6 @@ export default function ApiEndpointsTab() {
|
||||
const [tryResult, setTryResult] = useState<TryItResult | null>(null);
|
||||
const [trying, setTrying] = useState(false);
|
||||
|
||||
// Webhooks state
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>([]);
|
||||
const [webhooksLoading, setWebhooksLoading] = useState(false);
|
||||
const [showAddWebhook, setShowAddWebhook] = useState(false);
|
||||
const [whUrl, setWhUrl] = useState("");
|
||||
const [whEvents, setWhEvents] = useState<string[]>(["*"]);
|
||||
const [whDesc, setWhDesc] = useState("");
|
||||
const [testingWebhookId, setTestingWebhookId] = useState<string | null>(null);
|
||||
|
||||
// Load catalog
|
||||
const loadCatalog = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/openapi/spec");
|
||||
@@ -124,39 +91,6 @@ export default function ApiEndpointsTab() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load webhooks
|
||||
const fetchWebhooksData = async (): Promise<WebhookItem[]> => {
|
||||
try {
|
||||
const res = await fetch("/api/webhooks");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return data.webhooks || [];
|
||||
}
|
||||
} catch {}
|
||||
return [];
|
||||
};
|
||||
|
||||
const loadWebhooks = async () => {
|
||||
setWebhooksLoading(true);
|
||||
const data = await fetchWebhooksData();
|
||||
setWebhooks(data);
|
||||
setWebhooksLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (section !== "webhooks") return;
|
||||
let cancelled = false;
|
||||
fetchWebhooksData().then((data) => {
|
||||
if (!cancelled) {
|
||||
setWebhooks(data);
|
||||
setWebhooksLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [section]);
|
||||
|
||||
// Filter endpoints
|
||||
const filteredEndpoints = useMemo(() => {
|
||||
if (!catalog) return [];
|
||||
@@ -226,51 +160,6 @@ export default function ApiEndpointsTab() {
|
||||
setTrying(false);
|
||||
};
|
||||
|
||||
// Webhook handlers
|
||||
const addWebhook = async () => {
|
||||
if (!whUrl.trim()) return;
|
||||
try {
|
||||
await fetch("/api/webhooks", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url: whUrl, events: whEvents, description: whDesc }),
|
||||
});
|
||||
setWhUrl("");
|
||||
setWhEvents(["*"]);
|
||||
setWhDesc("");
|
||||
setShowAddWebhook(false);
|
||||
await loadWebhooks();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const toggleWebhook = async (wh: WebhookItem) => {
|
||||
try {
|
||||
await fetch(`/api/webhooks/${wh.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !wh.enabled }),
|
||||
});
|
||||
setWebhooks((prev) => prev.map((w) => (w.id === wh.id ? { ...w, enabled: !w.enabled } : w)));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const deleteWebhook = async (id: string) => {
|
||||
if (!confirm("Delete this webhook?")) return;
|
||||
try {
|
||||
await fetch(`/api/webhooks/${id}`, { method: "DELETE" });
|
||||
setWebhooks((prev) => prev.filter((w) => w.id !== id));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const testWebhook = async (id: string) => {
|
||||
setTestingWebhookId(id);
|
||||
try {
|
||||
await fetch(`/api/webhooks/${id}/test`, { method: "POST" });
|
||||
await loadWebhooks();
|
||||
} catch {}
|
||||
setTestingWebhookId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
@@ -327,30 +216,8 @@ export default function ApiEndpointsTab() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Section tabs */}
|
||||
<div className="flex gap-1 p-1 rounded-xl bg-black/5 dark:bg-white/[0.03] w-fit">
|
||||
{[
|
||||
{ id: "catalog" as const, label: "API Catalog", icon: "menu_book" },
|
||||
{ id: "webhooks" as const, label: "Webhooks", icon: "webhook" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSection(tab.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-all
|
||||
${
|
||||
section === tab.id
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ═══ API CATALOG ═══ */}
|
||||
{section === "catalog" && !catalog && (
|
||||
{!catalog && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-red-500/10">
|
||||
@@ -376,7 +243,7 @@ export default function ApiEndpointsTab() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{section === "catalog" && catalog && (
|
||||
{catalog && (
|
||||
<>
|
||||
{/* Search & filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -647,232 +514,6 @@ export default function ApiEndpointsTab() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══ WEBHOOKS ═══ */}
|
||||
{section === "webhooks" && (
|
||||
<>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[18px]">webhook</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Event Webhooks</h3>
|
||||
<p className="text-[11px] text-text-muted">
|
||||
Receive HTTP callbacks when events occur in OmniRoute
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!showAddWebhook && (
|
||||
<button
|
||||
onClick={() => setShowAddWebhook(true)}
|
||||
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-lg
|
||||
bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
Add Webhook
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add webhook form */}
|
||||
{showAddWebhook && (
|
||||
<div className="mb-4 p-3 rounded-lg border border-primary/20 bg-primary/[0.03] space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
value={whUrl}
|
||||
onChange={(e) => setWhUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
value={whDesc}
|
||||
onChange={(e) => setWhDesc(e.target.value)}
|
||||
placeholder="Production monitoring"
|
||||
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
|
||||
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-medium text-text-muted uppercase tracking-wider">
|
||||
Events
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<button
|
||||
onClick={() => setWhEvents(["*"])}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors
|
||||
${
|
||||
whEvents.includes("*")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
>
|
||||
All events
|
||||
</button>
|
||||
{WEBHOOK_EVENTS.map((ev) => (
|
||||
<button
|
||||
key={ev}
|
||||
onClick={() => {
|
||||
if (whEvents.includes("*")) {
|
||||
setWhEvents([ev]);
|
||||
} else if (whEvents.includes(ev)) {
|
||||
setWhEvents(whEvents.filter((e) => e !== ev));
|
||||
} else {
|
||||
setWhEvents([...whEvents, ev]);
|
||||
}
|
||||
}}
|
||||
className={`px-2 py-0.5 text-[10px] font-medium rounded transition-colors
|
||||
${
|
||||
whEvents.includes(ev) || whEvents.includes("*")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "bg-black/5 dark:bg-white/5 text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{ev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={addWebhook}
|
||||
disabled={!whUrl.trim()}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg bg-primary text-white
|
||||
hover:bg-primary/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddWebhook(false)}
|
||||
className="px-3 py-1 text-xs font-medium rounded-lg
|
||||
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Webhooks list */}
|
||||
{webhooksLoading ? (
|
||||
<div className="text-xs text-text-muted py-4 text-center">Loading...</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="text-center py-6">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted">
|
||||
webhook
|
||||
</span>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
No webhooks configured. Add one to receive event notifications.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{webhooks.map((wh) => (
|
||||
<div
|
||||
key={wh.id}
|
||||
className={`flex items-center justify-between px-3 py-2.5 rounded-lg border transition-colors
|
||||
${
|
||||
wh.enabled
|
||||
? "border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/[0.02]"
|
||||
: "border-black/5 dark:border-white/5 opacity-50"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-text-main truncate">{wh.url}</code>
|
||||
{wh.failure_count > 0 && (
|
||||
<span className="text-[9px] px-1 py-0.5 rounded bg-red-500/10 text-red-500">
|
||||
{wh.failure_count} failures
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{wh.description && (
|
||||
<span className="text-[10px] text-text-muted">{wh.description}</span>
|
||||
)}
|
||||
<span className="text-[9px] text-text-muted">
|
||||
Events: {wh.events.join(", ")}
|
||||
</span>
|
||||
{wh.last_triggered_at && (
|
||||
<span className="text-[9px] text-text-muted">
|
||||
Last: {new Date(wh.last_triggered_at).toLocaleString()}
|
||||
{wh.last_status ? ` (${wh.last_status})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => testWebhook(wh.id)}
|
||||
disabled={testingWebhookId === wh.id}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title="Send test event"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${testingWebhookId === wh.id ? "animate-spin text-primary" : "text-text-muted"}`}
|
||||
>
|
||||
{testingWebhookId === wh.id ? "sync" : "send"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleWebhook(wh)}
|
||||
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
title={wh.enabled ? "Disable" : "Enable"}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${wh.enabled ? "text-emerald-500" : "text-text-muted"}`}
|
||||
>
|
||||
{wh.enabled ? "toggle_on" : "toggle_off"}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteWebhook(wh.id)}
|
||||
className="p-1 rounded hover:bg-red-500/10 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-red-500">
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Webhook signature info */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[14px] text-amber-500">vpn_key</span>
|
||||
<h3 className="text-xs font-semibold">Webhook Signatures</h3>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted mb-2">
|
||||
Each webhook delivery includes an{" "}
|
||||
<code className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5">
|
||||
X-Webhook-Signature
|
||||
</code>{" "}
|
||||
header signed with HMAC-SHA256 using the webhook secret. Verify the signature to
|
||||
ensure the payload is authentic.
|
||||
</p>
|
||||
<div className="rounded-lg bg-black/5 dark:bg-black/30 p-3">
|
||||
<code className="text-[10px] font-mono text-text-main">
|
||||
{`const crypto = require('crypto');\nconst sig = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');\nif (sig !== req.headers['x-webhook-signature']) throw new Error('Invalid signature');`}
|
||||
</code>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,424 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, SegmentedControl } from "@/shared/components";
|
||||
import EndpointPageClient from "./EndpointPageClient";
|
||||
import McpDashboardPage from "./components/MCPDashboard";
|
||||
import A2ADashboardPage from "./components/A2ADashboard";
|
||||
import ApiEndpointsTab from "./ApiEndpointsTab";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
|
||||
type ServiceStatus = {
|
||||
online: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
type McpTransport = "stdio" | "sse" | "streamable-http";
|
||||
|
||||
/* ────── Toggle Switch ────── */
|
||||
function ServiceToggle({
|
||||
label,
|
||||
status,
|
||||
enabled,
|
||||
onToggle,
|
||||
toggling,
|
||||
}: {
|
||||
label: string;
|
||||
status: ServiceStatus;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
toggling: boolean;
|
||||
}) {
|
||||
const online = enabled && status.online;
|
||||
const loading = enabled && status.loading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 ml-auto">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border"
|
||||
style={{
|
||||
borderColor: loading
|
||||
? "var(--color-border)"
|
||||
: online
|
||||
? "rgba(34,197,94,0.3)"
|
||||
: "rgba(239,68,68,0.3)",
|
||||
background: loading
|
||||
? "transparent"
|
||||
: online
|
||||
? "rgba(34,197,94,0.1)"
|
||||
: "rgba(239,68,68,0.1)",
|
||||
color: loading ? "var(--color-text-muted)" : online ? "rgb(34,197,94)" : "rgb(239,68,68)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background: loading
|
||||
? "var(--color-text-muted)"
|
||||
: online
|
||||
? "rgb(34,197,94)"
|
||||
: "rgb(239,68,68)",
|
||||
animation: online ? "pulse 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
{loading ? "..." : online ? "Online" : "Offline"}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={toggling}
|
||||
className="relative inline-flex items-center h-7 w-[52px] rounded-full transition-all duration-300 focus:outline-none border"
|
||||
style={{
|
||||
background: enabled ? "rgb(34,197,94)" : "var(--color-bg-tertiary)",
|
||||
borderColor: enabled ? "rgba(34,197,94,0.5)" : "var(--color-border)",
|
||||
opacity: toggling ? 0.6 : 1,
|
||||
cursor: toggling ? "wait" : "pointer",
|
||||
}}
|
||||
title={enabled ? `Disable ${label}` : `Enable ${label}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
|
||||
style={{
|
||||
transform: enabled ? "translateX(26px)" : "translateX(3px)",
|
||||
background: enabled ? "#fff" : "var(--color-text-muted)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="text-xs font-medium min-w-[24px]"
|
||||
style={{ color: enabled ? "rgb(34,197,94)" : "var(--color-text-muted)" }}
|
||||
>
|
||||
{toggling ? "..." : enabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledServicePanel({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
style={{ background: "var(--color-bg-tertiary)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative block size-5 rounded-full border-2"
|
||||
style={{ borderColor: "var(--color-text-muted)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-1/2 top-[-3px] h-3 w-0.5 -translate-x-1/2 rounded-full"
|
||||
style={{ background: "var(--color-text-muted)" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────── Transport Selector ────── */
|
||||
function TransportSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
baseUrl,
|
||||
}: {
|
||||
value: McpTransport;
|
||||
onChange: (t: McpTransport) => void;
|
||||
disabled: boolean;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const options: { value: McpTransport; label: string; desc: string }[] = [
|
||||
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
|
||||
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
|
||||
{
|
||||
value: "streamable-http",
|
||||
label: "Streamable HTTP",
|
||||
desc: "Remote — Modern bidirectional HTTP",
|
||||
},
|
||||
];
|
||||
|
||||
const urlMap: Record<McpTransport, string> = {
|
||||
stdio: "omniroute --mcp",
|
||||
sse: `${baseUrl}/api/mcp/sse`,
|
||||
"streamable-http": `${baseUrl}/api/mcp/stream`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-4 mt-3"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg-secondary)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
className="material-symbols-rounded text-base"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
swap_horiz
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
Transport Mode
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
disabled={disabled}
|
||||
className="flex flex-col items-start px-4 py-2.5 rounded-lg border transition-all duration-200 text-left"
|
||||
style={{
|
||||
borderColor: value === opt.value ? "var(--color-primary)" : "var(--color-border)",
|
||||
background:
|
||||
value === opt.value
|
||||
? "rgba(var(--color-primary-rgb, 99,102,241), 0.1)"
|
||||
: "transparent",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
cursor: disabled ? "wait" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
color: value === opt.value ? "var(--color-primary)" : "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Connection info */}
|
||||
<div
|
||||
className="mt-3 rounded-md px-3 py-2 flex items-center gap-2"
|
||||
style={{ background: "var(--color-bg-tertiary)" }}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-rounded text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{value === "stdio" ? "terminal" : "link"}
|
||||
</span>
|
||||
<code className="text-xs break-all" style={{ color: "var(--color-text-muted)" }}>
|
||||
{urlMap[value]}
|
||||
</code>
|
||||
{value !== "stdio" && (
|
||||
<button
|
||||
className="ml-auto text-xs px-2 py-0.5 rounded border hover:opacity-80 transition-opacity"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
onClick={() => void copyToClipboard(urlMap[value])}
|
||||
title="Copy URL"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ────── Main Page ────── */
|
||||
export default function EndpointPage() {
|
||||
const [activeTab, setActiveTab] = useState("endpoint-proxy");
|
||||
const t = useTranslations("endpoints");
|
||||
|
||||
const [mcpStatus, setMcpStatus] = useState<ServiceStatus>({ online: false, loading: true });
|
||||
const [a2aStatus, setA2aStatus] = useState<ServiceStatus>({ online: false, loading: true });
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
const [a2aEnabled, setA2aEnabled] = useState(false);
|
||||
const [mcpToggling, setMcpToggling] = useState(false);
|
||||
const [a2aToggling, setA2aToggling] = useState(false);
|
||||
const [mcpTransport, setMcpTransport] = useState<McpTransport>("stdio");
|
||||
const [transportSaving, setTransportSaving] = useState(false);
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
// Detect base URL from browser
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseUrl(`${window.location.protocol}//${window.location.host}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch initial settings
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMcpEnabled(!!data.mcpEnabled);
|
||||
setA2aEnabled(!!data.a2aEnabled);
|
||||
setMcpTransport((data.mcpTransport as McpTransport) || "stdio");
|
||||
}
|
||||
} catch {
|
||||
// defaults stay
|
||||
}
|
||||
};
|
||||
void fetchSettings();
|
||||
}, []);
|
||||
|
||||
const patchSetting = useCallback(async (body: Record<string, unknown>) => {
|
||||
return fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleService = useCallback(
|
||||
async (service: "mcp" | "a2a") => {
|
||||
const setToggling = service === "mcp" ? setMcpToggling : setA2aToggling;
|
||||
const setEnabled = service === "mcp" ? setMcpEnabled : setA2aEnabled;
|
||||
const currentlyEnabled = service === "mcp" ? mcpEnabled : a2aEnabled;
|
||||
const newValue = !currentlyEnabled;
|
||||
|
||||
setToggling(true);
|
||||
try {
|
||||
const res = await patchSetting({
|
||||
[service === "mcp" ? "mcpEnabled" : "a2aEnabled"]: newValue,
|
||||
});
|
||||
if (res.ok) setEnabled(newValue);
|
||||
} catch {
|
||||
// keep current state
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
},
|
||||
[mcpEnabled, a2aEnabled, patchSetting]
|
||||
);
|
||||
|
||||
const changeTransport = useCallback(
|
||||
async (newTransport: McpTransport) => {
|
||||
setTransportSaving(true);
|
||||
try {
|
||||
const res = await patchSetting({ mcpTransport: newTransport });
|
||||
if (res.ok) setMcpTransport(newTransport);
|
||||
} catch {
|
||||
// keep current
|
||||
} finally {
|
||||
setTransportSaving(false);
|
||||
}
|
||||
},
|
||||
[patchSetting]
|
||||
);
|
||||
|
||||
const refreshMcpStatus = useCallback(async () => {
|
||||
setMcpStatus((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const res = await fetch("/api/mcp/status");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMcpStatus({ online: !!data.online, loading: false });
|
||||
} else {
|
||||
setMcpStatus({ online: false, loading: false });
|
||||
}
|
||||
} catch {
|
||||
setMcpStatus({ online: false, loading: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshA2aStatus = useCallback(async () => {
|
||||
setA2aStatus((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const res = await fetch("/api/a2a/status");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setA2aStatus({ online: data.status === "ok", loading: false });
|
||||
} else {
|
||||
setA2aStatus({ online: false, loading: false });
|
||||
}
|
||||
} catch {
|
||||
setA2aStatus({ online: false, loading: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
void refreshMcpStatus();
|
||||
void refreshA2aStatus();
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshMcpStatus, refreshA2aStatus]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "endpoint-proxy", label: t("tabProxy"), icon: "api" },
|
||||
{ value: "mcp", label: "MCP", icon: "hub" },
|
||||
{ value: "a2a", label: "A2A", icon: "group_work" },
|
||||
{ value: "api-endpoints", label: t("tabApiEndpoints"), icon: "code" },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
{activeTab === "mcp" && (
|
||||
<ServiceToggle
|
||||
label="MCP"
|
||||
status={mcpStatus}
|
||||
enabled={mcpEnabled}
|
||||
onToggle={() => void toggleService("mcp")}
|
||||
toggling={mcpToggling}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "a2a" && (
|
||||
<ServiceToggle
|
||||
label="A2A"
|
||||
status={a2aStatus}
|
||||
enabled={a2aEnabled}
|
||||
onToggle={() => void toggleService("a2a")}
|
||||
toggling={a2aToggling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transport selector for MCP */}
|
||||
{activeTab === "mcp" && mcpEnabled && (
|
||||
<TransportSelector
|
||||
value={mcpTransport}
|
||||
onChange={(t) => void changeTransport(t)}
|
||||
disabled={transportSaving}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "endpoint-proxy" && <EndpointPageClient machineId="" />}
|
||||
{activeTab === "mcp" && <McpDashboardPage />}
|
||||
{activeTab === "a2a" &&
|
||||
(a2aEnabled ? (
|
||||
<A2ADashboardPage />
|
||||
) : (
|
||||
<DisabledServicePanel
|
||||
title="A2A is disabled"
|
||||
description="Enable A2A above to view task telemetry, agent details, and validation tools."
|
||||
/>
|
||||
))}
|
||||
{activeTab === "api-endpoints" && <ApiEndpointsTab />}
|
||||
</div>
|
||||
);
|
||||
return <EndpointPageClient machineId="" />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { copyToClipboard } from "@/shared/utils/clipboard";
|
||||
import McpDashboardPage from "../endpoint/components/MCPDashboard";
|
||||
|
||||
type ServiceStatus = { online: boolean; loading: boolean };
|
||||
type McpTransport = "stdio" | "sse" | "streamable-http";
|
||||
|
||||
function ServiceToggle({
|
||||
label,
|
||||
status,
|
||||
enabled,
|
||||
onToggle,
|
||||
toggling,
|
||||
}: {
|
||||
label: string;
|
||||
status: ServiceStatus;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
toggling: boolean;
|
||||
}) {
|
||||
const online = enabled && status.online;
|
||||
const loading = enabled && status.loading;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border"
|
||||
style={{
|
||||
borderColor: loading
|
||||
? "var(--color-border)"
|
||||
: online
|
||||
? "rgba(34,197,94,0.3)"
|
||||
: "rgba(239,68,68,0.3)",
|
||||
background: loading
|
||||
? "transparent"
|
||||
: online
|
||||
? "rgba(34,197,94,0.1)"
|
||||
: "rgba(239,68,68,0.1)",
|
||||
color: loading ? "var(--color-text-muted)" : online ? "rgb(34,197,94)" : "rgb(239,68,68)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{
|
||||
background: loading
|
||||
? "var(--color-text-muted)"
|
||||
: online
|
||||
? "rgb(34,197,94)"
|
||||
: "rgb(239,68,68)",
|
||||
animation: online ? "pulse 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
{loading ? "..." : online ? "Online" : "Offline"}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
disabled={toggling}
|
||||
className="relative inline-flex items-center h-7 w-[52px] rounded-full transition-all duration-300 focus:outline-none border"
|
||||
style={{
|
||||
background: enabled ? "rgb(34,197,94)" : "var(--color-bg-tertiary)",
|
||||
borderColor: enabled ? "rgba(34,197,94,0.5)" : "var(--color-border)",
|
||||
opacity: toggling ? 0.6 : 1,
|
||||
cursor: toggling ? "wait" : "pointer",
|
||||
}}
|
||||
title={enabled ? `Disable ${label}` : `Enable ${label}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
|
||||
style={{
|
||||
transform: enabled ? "translateX(26px)" : "translateX(3px)",
|
||||
background: enabled ? "#fff" : "var(--color-text-muted)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<span
|
||||
className="text-xs font-medium min-w-[24px]"
|
||||
style={{ color: enabled ? "rgb(34,197,94)" : "var(--color-text-muted)" }}
|
||||
>
|
||||
{toggling ? "..." : enabled ? "ON" : "OFF"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TransportSelector({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
baseUrl,
|
||||
}: {
|
||||
value: McpTransport;
|
||||
onChange: (t: McpTransport) => void;
|
||||
disabled: boolean;
|
||||
baseUrl: string;
|
||||
}) {
|
||||
const options: { value: McpTransport; label: string; desc: string }[] = [
|
||||
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
|
||||
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
|
||||
{
|
||||
value: "streamable-http",
|
||||
label: "Streamable HTTP",
|
||||
desc: "Remote — Modern bidirectional HTTP",
|
||||
},
|
||||
];
|
||||
|
||||
const urlMap: Record<McpTransport, string> = {
|
||||
stdio: "omniroute --mcp",
|
||||
sse: `${baseUrl}/api/mcp/sse`,
|
||||
"streamable-http": `${baseUrl}/api/mcp/stream`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border p-4"
|
||||
style={{ borderColor: "var(--color-border)", background: "var(--color-bg-secondary)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span
|
||||
className="material-symbols-rounded text-base"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
swap_horiz
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
Transport Mode
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
disabled={disabled}
|
||||
className="flex flex-col items-start px-4 py-2.5 rounded-lg border transition-all duration-200 text-left"
|
||||
style={{
|
||||
borderColor: value === opt.value ? "var(--color-primary)" : "var(--color-border)",
|
||||
background:
|
||||
value === opt.value
|
||||
? "rgba(var(--color-primary-rgb, 99,102,241), 0.1)"
|
||||
: "transparent",
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
cursor: disabled ? "wait" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
color: value === opt.value ? "var(--color-primary)" : "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="text-xs mt-0.5" style={{ color: "var(--color-text-muted)" }}>
|
||||
{opt.desc}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-3 rounded-md px-3 py-2 flex items-center gap-2"
|
||||
style={{ background: "var(--color-bg-tertiary)" }}
|
||||
>
|
||||
<span
|
||||
className="material-symbols-rounded text-sm"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{value === "stdio" ? "terminal" : "link"}
|
||||
</span>
|
||||
<code className="text-xs break-all" style={{ color: "var(--color-text-muted)" }}>
|
||||
{urlMap[value]}
|
||||
</code>
|
||||
{value !== "stdio" && (
|
||||
<button
|
||||
className="ml-auto text-xs px-2 py-0.5 rounded border hover:opacity-80 transition-opacity"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
onClick={() => void copyToClipboard(urlMap[value])}
|
||||
title="Copy URL"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledPanel() {
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
|
||||
style={{ background: "var(--color-bg-tertiary)" }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="relative block size-5 rounded-full border-2"
|
||||
style={{ borderColor: "var(--color-text-muted)" }}
|
||||
>
|
||||
<span
|
||||
className="absolute left-1/2 top-[-3px] h-3 w-0.5 -translate-x-1/2 rounded-full"
|
||||
style={{ background: "var(--color-text-muted)" }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
MCP is disabled
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Enable MCP above to configure transport mode and view server telemetry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function McpPage() {
|
||||
return <McpDashboardPage />;
|
||||
const [mcpStatus, setMcpStatus] = useState<ServiceStatus>({ online: false, loading: true });
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
const [mcpToggling, setMcpToggling] = useState(false);
|
||||
const [mcpTransport, setMcpTransport] = useState<McpTransport>("stdio");
|
||||
const [transportSaving, setTransportSaving] = useState(false);
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setBaseUrl(`${window.location.protocol}//${window.location.host}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const patchSetting = useCallback(async (body: Record<string, unknown>) => {
|
||||
return fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMcpEnabled(!!data.mcpEnabled);
|
||||
setMcpTransport((data.mcpTransport as McpTransport) || "stdio");
|
||||
}
|
||||
} catch {
|
||||
// defaults stay
|
||||
}
|
||||
};
|
||||
void fetchSettings();
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
setMcpStatus((prev) => ({ ...prev, loading: true }));
|
||||
try {
|
||||
const res = await fetch("/api/mcp/status");
|
||||
setMcpStatus({ online: res.ok ? !!(await res.json()).online : false, loading: false });
|
||||
} catch {
|
||||
setMcpStatus({ online: false, loading: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus();
|
||||
const interval = setInterval(() => void refreshStatus(), 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshStatus]);
|
||||
|
||||
const toggleMcp = useCallback(async () => {
|
||||
const newValue = !mcpEnabled;
|
||||
setMcpToggling(true);
|
||||
try {
|
||||
const res = await patchSetting({ mcpEnabled: newValue });
|
||||
if (res.ok) setMcpEnabled(newValue);
|
||||
} catch {
|
||||
// keep current
|
||||
} finally {
|
||||
setMcpToggling(false);
|
||||
}
|
||||
}, [mcpEnabled, patchSetting]);
|
||||
|
||||
const changeTransport = useCallback(
|
||||
async (newTransport: McpTransport) => {
|
||||
setTransportSaving(true);
|
||||
try {
|
||||
const res = await patchSetting({ mcpTransport: newTransport });
|
||||
if (res.ok) setMcpTransport(newTransport);
|
||||
} catch {
|
||||
// keep current
|
||||
} finally {
|
||||
setTransportSaving(false);
|
||||
}
|
||||
},
|
||||
[patchSetting]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex justify-end">
|
||||
<ServiceToggle
|
||||
label="MCP"
|
||||
status={mcpStatus}
|
||||
enabled={mcpEnabled}
|
||||
onToggle={() => void toggleMcp()}
|
||||
toggling={mcpToggling}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mcpEnabled && (
|
||||
<TransportSelector
|
||||
value={mcpTransport}
|
||||
onChange={(t) => void changeTransport(t)}
|
||||
disabled={transportSaving}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mcpEnabled ? <McpDashboardPage /> : <DisabledPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user