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:
diegosouzapw
2026-05-16 00:37:03 -03:00
parent cfc6be6a12
commit dc6e7b00d0
4 changed files with 516 additions and 783 deletions

View File

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

View File

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

View File

@@ -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="" />;
}

View File

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