feat: Introduce new A2A and MCP API routes, enhance dashboard UI, update READMEs, and add E2E tests.

This commit is contained in:
diegosouzapw
2026-03-05 11:16:56 -03:00
parent c38a58fc98
commit 21135407af
58 changed files with 17980 additions and 8998 deletions

View File

@@ -1,118 +1,555 @@
/**
* Dashboard A2A Panel — /dashboard/a2a
*
* Shows Agent Card, active/completed tasks, and routing metadata.
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
export default function A2ADashboard() {
const [agentCard, setAgentCard] = useState<any>(null);
const [tasks, setTasks] = useState<any[]>([]);
type A2ATaskState = "submitted" | "working" | "completed" | "failed" | "cancelled";
const fetchData = useCallback(async () => {
try {
const [cardRes, tasksRes] = await Promise.allSettled([
fetch("/.well-known/agent.json"),
fetch("/api/a2a/tasks"),
]);
if (cardRes.status === "fulfilled") setAgentCard(await cardRes.value.json());
if (tasksRes.status === "fulfilled") {
const data = await tasksRes.value.json();
setTasks(Array.isArray(data) ? data : data.tasks || []);
}
} catch {
/* ignore */
}
type A2AStatus = {
status: "ok";
tasks: {
counts: Record<A2ATaskState, number>;
total: number;
activeStreams: number;
lastTaskAt: string | null;
};
agent: {
name: string;
description: string;
version: string;
url: string;
} | null;
capabilities: Record<string, unknown> | null;
skills: Array<{
id: string;
name: string;
description: string;
tags?: string[];
}>;
};
type TaskArtifact = {
type: "text" | "json" | "error";
content: string;
};
type TaskEvent = {
timestamp: string;
state: A2ATaskState;
message?: string;
};
type A2ATask = {
id: string;
skill: string;
state: A2ATaskState;
input: {
skill: string;
messages: Array<{ role: string; content: string }>;
metadata?: Record<string, unknown>;
};
artifacts: TaskArtifact[];
events: TaskEvent[];
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
expiresAt: string;
};
type TaskListResponse = {
tasks: A2ATask[];
total: number;
limit: number;
offset: number;
};
const PAGE_SIZE = 20;
const TASK_STATES: Array<"all" | A2ATaskState> = [
"all",
"submitted",
"working",
"completed",
"failed",
"cancelled",
];
function stateClass(state: A2ATaskState) {
if (state === "completed") return "bg-green-500/15 text-green-500";
if (state === "failed") return "bg-red-500/15 text-red-500";
if (state === "working") return "bg-amber-500/15 text-amber-500";
if (state === "cancelled") return "bg-gray-500/15 text-gray-400";
return "bg-blue-500/15 text-blue-500";
}
export default function A2ADashboardPage() {
const t = useTranslations("a2aDashboard");
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<A2AStatus | null>(null);
const [stateFilter, setStateFilter] = useState<"all" | A2ATaskState>("all");
const [skillFilter, setSkillFilter] = useState("");
const [offset, setOffset] = useState(0);
const [tasksData, setTasksData] = useState<TaskListResponse>({
tasks: [],
total: 0,
limit: PAGE_SIZE,
offset: 0,
});
const [tasksLoading, setTasksLoading] = useState(false);
const [selectedTask, setSelectedTask] = useState<A2ATask | null>(null);
const [actionMessage, setActionMessage] = useState("");
const [actionBusy, setActionBusy] = useState<null | "cancel" | "send" | "stream">(null);
const refreshStatus = useCallback(async () => {
const response = await fetch("/api/a2a/status");
if (!response.ok) return;
const json = await response.json();
setStatus(json);
}, []);
const refreshTasks = useCallback(async () => {
setTasksLoading(true);
try {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
params.set("offset", String(offset));
if (stateFilter !== "all") params.set("state", stateFilter);
if (skillFilter) params.set("skill", skillFilter);
const response = await fetch(`/api/a2a/tasks?${params.toString()}`);
if (!response.ok) return;
const json = await response.json();
setTasksData({
tasks: Array.isArray(json.tasks) ? json.tasks : [],
total: Number(json.total || 0),
limit: Number(json.limit || PAGE_SIZE),
offset: Number(json.offset || 0),
});
} finally {
setTasksLoading(false);
}
}, [offset, stateFilter, skillFilter]);
useEffect(() => {
const id = setTimeout(fetchData, 0);
const interval = setInterval(fetchData, 30_000);
return () => {
clearTimeout(id);
clearInterval(interval);
};
}, [fetchData]);
Promise.allSettled([refreshStatus(), refreshTasks()]).finally(() => setLoading(false));
const interval = setInterval(() => {
void refreshStatus();
void refreshTasks();
}, 30000);
return () => clearInterval(interval);
}, [refreshStatus, refreshTasks]);
useEffect(() => {
void refreshTasks();
}, [refreshTasks]);
const availableSkills = useMemo(() => {
const values = new Set<string>();
for (const skill of status?.skills || []) values.add(skill.id);
for (const task of tasksData.tasks) values.add(task.skill);
return Array.from(values.values()).sort();
}, [status, tasksData.tasks]);
const currentPage = Math.floor(tasksData.offset / PAGE_SIZE) + 1;
const totalPages = Math.max(1, Math.ceil(tasksData.total / PAGE_SIZE));
const handleLoadTask = async (taskId: string) => {
const response = await fetch(`/api/a2a/tasks/${encodeURIComponent(taskId)}`);
if (!response.ok) return;
const json = await response.json();
setSelectedTask(json.task || null);
};
const handleCancelTask = async (taskId: string) => {
if (!globalThis.confirm(t("confirmCancelTask", { taskId }))) return;
setActionBusy("cancel");
setActionMessage("");
try {
const response = await fetch(`/api/a2a/tasks/${encodeURIComponent(taskId)}/cancel`, {
method: "POST",
});
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("cancelTaskFailed"));
return;
}
setActionMessage(t("cancelTaskSuccess", { taskId }));
await refreshStatus();
await refreshTasks();
if (selectedTask?.id === taskId) {
await handleLoadTask(taskId);
}
} finally {
setActionBusy(null);
}
};
const handleSmokeSend = async () => {
setActionBusy("send");
setActionMessage("");
try {
const response = await fetch("/a2a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: "dashboard-send",
method: "message/send",
params: {
skill: "quota-management",
messages: [{ role: "user", content: "Show a short quota summary." }],
},
}),
});
const json = await response.json().catch(() => ({}));
if (!response.ok || json?.error) {
setActionMessage(json?.error?.message || t("smokeSendFailed"));
return;
}
const taskId = json?.result?.task?.id;
setActionMessage(taskId ? t("smokeSendSuccessWithTask", { taskId }) : t("smokeSendSuccess"));
await refreshStatus();
await refreshTasks();
} finally {
setActionBusy(null);
}
};
const handleSmokeStream = async () => {
setActionBusy("stream");
setActionMessage("");
try {
const response = await fetch("/a2a", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: "dashboard-stream",
method: "message/stream",
params: {
skill: "quota-management",
messages: [{ role: "user", content: "Stream a short quota summary." }],
},
}),
});
if (!response.ok || !response.body) {
const text = await response.text().catch(() => "");
setActionMessage(text || t("smokeStreamFailed"));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let streamTaskId: string | null = null;
let terminalState: string | null = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
const payload = part.slice("data: ".length);
let parsed: any;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
const nextTaskId = parsed?.params?.task?.id;
const nextState = parsed?.params?.task?.state;
if (nextTaskId) streamTaskId = nextTaskId;
if (typeof nextState === "string") {
if (["completed", "failed", "cancelled"].includes(nextState)) {
terminalState = nextState;
}
}
}
}
if (streamTaskId) {
setActionMessage(
t("smokeStreamSuccessWithTask", {
taskId: streamTaskId,
stateSuffix: terminalState ? `, ${t(`state.${terminalState as A2ATaskState}`)}` : "",
})
);
} else {
setActionMessage(t("smokeStreamNoTaskId"));
}
await refreshStatus();
await refreshTasks();
} finally {
setActionBusy(null);
}
};
if (loading) {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="text-sm text-text-muted">{t("loading")}</div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-2xl font-bold mb-6">🤖 A2A Server Dashboard</h1>
<div className="p-6 max-w-7xl mx-auto space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard label={t("health")} value={status?.status === "ok" ? t("ok") : "—"} />
<StatCard label={t("totalTasks")} value={status?.tasks?.total || 0} />
<StatCard label={t("activeStreams")} value={status?.tasks?.activeStreams || 0} />
<StatCard
label={t("lastTask")}
value={
status?.tasks?.lastTaskAt ? new Date(status.tasks.lastTaskAt).toLocaleTimeString() : "—"
}
/>
</div>
{/* Agent Card */}
{agentCard && (
<div className="mb-8 p-4 bg-white dark:bg-gray-800 rounded-lg shadow border">
<h2 className="text-lg font-semibold mb-2">{agentCard.name}</h2>
<p className="text-sm text-gray-500 mb-3">{agentCard.description}</p>
<div className="flex gap-2 mb-3">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 rounded text-xs">
v{agentCard.version}
</span>
{agentCard.capabilities?.streaming && (
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/30 rounded text-xs">
Streaming
</span>
)}
</div>
<h3 className="font-medium text-sm mb-2">Skills ({agentCard.skills?.length || 0})</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{agentCard.skills?.map((s: any) => (
<div key={s.id} className="p-2 bg-gray-50 dark:bg-gray-700 rounded text-sm">
<span className="font-medium">{s.name}</span>
<p className="text-xs text-gray-500 mt-1">{s.description?.slice(0, 100)}</p>
<div className="flex gap-1 mt-1">
{s.tags?.slice(0, 4).map((t: string) => (
<span key={t} className="px-1 bg-gray-200 dark:bg-gray-600 rounded text-xs">
{t}
</span>
))}
</div>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("taskStateOverview")}</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{(["submitted", "working", "completed", "failed", "cancelled"] as A2ATaskState[]).map(
(state) => (
<div key={state} className="rounded-lg border border-border p-3 bg-bg">
<p className="text-xs text-text-muted uppercase">{t(`state.${state}`)}</p>
<p className="text-2xl font-semibold mt-1">{status?.tasks?.counts?.[state] || 0}</p>
</div>
))}
)
)}
</div>
</Card>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="p-5">
<h2 className="text-lg font-semibold mb-3">{t("agentCard")}</h2>
{status?.agent ? (
<div className="space-y-2 text-sm">
<p className="font-semibold">{status.agent.name}</p>
<p className="text-text-muted">{status.agent.description}</p>
<p>
{t("version")}: <span className="font-mono">{status.agent.version}</span>
</p>
<p>
{t("url")}: <span className="font-mono text-xs break-all">{status.agent.url}</span>
</p>
<div className="pt-2">
<p className="text-xs uppercase text-text-muted mb-1">{t("capabilities")}</p>
<code className="text-xs break-all">
{JSON.stringify(status.capabilities || {}, null, 2)}
</code>
</div>
</div>
) : (
<p className="text-sm text-text-muted">{t("agentCardNotAvailable")}</p>
)}
</Card>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-3">{t("quickValidation")}</h2>
<p className="text-sm text-text-muted mb-3">{t("quickValidationDescription")}</p>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="secondary"
onClick={handleSmokeSend}
disabled={actionBusy !== null}
>
{t("runMessageSend")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={handleSmokeStream}
disabled={actionBusy !== null}
>
{t("runMessageStream")}
</Button>
</div>
{actionMessage && <p className="text-sm text-text-muted mt-3">{actionMessage}</p>}
</Card>
</div>
<Card className="p-5">
<div className="flex flex-wrap items-end justify-between gap-3 mb-4">
<div>
<h2 className="text-lg font-semibold">{t("taskManagement")}</h2>
<p className="text-sm text-text-muted">
{t("taskSummary", { total: tasksData.total, page: currentPage, totalPages })}
</p>
</div>
<div className="flex flex-wrap gap-2">
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={stateFilter}
onChange={(event) => {
setOffset(0);
setStateFilter(event.target.value as "all" | A2ATaskState);
}}
>
{TASK_STATES.map((state) => (
<option key={state} value={state}>
{state === "all" ? t("allStates") : t(`state.${state}`)}
</option>
))}
</select>
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={skillFilter}
onChange={(event) => {
setOffset(0);
setSkillFilter(event.target.value);
}}
>
<option value="">{t("allSkills")}</option>
{availableSkills.map((skill) => (
<option key={skill} value={skill}>
{skill}
</option>
))}
</select>
</div>
</div>
)}
{/* Tasks */}
<div>
<h2 className="text-lg font-semibold mb-3">📋 Task History</h2>
{tasks.length === 0 ? (
<p className="text-gray-500">
No A2A tasks yet. Send a request to <code>/a2a</code> to get started.
</p>
{tasksLoading ? (
<p className="text-sm text-text-muted">{t("loadingTasks")}</p>
) : tasksData.tasks.length === 0 ? (
<p className="text-sm text-text-muted">{t("noTasksForFilters")}</p>
) : (
<div className="space-y-2">
{tasks.map((task: any) => (
<div key={task.id} className="p-3 bg-white dark:bg-gray-800 rounded border">
<div className="flex justify-between items-center">
<span className="font-mono text-xs">{task.id}</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
task.state === "completed"
? "bg-green-100 text-green-700"
: task.state === "failed"
? "bg-red-100 text-red-700"
: task.state === "working"
? "bg-yellow-100 text-yellow-700"
: "bg-gray-100 text-gray-700"
}`}
>
{task.state}
</span>
</div>
<p className="text-sm mt-1">
Skill: <strong>{task.skill}</strong>
</p>
{task.metadata?.routing_explanation && (
<p className="text-xs text-gray-500 mt-1">{task.metadata.routing_explanation}</p>
)}
</div>
))}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2">{t("tableTask")}</th>
<th className="text-left py-2 pr-2">{t("tableSkill")}</th>
<th className="text-left py-2 pr-2">{t("tableState")}</th>
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
<th className="text-left py-2">{t("tableActions")}</th>
</tr>
</thead>
<tbody>
{tasksData.tasks.map((task) => (
<tr key={task.id} className="border-b border-border/40">
<td className="py-2 pr-2 font-mono text-xs">{task.id}</td>
<td className="py-2 pr-2">{task.skill}</td>
<td className="py-2 pr-2">
<span className={`text-xs px-2 py-1 rounded-full ${stateClass(task.state)}`}>
{t(`state.${task.state}`)}
</span>
</td>
<td className="py-2 pr-2 text-xs">
{new Date(task.updatedAt).toLocaleString()}
</td>
<td className="py-2 flex gap-2">
<Button size="sm" variant="secondary" onClick={() => handleLoadTask(task.id)}>
{t("view")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleCancelTask(task.id)}
disabled={
task.state === "completed" ||
task.state === "failed" ||
task.state === "cancelled" ||
actionBusy === "cancel"
}
>
{t("cancel")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="flex justify-end gap-2 mt-4">
<Button
size="sm"
variant="secondary"
disabled={offset === 0}
onClick={() => setOffset((current) => Math.max(0, current - PAGE_SIZE))}
>
{t("previous")}
</Button>
<Button
size="sm"
variant="secondary"
disabled={offset + PAGE_SIZE >= tasksData.total}
onClick={() =>
setOffset((current) =>
current + PAGE_SIZE < tasksData.total ? current + PAGE_SIZE : current
)
}
>
{t("next")}
</Button>
</div>
</Card>
{selectedTask && (
<Card className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">{t("taskDetail")}</h2>
<Button size="sm" variant="secondary" onClick={() => setSelectedTask(null)}>
{t("close")}
</Button>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div className="rounded-lg border border-border p-3 bg-bg">
<p className="text-xs uppercase text-text-muted mb-2">{t("metadata")}</p>
<code className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(
{
id: selectedTask.id,
skill: selectedTask.skill,
state: selectedTask.state,
createdAt: selectedTask.createdAt,
updatedAt: selectedTask.updatedAt,
expiresAt: selectedTask.expiresAt,
metadata: selectedTask.metadata,
},
null,
2
)}
</code>
</div>
<div className="rounded-lg border border-border p-3 bg-bg">
<p className="text-xs uppercase text-text-muted mb-2">{t("events")}</p>
<code className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(selectedTask.events, null, 2)}
</code>
</div>
</div>
<div className="rounded-lg border border-border p-3 bg-bg mt-4">
<p className="text-xs uppercase text-text-muted mb-2">{t("artifacts")}</p>
<code className="text-xs break-all whitespace-pre-wrap">
{JSON.stringify(selectedTask.artifacts, null, 2)}
</code>
</div>
</Card>
)}
</div>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-border bg-bg p-4">
<p className="text-xs text-text-muted uppercase tracking-wide">{label}</p>
<p className="text-xl font-semibold mt-1">{value}</p>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import {
ProxyConfigModal,
EmptyState,
} from "@/shared/components";
import Tooltip from "@/shared/components/Tooltip";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
@@ -19,6 +20,36 @@ import { useTranslations } from "next-intl";
// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
const STRATEGY_OPTIONS = [
{ value: "priority", labelKey: "priority", descKey: "priorityDesc", icon: "sort" },
{ value: "weighted", labelKey: "weighted", descKey: "weightedDesc", icon: "percent" },
{ value: "round-robin", labelKey: "roundRobin", descKey: "roundRobinDesc", icon: "autorenew" },
{ value: "random", labelKey: "random", descKey: "randomDesc", icon: "shuffle" },
{ value: "least-used", labelKey: "leastUsed", descKey: "leastUsedDesc", icon: "low_priority" },
{ value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" },
];
function getStrategyMeta(strategy) {
return STRATEGY_OPTIONS.find((s) => s.value === strategy) || STRATEGY_OPTIONS[0];
}
function getStrategyLabel(t, strategy) {
return t(getStrategyMeta(strategy).labelKey);
}
function getStrategyDescription(t, strategy) {
return t(getStrategyMeta(strategy).descKey);
}
function getStrategyBadgeClass(strategy) {
if (strategy === "weighted") return "bg-amber-500/15 text-amber-600 dark:text-amber-400";
if (strategy === "round-robin") return "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400";
if (strategy === "random") return "bg-purple-500/15 text-purple-600 dark:text-purple-400";
if (strategy === "least-used") return "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400";
if (strategy === "cost-optimized") return "bg-teal-500/15 text-teal-600 dark:text-teal-400";
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
}
// ─────────────────────────────────────────────
// Helper: normalize model entry (legacy string ↔ new object)
// ─────────────────────────────────────────────
@@ -219,6 +250,8 @@ export default function CombosPage() {
</Button>
</div>
<ComboUsageGuide />
{/* Combos List */}
{combos.length === 0 ? (
<EmptyState
@@ -299,6 +332,49 @@ export default function CombosPage() {
);
}
function ComboUsageGuide() {
const t = useTranslations("combos");
const guideStrategies = ["priority", "cost-optimized", "least-used"];
return (
<Card padding="sm">
<div className="flex items-center gap-2">
<div className="size-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[16px]">
tips_and_updates
</span>
</div>
<div className="min-w-0">
<h2 className="text-sm font-semibold">{t("routingStrategy")}</h2>
<p className="text-xs text-text-muted mt-0.5">{t("description")}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-3">
{guideStrategies.map((strategyValue) => {
const strategyMeta = getStrategyMeta(strategyValue);
return (
<div
key={strategyValue}
className="rounded-lg border border-black/10 dark:border-white/10 bg-black/[0.02] dark:bg-white/[0.02] p-2.5"
>
<div className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[14px] text-primary">
{strategyMeta.icon}
</span>
<span className="text-xs font-medium">{getStrategyLabel(t, strategyValue)}</span>
</div>
<p className="text-[11px] leading-4 text-text-muted mt-1.5">
{getStrategyDescription(t, strategyValue)}
</p>
</div>
);
})}
</div>
</Card>
);
}
// ─────────────────────────────────────────────
// Combo Card
// ─────────────────────────────────────────────
@@ -322,6 +398,7 @@ function ComboCard({
const isDisabled = combo.isActive === false;
const t = useTranslations("combos");
const tc = useTranslations("common");
const strategyDescription = getStrategyDescription(t, strategy);
// Resolve provider UUID to user-defined name
const formatModelDisplay = (modelValue) => {
@@ -346,23 +423,15 @@ function ComboCard({
{/* Name + Strategy Badge + Copy */}
<div className="flex items-center gap-2">
<code className="text-sm font-medium font-mono truncate">{combo.name}</code>
<span
className={`text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full ${
strategy === "weighted"
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
: strategy === "round-robin"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: strategy === "random"
? "bg-purple-500/15 text-purple-600 dark:text-purple-400"
: strategy === "least-used"
? "bg-cyan-500/15 text-cyan-600 dark:text-cyan-400"
: strategy === "cost-optimized"
? "bg-teal-500/15 text-teal-600 dark:text-teal-400"
: "bg-blue-500/15 text-blue-600 dark:text-blue-400"
}`}
>
{strategy}
</span>
<Tooltip content={strategyDescription}>
<span
className={`text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full ${getStrategyBadgeClass(
strategy
)}`}
>
{getStrategyLabel(t, strategy)}
</span>
</Tooltip>
{hasProxy && (
<span
className="text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full bg-primary/15 text-primary flex items-center gap-0.5"
@@ -377,7 +446,7 @@ function ComboCard({
e.stopPropagation();
onCopy(combo.name, `combo-${combo.id}`);
}}
className="p-0.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors opacity-0 group-hover:opacity-100"
className="p-0.5 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors opacity-100 md:opacity-0 md:group-hover:opacity-100"
title={t("copyComboName")}
>
<span className="material-symbols-outlined text-[14px]">
@@ -440,7 +509,7 @@ function ComboCard({
onChange={onToggle}
title={isDisabled ? t("enableCombo") : t("disableCombo")}
/>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={onTest}
disabled={testing}
@@ -573,6 +642,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
// DnD state
const [dragIndex, setDragIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const weightTotal = models.reduce((sum, modelEntry) => sum + (modelEntry.weight || 0), 0);
const hasNoModels = models.length === 0;
const hasInvalidWeightedTotal =
strategy === "weighted" && models.length > 0 && weightTotal !== 100;
const saveBlocked =
!name.trim() || !!nameError || saving || hasNoModels || hasInvalidWeightedTotal;
const fetchModalData = async () => {
try {
@@ -724,6 +799,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const handleSave = async () => {
if (!validateName(name)) return;
if (hasNoModels || hasInvalidWeightedTotal) return;
setSaving(true);
const saveData: any = {
@@ -768,19 +844,21 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Strategy Toggle */}
<div>
<label className="text-sm font-medium mb-1.5 block">{t("routingStrategy")}</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="text-sm font-medium">{t("routingStrategy")}</label>
<Tooltip content={getStrategyDescription(t, strategy)}>
<span className="material-symbols-outlined text-[13px] text-text-muted cursor-help">
help
</span>
</Tooltip>
</div>
<div className="grid grid-cols-3 gap-1 p-0.5 bg-black/5 dark:bg-white/5 rounded-lg">
{[
{ value: "priority", label: "Priority", icon: "sort" },
{ value: "weighted", label: "Weighted", icon: "percent" },
{ value: "round-robin", label: "Round-Robin", icon: "autorenew" },
{ value: "random", label: "Random", icon: "shuffle" },
{ value: "least-used", label: "Least-Used", icon: "low_priority" },
{ value: "cost-optimized", label: "Cost-Opt", icon: "savings" },
].map((s) => (
{STRATEGY_OPTIONS.map((s) => (
<button
key={s.value}
onClick={() => setStrategy(s.value)}
title={t(s.descKey)}
aria-label={`${getStrategyLabel(t, s.value)}. ${t(s.descKey)}`}
className={`py-1.5 px-2 rounded-md text-xs font-medium transition-all ${
strategy === s.value
? "bg-white dark:bg-bg-main shadow-sm text-primary"
@@ -790,22 +868,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<span className="material-symbols-outlined text-[14px] align-middle mr-0.5">
{s.icon}
</span>
{s.label}
{getStrategyLabel(t, s.value)}
</button>
))}
</div>
<p className="text-[10px] text-text-muted mt-0.5">
{
{
priority: "Sequential fallback: tries model 1 first, then 2, etc.",
weighted: "Distributes traffic by weight percentage with fallback",
"round-robin":
"Circular distribution: each request goes to the next model in rotation",
random: "Uniform random selection, then fallback to remaining models",
"least-used": "Picks the model with fewest requests, balancing load over time",
"cost-optimized": "Routes to the cheapest model first based on pricing",
}[strategy]
}
{getStrategyDescription(t, strategy)}
</p>
</div>
@@ -918,6 +986,22 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Weight total indicator */}
{strategy === "weighted" && models.length > 0 && <WeightTotalBar models={models} />}
{hasNoModels && (
<div className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] text-amber-700 dark:text-amber-300 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">warning</span>
<span>{t("noModelsYet")}</span>
</div>
)}
{hasInvalidWeightedTotal && (
<div className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] text-amber-700 dark:text-amber-300 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">warning</span>
<span>
{t("weighted")} {weightTotal}% {"\u2260"} 100%. {t("autoBalance")}
</span>
</div>
)}
{/* Add Model button */}
<button
onClick={() => setShowModelSelect(true)}
@@ -1061,12 +1145,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
<Button onClick={onClose} variant="ghost" fullWidth size="sm">
{tc("cancel")}
</Button>
<Button
onClick={handleSave}
fullWidth
size="sm"
disabled={!name.trim() || !!nameError || saving}
>
<Button onClick={handleSave} fullWidth size="sm" disabled={saveBlocked}>
{saving ? t("saving") : isEdit ? tc("save") : t("createCombo")}
</Button>
</div>

View File

@@ -2,7 +2,8 @@
import { useState, useEffect, useMemo } from "react";
import PropTypes from "prop-types";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import Link from "next/link";
import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers";
import { useTranslations } from "next-intl";
@@ -13,7 +14,6 @@ const CLOUD_ACTION_TIMEOUT_MS = 15000;
export default function APIPageClient({ machineId }) {
const t = useTranslations("endpoint");
const tc = useTranslations("common");
const [providerConnections, setProviderConnections] = useState([]);
const [loading, setLoading] = useState(true);
// Endpoints / models state
@@ -30,13 +30,16 @@ export default function APIPageClient({ machineId }) {
const [modalSuccess, setModalSuccess] = useState(false); // show success state in modal before closing
const [selectedProvider, setSelectedProvider] = useState(null); // for provider models popup
const [cloudBaseUrl, setCloudBaseUrl] = useState(CLOUD_URL); // dynamic cloud URL from API response
const [viewTab, setViewTab] = useState("api");
const [mcpStatus, setMcpStatus] = useState<any>(null);
const [a2aStatus, setA2aStatus] = useState<any>(null);
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
loadCloudSettings();
fetchModels();
Promise.allSettled([loadCloudSettings(), fetchModels(), fetchProtocolStatus()]).finally(() => {
setLoading(false);
});
}, []);
const fetchModels = async () => {
@@ -51,6 +54,24 @@ export default function APIPageClient({ machineId }) {
}
};
const fetchProtocolStatus = async () => {
try {
const [mcpRes, a2aRes] = await Promise.allSettled([
fetch("/api/mcp/status"),
fetch("/api/a2a/status"),
]);
if (mcpRes.status === "fulfilled" && mcpRes.value.ok) {
setMcpStatus(await mcpRes.value.json());
}
if (a2aRes.status === "fulfilled" && a2aRes.value.ok) {
setA2aStatus(await a2aRes.value.json());
}
} catch {
// Ignore status failures; protocols panel has fallback text.
}
};
// Categorize models by endpoint type
// Filter out parent models (models with parent field set) to avoid showing duplicates
const endpointData = useMemo(() => {
@@ -68,34 +89,6 @@ export default function APIPageClient({ machineId }) {
return { chat, embeddings, images, rerank, audioTranscription, audioSpeech, moderation };
}, [allModels]);
const providerStats = useMemo(() => {
return Object.entries(AI_PROVIDERS).map(([providerId, providerInfo]) => {
const connections = providerConnections.filter((conn) => conn.provider === providerId);
const connected = connections.filter(
(conn) =>
conn.isActive !== false &&
(conn.testStatus === "active" ||
conn.testStatus === "success" ||
conn.testStatus === "unknown")
).length;
const errors = connections.filter(
(conn) =>
conn.isActive !== false &&
(conn.testStatus === "error" ||
conn.testStatus === "expired" ||
conn.testStatus === "unavailable")
).length;
return {
id: providerId,
provider: providerInfo,
total: connections.length,
connected,
errors,
};
});
}, [providerConnections]);
const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -130,22 +123,6 @@ export default function APIPageClient({ machineId }) {
}
};
const fetchData = async () => {
try {
const providersRes = await fetch("/api/providers");
const providersData = await providersRes.json();
if (providersRes.ok) {
setProviderConnections(providersData.connections || []);
}
} catch (error) {
console.log("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const handleCloudToggle = (checked) => {
if (checked) {
setShowCloudModal(true);
@@ -162,6 +139,11 @@ export default function APIPageClient({ machineId }) {
}
}, [cloudStatus]);
useEffect(() => {
const interval = setInterval(fetchProtocolStatus, 30000);
return () => clearInterval(interval);
}, []);
const dispatchCloudChange = () => {
globalThis.dispatchEvent(new Event("cloud-status-changed"));
};
@@ -201,10 +183,6 @@ export default function APIPageClient({ machineId }) {
});
}
// Refresh keys list if new key was created
if (data.createdKey) {
await fetchData();
}
// Update cloud URL from API response (fixes undefined/v1 when env var not set)
if (data.cloudUrl) {
setCloudBaseUrl(data.cloudUrl);
@@ -260,24 +238,6 @@ export default function APIPageClient({ machineId }) {
}
};
const handleSyncCloud = async () => {
if (!cloudEnabled) return;
setCloudSyncing(true);
try {
const { ok, data } = await postCloudAction("sync");
if (ok) {
setCloudStatus({ type: "success", message: t("syncedSuccess") });
} else {
setCloudStatus({ type: "error", message: data.error || t("syncFailed") });
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message || t("syncFailed") });
} finally {
setCloudSyncing(false);
}
};
const [baseUrl, setBaseUrl] = useState("/v1");
const cloudEndpointNew = cloudBaseUrl ? `${cloudBaseUrl}/v1` : null;
@@ -299,6 +259,10 @@ export default function APIPageClient({ machineId }) {
// Use new format endpoint (machineId embedded in key)
const currentEndpoint = cloudEnabled && cloudEndpointNew ? cloudEndpointNew : baseUrl;
const mcpOnline = Boolean(mcpStatus?.online);
const a2aOnline = a2aStatus?.status === "ok";
const mcpToolCount = Number(mcpStatus?.heartbeat?.toolCount || 0);
const a2aActiveStreams = Number(a2aStatus?.tasks?.activeStreams || 0);
return (
<div className="flex flex-col gap-8">
@@ -387,219 +351,408 @@ export default function APIPageClient({ machineId }) {
</div>
</Card>
{/* Available Endpoints */}
<Card>
<div className="flex items-center justify-between mb-4">
<div className="flex flex-wrap gap-3 items-center justify-between">
<div>
<h2 className="text-lg font-semibold">{t("available")}</h2>
<h2 className="text-lg font-semibold">{t("sectionTitle") || "Integration Surface"}</h2>
<p className="text-sm text-text-muted">
{t("modelsAcrossEndpoints", {
models: Object.values(endpointData).reduce((acc, models) => acc + models.length, 0),
endpoints: [
endpointData.chat,
endpointData.embeddings,
endpointData.images,
endpointData.rerank,
endpointData.audioTranscription,
endpointData.audioSpeech,
endpointData.moderation,
].filter((a) => a.length > 0).length + 2,
})}
{t("sectionDescription") ||
"OpenAI-compatible APIs and operational protocol endpoints"}
</p>
</div>
</div>
{/* Core APIs */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-primary">hub</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryCore") || "Core APIs"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Chat Completions */}
<EndpointSection
icon="chat"
iconColor="text-blue-500"
iconBg="bg-blue-500/10"
title={t("chatCompletions")}
path="/v1/chat/completions"
description={t("chatDesc")}
models={endpointData.chat}
expanded={expandedEndpoint === "chat"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Responses API */}
<EndpointSection
icon="code"
iconColor="text-indigo-500"
iconBg="bg-indigo-500/10"
title={t("responses") || "Responses API"}
path="/v1/responses"
description={t("responsesDesc") || "OpenAI Responses API for Codex and advanced agentic workflows"}
models={endpointData.chat}
expanded={expandedEndpoint === "responses"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "responses" ? null : "responses")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Media & Multi-Modal */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryMedia") || "Media & Multi-Modal"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Embeddings */}
<EndpointSection
icon="data_array"
iconColor="text-emerald-500"
iconBg="bg-emerald-500/10"
title={t("embeddings")}
path="/v1/embeddings"
description={t("embeddingsDesc")}
models={endpointData.embeddings}
expanded={expandedEndpoint === "embeddings"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "embeddings" ? null : "embeddings")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Image Generation */}
<EndpointSection
icon="image"
iconColor="text-purple-500"
iconBg="bg-purple-500/10"
title={t("imageGeneration")}
path="/v1/images/generations"
description={t("imageDesc")}
models={endpointData.images}
expanded={expandedEndpoint === "images"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "images" ? null : "images")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Transcription */}
<EndpointSection
icon="mic"
iconColor="text-rose-500"
iconBg="bg-rose-500/10"
title={t("audioTranscription")}
path="/v1/audio/transcriptions"
description={t("audioTranscriptionDesc")}
models={endpointData.audioTranscription}
expanded={expandedEndpoint === "audioTranscription"}
onToggle={() =>
setExpandedEndpoint(
expandedEndpoint === "audioTranscription" ? null : "audioTranscription"
)
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Speech (TTS) */}
<EndpointSection
icon="record_voice_over"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title={t("textToSpeech")}
path="/v1/audio/speech"
description={t("textToSpeechDesc")}
models={endpointData.audioSpeech}
expanded={expandedEndpoint === "audioSpeech"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "audioSpeech" ? null : "audioSpeech")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Utility & Management */}
<div>
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryUtility") || "Utility & Management"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Rerank */}
<EndpointSection
icon="sort"
iconColor="text-amber-500"
iconBg="bg-amber-500/10"
title={t("rerank")}
path="/v1/rerank"
description={t("rerankDesc")}
models={endpointData.rerank}
expanded={expandedEndpoint === "rerank"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Moderations */}
<EndpointSection
icon="shield"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title={t("moderations")}
path="/v1/moderations"
description={t("moderationsDesc")}
models={endpointData.moderation}
expanded={expandedEndpoint === "moderation"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "moderation" ? null : "moderation")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* List Models */}
<EndpointSection
icon="list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("listModels") || "List Models"}
path="/v1/models"
description={t("listModelsDesc") || "List all available models across all connected providers"}
models={[]}
expanded={expandedEndpoint === "models"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "models" ? null : "models")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
<SegmentedControl
options={[
{ value: "api", label: t("tabApis") || "OpenAI-compatible APIs", icon: "api" },
{ value: "protocols", label: t("tabProtocols") || "Protocols", icon: "hub" },
]}
value={viewTab}
onChange={setViewTab}
aria-label={t("tabsAria") || "Endpoint sections"}
/>
</div>
</Card>
{viewTab === "api" ? (
<Card>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">{t("available")}</h2>
<p className="text-sm text-text-muted">
{t("modelsAcrossEndpoints", {
models: Object.values(endpointData).reduce(
(acc, models) => acc + models.length,
0
),
endpoints:
[
endpointData.chat,
endpointData.embeddings,
endpointData.images,
endpointData.rerank,
endpointData.audioTranscription,
endpointData.audioSpeech,
endpointData.moderation,
].filter((a) => a.length > 0).length + 2,
})}
</p>
</div>
</div>
{/* Core APIs */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-primary">hub</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryCore") || "Core APIs"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Chat Completions */}
<EndpointSection
icon="chat"
iconColor="text-blue-500"
iconBg="bg-blue-500/10"
title={t("chatCompletions")}
path="/v1/chat/completions"
description={t("chatDesc")}
models={endpointData.chat}
expanded={expandedEndpoint === "chat"}
onToggle={() => setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Responses API */}
<EndpointSection
icon="code"
iconColor="text-indigo-500"
iconBg="bg-indigo-500/10"
title={t("responses") || "Responses API"}
path="/v1/responses"
description={
t("responsesDesc") ||
"OpenAI Responses API for Codex and advanced agentic workflows"
}
models={endpointData.chat}
expanded={expandedEndpoint === "responses"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "responses" ? null : "responses")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Media & Multi-Modal */}
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryMedia") || "Media & Multi-Modal"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Embeddings */}
<EndpointSection
icon="data_array"
iconColor="text-emerald-500"
iconBg="bg-emerald-500/10"
title={t("embeddings")}
path="/v1/embeddings"
description={t("embeddingsDesc")}
models={endpointData.embeddings}
expanded={expandedEndpoint === "embeddings"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "embeddings" ? null : "embeddings")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Image Generation */}
<EndpointSection
icon="image"
iconColor="text-purple-500"
iconBg="bg-purple-500/10"
title={t("imageGeneration")}
path="/v1/images/generations"
description={t("imageDesc")}
models={endpointData.images}
expanded={expandedEndpoint === "images"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "images" ? null : "images")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Transcription */}
<EndpointSection
icon="mic"
iconColor="text-rose-500"
iconBg="bg-rose-500/10"
title={t("audioTranscription")}
path="/v1/audio/transcriptions"
description={t("audioTranscriptionDesc")}
models={endpointData.audioTranscription}
expanded={expandedEndpoint === "audioTranscription"}
onToggle={() =>
setExpandedEndpoint(
expandedEndpoint === "audioTranscription" ? null : "audioTranscription"
)
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Audio Speech (TTS) */}
<EndpointSection
icon="record_voice_over"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title={t("textToSpeech")}
path="/v1/audio/speech"
description={t("textToSpeechDesc")}
models={endpointData.audioSpeech}
expanded={expandedEndpoint === "audioSpeech"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "audioSpeech" ? null : "audioSpeech")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
{/* Utility & Management */}
<div>
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryUtility") || "Utility & Management"}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
<div className="flex flex-col gap-3">
{/* Rerank */}
<EndpointSection
icon="sort"
iconColor="text-amber-500"
iconBg="bg-amber-500/10"
title={t("rerank")}
path="/v1/rerank"
description={t("rerankDesc")}
models={endpointData.rerank}
expanded={expandedEndpoint === "rerank"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* Moderations */}
<EndpointSection
icon="shield"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title={t("moderations")}
path="/v1/moderations"
description={t("moderationsDesc")}
models={endpointData.moderation}
expanded={expandedEndpoint === "moderation"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "moderation" ? null : "moderation")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
{/* List Models */}
<EndpointSection
icon="list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("listModels") || "List Models"}
path="/v1/models"
description={
t("listModelsDesc") || "List all available models across all connected providers"
}
models={[]}
expanded={expandedEndpoint === "models"}
onToggle={() =>
setExpandedEndpoint(expandedEndpoint === "models" ? null : "models")
}
copy={copy}
copied={copied}
baseUrl={currentEndpoint}
/>
</div>
</div>
</Card>
) : (
<Card>
<div className="flex flex-col gap-6">
<div>
<h2 className="text-lg font-semibold">{t("protocolsTitle") || "Protocols"}</h2>
<p className="text-sm text-text-muted mt-1">
{t("protocolsDescription") ||
"MCP and A2A are first-class endpoints with dedicated observability and controls."}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">
hub
</span>
{t("mcpCardTitle") || "MCP Server"}
</h3>
<p className="text-xs text-text-muted mt-1">
{t("mcpCardDescription") || "Model Context Protocol over stdio"}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded-full font-semibold ${
mcpOnline ? "bg-green-500/15 text-green-500" : "bg-red-500/15 text-red-500"
}`}
>
{mcpOnline ? tc("active") : tc("inactive")}
</span>
</div>
<div className="mt-3 text-xs text-text-muted space-y-1">
<p>
{t("protocolToolsLabel") || "Tools"}:{" "}
<span className="text-text-main font-semibold">{mcpToolCount || 16}</span>
</p>
<p>
{t("protocolLastActivity") || "Last activity"}:{" "}
<span className="text-text-main">
{mcpStatus?.activity?.lastCallAt
? new Date(mcpStatus.activity.lastCallAt).toLocaleString()
: "—"}
</span>
</p>
</div>
<div className="mt-3 rounded-lg bg-bg p-3 border border-border/70">
<p className="text-xs font-semibold mb-1">{t("quickStart") || "Quick Start"}</p>
<code className="text-xs font-mono break-all">omniroute --mcp</code>
</div>
<div className="mt-3">
<Link
href="/dashboard/mcp"
className="text-sm text-primary hover:text-primary-hover transition-colors"
>
{t("openMcpDashboard") || "Open MCP management"}
</Link>
</div>
</div>
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[18px]">
group_work
</span>
{t("a2aCardTitle") || "A2A Server"}
</h3>
<p className="text-xs text-text-muted mt-1">
{t("a2aCardDescription") || "Agent2Agent JSON-RPC endpoint"}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded-full font-semibold ${
a2aOnline ? "bg-green-500/15 text-green-500" : "bg-red-500/15 text-red-500"
}`}
>
{a2aOnline ? tc("active") : tc("inactive")}
</span>
</div>
<div className="mt-3 text-xs text-text-muted space-y-1">
<p>
{t("protocolTasksLabel") || "Tasks"}:{" "}
<span className="text-text-main font-semibold">
{a2aStatus?.tasks?.total || 0}
</span>
</p>
<p>
{t("protocolActiveStreamsLabel") || "Active streams"}:{" "}
<span className="text-text-main font-semibold">{a2aActiveStreams}</span>
</p>
</div>
<div className="mt-3 rounded-lg bg-bg p-3 border border-border/70">
<p className="text-xs font-semibold mb-1">{t("quickStart") || "Quick Start"}</p>
<code className="text-xs font-mono break-all">
{baseUrl.replace(/\/v1$/, "")}/a2a
</code>
</div>
<div className="mt-3">
<Link
href="/dashboard/a2a"
className="text-sm text-primary hover:text-primary-hover transition-colors"
>
{t("openA2aDashboard") || "Open A2A management"}
</Link>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<h4 className="font-semibold mb-2">
{t("mcpQuickStartTitle") || "MCP Quick Start"}
</h4>
<ol className="text-sm text-text-muted space-y-1 list-decimal list-inside">
<li>{t("mcpQuickStartStep1") || "Run the MCP server via `omniroute --mcp`."}</li>
<li>
{t("mcpQuickStartStep2") ||
"Configure your MCP client to connect over stdio transport."}
</li>
<li>
{t("mcpQuickStartStep3") ||
"Invoke tools such as `omniroute_get_health` and `omniroute_list_combos`."}
</li>
</ol>
</div>
<div className="rounded-xl border border-border p-4 bg-bg-subtle">
<h4 className="font-semibold mb-2">
{t("a2aQuickStartTitle") || "A2A Quick Start"}
</h4>
<ol className="text-sm text-text-muted space-y-1 list-decimal list-inside">
<li>
{t("a2aQuickStartStep1") ||
"Discover the agent card at `/.well-known/agent.json`."}
</li>
<li>
{t("a2aQuickStartStep2") ||
"Send JSON-RPC requests to `POST /a2a` using `message/send` or `message/stream`."}
</li>
<li>
{t("a2aQuickStartStep3") ||
"Track and control tasks using `tasks/get` and `tasks/cancel`."}
</li>
</ol>
</div>
</div>
</div>
</Card>
)}
{/* Cloud Enable Modal */}
<Modal
isOpen={showCloudModal}

View File

@@ -1,147 +1,642 @@
/**
* Dashboard MCP Panel — /dashboard/mcp
*
* Shows MCP tool audit log, usage stats, and real-time metrics.
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Card, Button } from "@/shared/components";
import { useTranslations } from "next-intl";
interface AuditEntry {
tool_name: string;
timestamp: string;
duration_ms: number;
type McpStatusResponse = {
status: "online" | "offline";
online: boolean;
heartbeatPath: string;
heartbeat: {
pid: number;
startedAt: string;
lastHeartbeatAt: string;
version: string;
transport: "stdio";
scopesEnforced: boolean;
allowedScopes: string[];
toolCount: number;
pidAlive: boolean;
heartbeatAgeMs: number | null;
uptimeMs: number | null;
} | null;
activity: {
totalCalls24h: number;
successRate: number;
avgDurationMs: number;
topTools: Array<{ tool: string; count: number }>;
lastCallAt: string | null;
lastCallTool: string | null;
};
};
type McpTool = {
name: string;
description: string;
scopes: string[];
phase: 1 | 2;
auditLevel: "none" | "basic" | "full";
sourceEndpoints: string[];
};
type McpAuditEntry = {
id: number;
toolName: string;
inputHash: string;
outputSummary: string;
durationMs: number;
apiKeyId: string | null;
success: boolean;
api_key_hash: string;
errorCode: string | null;
createdAt: string;
};
type McpAuditResponse = {
entries: McpAuditEntry[];
total: number;
limit: number;
offset: number;
};
type Combo = {
id: string;
name: string;
isActive?: boolean;
};
const AUDIT_PAGE_SIZE = 20;
const RESILIENCE_PRESETS = {
aggressive: {
profiles: {
oauth: {
transientCooldown: 3000,
rateLimitCooldown: 30000,
maxBackoffLevel: 4,
circuitBreakerThreshold: 2,
circuitBreakerReset: 30000,
},
apikey: {
transientCooldown: 2000,
rateLimitCooldown: 0,
maxBackoffLevel: 3,
circuitBreakerThreshold: 3,
circuitBreakerReset: 15000,
},
},
defaults: {
requestsPerMinute: 180,
minTimeBetweenRequests: 100,
concurrentRequests: 16,
},
},
balanced: {
profiles: {
oauth: {
transientCooldown: 5000,
rateLimitCooldown: 60000,
maxBackoffLevel: 8,
circuitBreakerThreshold: 3,
circuitBreakerReset: 60000,
},
apikey: {
transientCooldown: 3000,
rateLimitCooldown: 0,
maxBackoffLevel: 5,
circuitBreakerThreshold: 5,
circuitBreakerReset: 30000,
},
},
defaults: {
requestsPerMinute: 100,
minTimeBetweenRequests: 200,
concurrentRequests: 10,
},
},
conservative: {
profiles: {
oauth: {
transientCooldown: 8000,
rateLimitCooldown: 120000,
maxBackoffLevel: 10,
circuitBreakerThreshold: 8,
circuitBreakerReset: 120000,
},
apikey: {
transientCooldown: 5000,
rateLimitCooldown: 30000,
maxBackoffLevel: 8,
circuitBreakerThreshold: 8,
circuitBreakerReset: 60000,
},
},
defaults: {
requestsPerMinute: 60,
minTimeBetweenRequests: 350,
concurrentRequests: 6,
},
},
} as const;
function formatDuration(ms: number | null | undefined) {
if (typeof ms !== "number" || !Number.isFinite(ms)) return "—";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
interface McpStats {
totalCalls: number;
successRate: number;
avgDurationMs: number;
byTool: Array<{ tool: string; count: number; avgMs: number }>;
function formatPercent(value: number | null | undefined) {
if (typeof value !== "number" || !Number.isFinite(value)) return "0%";
return `${(value * 100).toFixed(1)}%`;
}
export default function McpDashboard() {
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [stats, setStats] = useState<McpStats | null>(null);
export default function McpDashboardPage() {
const t = useTranslations("mcpDashboard");
const [loading, setLoading] = useState(true);
const [status, setStatus] = useState<McpStatusResponse | null>(null);
const [tools, setTools] = useState<McpTool[]>([]);
const [combos, setCombos] = useState<Combo[]>([]);
const fetchData = useCallback(async () => {
const [toolFilter, setToolFilter] = useState("");
const [successFilter, setSuccessFilter] = useState<"all" | "true" | "false">("all");
const [apiKeyFilter, setApiKeyFilter] = useState("");
const [auditOffset, setAuditOffset] = useState(0);
const [auditData, setAuditData] = useState<McpAuditResponse>({
entries: [],
total: 0,
limit: AUDIT_PAGE_SIZE,
offset: 0,
});
const [auditLoading, setAuditLoading] = useState(false);
const [selectedComboId, setSelectedComboId] = useState("");
const [selectedProfile, setSelectedProfile] =
useState<keyof typeof RESILIENCE_PRESETS>("balanced");
const [actionBusy, setActionBusy] = useState<null | "switch" | "resilience" | "reset">(null);
const [actionMessage, setActionMessage] = useState<string>("");
const selectedCombo = useMemo(
() => combos.find((combo) => combo.id === selectedComboId) || null,
[combos, selectedComboId]
);
const refreshSummary = useCallback(async () => {
try {
const [auditRes, statsRes] = await Promise.allSettled([
fetch("/api/mcp/audit?limit=50"),
fetch("/api/mcp/audit/stats"),
const [statusRes, toolsRes, combosRes] = await Promise.all([
fetch("/api/mcp/status"),
fetch("/api/mcp/tools"),
fetch("/api/combos"),
]);
if (auditRes.status === "fulfilled") setAudit(await auditRes.value.json());
if (statsRes.status === "fulfilled") setStats(await statsRes.value.json());
} catch {
/* fallback data */
if (statusRes.ok) {
const json = await statusRes.json();
setStatus(json);
}
if (toolsRes.ok) {
const json = await toolsRes.json();
setTools(Array.isArray(json.tools) ? json.tools : []);
}
if (combosRes.ok) {
const json = await combosRes.json();
const nextCombos = Array.isArray(json?.combos) ? json.combos : [];
setCombos(nextCombos);
if (!selectedComboId && nextCombos.length > 0) {
setSelectedComboId(nextCombos[0].id);
}
}
} finally {
setLoading(false);
}
setLoading(false);
}, []);
}, [selectedComboId]);
const refreshAudit = useCallback(async () => {
setAuditLoading(true);
try {
const params = new URLSearchParams();
params.set("limit", String(AUDIT_PAGE_SIZE));
params.set("offset", String(auditOffset));
if (toolFilter) params.set("tool", toolFilter);
if (successFilter !== "all") params.set("success", successFilter);
if (apiKeyFilter) params.set("apiKeyId", apiKeyFilter);
const response = await fetch(`/api/mcp/audit?${params.toString()}`);
if (!response.ok) return;
const json = await response.json();
setAuditData({
entries: Array.isArray(json.entries) ? json.entries : [],
total: Number(json.total || 0),
limit: Number(json.limit || AUDIT_PAGE_SIZE),
offset: Number(json.offset || 0),
});
} finally {
setAuditLoading(false);
}
}, [auditOffset, toolFilter, successFilter, apiKeyFilter]);
useEffect(() => {
const id = setTimeout(fetchData, 0);
const interval = setInterval(fetchData, 30_000);
return () => {
clearTimeout(id);
clearInterval(interval);
};
}, [fetchData]);
refreshSummary();
const interval = setInterval(refreshSummary, 30000);
return () => clearInterval(interval);
}, [refreshSummary]);
const tools = [
"omniroute_get_health",
"omniroute_list_combos",
"omniroute_get_combo_metrics",
"omniroute_switch_combo",
"omniroute_check_quota",
"omniroute_route_request",
"omniroute_cost_report",
"omniroute_list_models_catalog",
"omniroute_simulate_route",
"omniroute_set_budget_guard",
"omniroute_set_resilience_profile",
"omniroute_test_combo",
"omniroute_get_provider_metrics",
"omniroute_best_combo_for_task",
"omniroute_explain_route",
"omniroute_get_session_snapshot",
];
useEffect(() => {
refreshAudit();
}, [refreshAudit]);
const handleSwitchCombo = async () => {
if (!selectedCombo) return;
const nextState = selectedCombo.isActive === false;
const confirmLabel = nextState ? t("activate") : t("deactivate");
if (
!globalThis.confirm(
t("confirmSwitchCombo", { action: confirmLabel, combo: selectedCombo.name })
)
)
return;
setActionBusy("switch");
setActionMessage("");
try {
const response = await fetch(`/api/combos/${selectedCombo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive: nextState }),
});
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("switchComboFailed"));
return;
}
setActionMessage(t("switchComboSuccess", { combo: selectedCombo.name }));
await refreshSummary();
} finally {
setActionBusy(null);
}
};
const handleApplyResilience = async () => {
const preset = RESILIENCE_PRESETS[selectedProfile];
const profileLabelById: Record<keyof typeof RESILIENCE_PRESETS, string> = {
aggressive: t("profileAggressive"),
balanced: t("profileBalanced"),
conservative: t("profileConservative"),
};
const profileLabel = profileLabelById[selectedProfile];
if (!globalThis.confirm(t("confirmApplyProfile", { profile: profileLabel }))) return;
setActionBusy("resilience");
setActionMessage("");
try {
const response = await fetch("/api/resilience", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(preset),
});
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("applyProfileFailed"));
return;
}
setActionMessage(t("applyProfileSuccess", { profile: profileLabel }));
await refreshSummary();
} finally {
setActionBusy(null);
}
};
const handleResetCircuitBreakers = async () => {
if (!globalThis.confirm(t("confirmResetBreakers"))) return;
setActionBusy("reset");
setActionMessage("");
try {
const response = await fetch("/api/monitoring/health", { method: "DELETE" });
if (!response.ok) {
const json = await response.json().catch(() => ({}));
setActionMessage(json?.error || t("resetBreakersFailed"));
return;
}
const json = await response.json().catch(() => ({}));
setActionMessage(json?.message || t("resetBreakersSuccess"));
await refreshSummary();
} finally {
setActionBusy(null);
}
};
const totalPages = Math.max(1, Math.ceil((auditData.total || 0) / AUDIT_PAGE_SIZE));
const currentPage = Math.floor((auditData.offset || 0) / AUDIT_PAGE_SIZE) + 1;
const topTools = status?.activity?.topTools || [];
if (loading) {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="text-sm text-text-muted">{t("loading")}</div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-2xl font-bold mb-6">🔧 MCP Server Dashboard</h1>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<StatCard label="Total Calls" value={stats?.totalCalls || 0} />
<StatCard label="Success Rate" value={`${((stats?.successRate || 1) * 100).toFixed(1)}%`} />
<StatCard label="Avg Latency" value={`${stats?.avgDurationMs || 0}ms`} />
<StatCard label="Active Tools" value={tools.length} />
<div className="p-6 max-w-7xl mx-auto space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard label={t("processStatus")} value={status?.online ? t("online") : t("offline")} />
<StatCard label={t("pid")} value={status?.heartbeat?.pid ?? "—"} />
<StatCard
label={t("sessionUptime")}
value={formatDuration(status?.heartbeat?.uptimeMs ?? null)}
/>
<StatCard
label={t("lastHeartbeat")}
value={formatDuration(status?.heartbeat?.heartbeatAgeMs ?? null)}
/>
</div>
{/* Tool List */}
<div className="mb-8">
<h2 className="text-lg font-semibold mb-3">📋 Available Tools ({tools.length})</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{tools.map((t) => (
<div
key={t}
className="p-2 bg-green-50 dark:bg-green-900/20 rounded text-sm border border-green-200 dark:border-green-800"
>
<span className="text-green-600 mr-1"></span> {t.replace("omniroute_", "")}
</div>
))}
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("activity24h")}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<StatCard label={t("totalCalls")} value={status?.activity?.totalCalls24h ?? 0} compact />
<StatCard
label={t("successRate")}
value={formatPercent(status?.activity?.successRate)}
compact
/>
<StatCard
label={t("avgLatency")}
value={formatDuration(status?.activity?.avgDurationMs ?? null)}
compact
/>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="rounded-lg border border-border p-3">
<h3 className="text-sm font-semibold mb-2">{t("topTools")}</h3>
{topTools.length === 0 ? (
<p className="text-sm text-text-muted">{t("noToolCalls24h")}</p>
) : (
<ul className="space-y-1 text-sm">
{topTools.map((entry) => (
<li key={entry.tool} className="flex justify-between">
<span className="font-mono text-xs">{entry.tool}</span>
<span>{entry.count}</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg border border-border p-3">
<h3 className="text-sm font-semibold mb-2">{t("runtimeDetails")}</h3>
<div className="text-sm space-y-1">
<p>
{t("transport")}:{" "}
<span className="font-mono">{status?.heartbeat?.transport || "—"}</span>
</p>
<p>
{t("scopesEnforced")}:{" "}
<span className="font-semibold">
{status?.heartbeat?.scopesEnforced ? t("yes") : t("no")}
</span>
</p>
<p>
{t("lastCall")}:{" "}
<span className="font-mono text-xs">
{status?.activity?.lastCallTool || "—"}{" "}
{status?.activity?.lastCallAt
? `(${new Date(status.activity.lastCallAt).toLocaleString()})`
: ""}
</span>
</p>
<p>
{t("heartbeatPath")}:{" "}
<span className="font-mono text-xs break-all">{status?.heartbeatPath || "—"}</span>
</p>
</div>
</div>
</div>
</Card>
{/* Audit Log */}
<div>
<h2 className="text-lg font-semibold mb-3">📊 Recent Calls</h2>
{loading ? (
<p className="text-gray-500">Loading...</p>
) : audit.length === 0 ? (
<p className="text-gray-500">
No MCP calls yet. Use <code>omniroute --mcp</code> to connect.
</p>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("operationalControls")}</h2>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="rounded-lg border border-border p-3 space-y-3">
<p className="text-sm font-semibold">{t("switchCombo")}</p>
<select
className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={selectedComboId}
onChange={(event) => setSelectedComboId(event.target.value)}
>
{combos.map((combo) => (
<option key={combo.id} value={combo.id}>
{combo.name} ({combo.isActive === false ? t("inactive") : t("active")})
</option>
))}
</select>
<Button
size="sm"
variant="secondary"
onClick={handleSwitchCombo}
disabled={!selectedCombo || actionBusy === "switch"}
>
{selectedCombo?.isActive === false ? t("activateCombo") : t("deactivateCombo")}
</Button>
</div>
<div className="rounded-lg border border-border p-3 space-y-3">
<p className="text-sm font-semibold">{t("applyResilienceProfile")}</p>
<select
className="w-full rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={selectedProfile}
onChange={(event) =>
setSelectedProfile(event.target.value as keyof typeof RESILIENCE_PRESETS)
}
>
<option value="aggressive">{t("profileAggressive")}</option>
<option value="balanced">{t("profileBalanced")}</option>
<option value="conservative">{t("profileConservative")}</option>
</select>
<Button
size="sm"
variant="secondary"
onClick={handleApplyResilience}
disabled={actionBusy === "resilience"}
>
{t("applyProfile")}
</Button>
</div>
<div className="rounded-lg border border-border p-3 space-y-3">
<p className="text-sm font-semibold">{t("resetCircuitBreakers")}</p>
<p className="text-xs text-text-muted">{t("resetCircuitBreakersHelp")}</p>
<Button
size="sm"
onClick={handleResetCircuitBreakers}
className="bg-red-500! hover:bg-red-600! text-white!"
disabled={actionBusy === "reset"}
>
{t("resetAllBreakers")}
</Button>
</div>
</div>
{actionMessage && <p className="text-sm text-text-muted mt-3">{actionMessage}</p>}
</Card>
<Card className="p-5">
<h2 className="text-lg font-semibold mb-4">{t("toolsAndScopes")}</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2">{t("tableTool")}</th>
<th className="text-left py-2 pr-2">{t("tableScopes")}</th>
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
<th className="text-left py-2">{t("tableAudit")}</th>
</tr>
</thead>
<tbody>
{tools.map((tool) => (
<tr key={tool.name} className="border-b border-border/40">
<td className="py-2 pr-2 font-mono text-xs">{tool.name}</td>
<td className="py-2 pr-2 text-xs">{tool.scopes.join(", ") || "—"}</td>
<td className="py-2 pr-2">{tool.phase}</td>
<td className="py-2">{tool.auditLevel}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card className="p-5">
<div className="flex flex-wrap gap-2 items-end justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">{t("auditLog")}</h2>
<p className="text-sm text-text-muted">
{t("auditSummary", { total: auditData.total, page: currentPage, totalPages })}
</p>
</div>
<div className="flex flex-wrap gap-2">
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={toolFilter}
onChange={(event) => {
setAuditOffset(0);
setToolFilter(event.target.value);
}}
>
<option value="">{t("allTools")}</option>
{tools.map((tool) => (
<option key={tool.name} value={tool.name}>
{tool.name}
</option>
))}
</select>
<select
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
value={successFilter}
onChange={(event) => {
setAuditOffset(0);
setSuccessFilter(event.target.value as "all" | "true" | "false");
}}
>
<option value="all">{t("allResults")}</option>
<option value="true">{t("success")}</option>
<option value="false">{t("failure")}</option>
</select>
<input
className="rounded-lg border border-border bg-bg px-3 py-2 text-sm"
placeholder={t("apiKeyIdPlaceholder")}
value={apiKeyFilter}
onChange={(event) => {
setAuditOffset(0);
setApiKeyFilter(event.target.value);
}}
/>
</div>
</div>
{auditLoading ? (
<p className="text-sm text-text-muted">{t("loadingAuditEntries")}</p>
) : auditData.entries.length === 0 ? (
<p className="text-sm text-text-muted">{t("noAuditEntriesForFilters")}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-2">Tool</th>
<th className="text-left p-2">Time</th>
<th className="text-left p-2">Duration</th>
<th className="text-left p-2">Status</th>
<tr className="border-b border-border">
<th className="text-left py-2 pr-2">{t("tableTimestamp")}</th>
<th className="text-left py-2 pr-2">{t("tableTool")}</th>
<th className="text-left py-2 pr-2">{t("tableDuration")}</th>
<th className="text-left py-2 pr-2">{t("tableResult")}</th>
<th className="text-left py-2">{t("tableApiKey")}</th>
</tr>
</thead>
<tbody>
{audit.map((entry, i) => (
<tr key={i} className="border-b hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="p-2 font-mono text-xs">{entry.tool_name}</td>
<td className="p-2 text-xs">
{new Date(entry.timestamp).toLocaleTimeString()}
{auditData.entries.map((entry) => (
<tr key={entry.id} className="border-b border-border/40">
<td className="py-2 pr-2 text-xs">
{new Date(entry.createdAt).toLocaleString()}
</td>
<td className="p-2">{entry.duration_ms}ms</td>
<td className="p-2">{entry.success ? "✅" : "❌"}</td>
<td className="py-2 pr-2 font-mono text-xs">{entry.toolName}</td>
<td className="py-2 pr-2">{entry.durationMs}ms</td>
<td className="py-2 pr-2">
<span className={entry.success ? "text-green-500" : "text-red-500"}>
{entry.success ? t("success") : entry.errorCode || t("failed")}
</span>
</td>
<td className="py-2">{entry.apiKeyId || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 mt-4">
<Button
size="sm"
variant="secondary"
disabled={auditOffset === 0}
onClick={() => setAuditOffset((current) => Math.max(0, current - AUDIT_PAGE_SIZE))}
>
{t("previous")}
</Button>
<Button
size="sm"
variant="secondary"
disabled={auditOffset + AUDIT_PAGE_SIZE >= auditData.total}
onClick={() =>
setAuditOffset((current) =>
current + AUDIT_PAGE_SIZE < auditData.total ? current + AUDIT_PAGE_SIZE : current
)
}
>
{t("next")}
</Button>
</div>
</Card>
</div>
);
}
function StatCard({ label, value }: { label: string; value: string | number }) {
function StatCard({
label,
value,
compact = false,
}: {
label: string;
value: string | number;
compact?: boolean;
}) {
return (
<div className="p-4 bg-white dark:bg-gray-800 rounded-lg shadow border">
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
<div className={`rounded-lg border border-border bg-bg p-4 ${compact ? "" : "min-h-[84px]"}`}>
<p className="text-xs text-text-muted uppercase tracking-wide">{label}</p>
<p className="text-xl font-semibold mt-1">{value}</p>
</div>
);
}

View File

@@ -194,7 +194,11 @@ export async function POST(req: NextRequest) {
const stream = createA2AStream(
task,
async (t) => executeA2ATaskWithState(tm, t, handler),
req.signal
req.signal,
{
onStart: () => tm.beginStream(),
onEnd: () => tm.endStream(),
}
);
return new Response(stream, { headers: SSE_HEADERS });

View File

@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
export async function GET() {
try {
const tm = getTaskManager();
const stats = tm.getStats();
let agentCard: any = null;
try {
const agentModule = await import("@/app/.well-known/agent.json/route");
const cardResponse = await agentModule.GET();
agentCard = await cardResponse.json();
} catch {
agentCard = null;
}
return NextResponse.json({
status: "ok",
tasks: stats,
agent: agentCard
? {
name: agentCard.name,
description: agentCard.description,
version: agentCard.version,
url: agentCard.url,
}
: null,
capabilities: agentCard?.capabilities || null,
skills: Array.isArray(agentCard?.skills) ? agentCard.skills : [],
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load A2A status";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.cancelTask(id);
return NextResponse.json({ task: { id: task.id, state: task.state } });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to cancel A2A task";
const status = message.includes("not found") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}
}

View File

@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.getTask(id);
if (!task) {
return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 });
}
return NextResponse.json({ task });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load A2A task";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { getTaskManager, type TaskState } from "@/lib/a2a/taskManager";
const VALID_TASK_STATES = new Set<TaskState>([
"submitted",
"working",
"completed",
"failed",
"cancelled",
]);
function parseIntParam(value: string | null, fallback: number): number {
if (typeof value !== "string") return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return parsed;
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const stateParam = searchParams.get("state");
const skill = searchParams.get("skill") || undefined;
const limit = Math.max(1, Math.min(200, parseIntParam(searchParams.get("limit"), 50)));
const offset = Math.max(0, parseIntParam(searchParams.get("offset"), 0));
const state =
typeof stateParam === "string" && VALID_TASK_STATES.has(stateParam as TaskState)
? (stateParam as TaskState)
: undefined;
const tm = getTaskManager();
const total = tm.countTasks({ state, skill });
const tasks = tm.listTasks({ state, skill, limit, offset });
return NextResponse.json({
tasks,
total,
limit,
offset,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list A2A tasks";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { queryAuditEntries } from "@omniroute/open-sse/mcp-server/audit";
function parseBooleanParam(value: string | null): boolean | undefined {
if (value === "true" || value === "1") return true;
if (value === "false" || value === "0") return false;
return undefined;
}
function parseNumberParam(value: string | null, fallback: number): number {
if (typeof value !== "string") return fallback;
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed)) return fallback;
return parsed;
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const limit = parseNumberParam(searchParams.get("limit"), 50);
const offset = parseNumberParam(searchParams.get("offset"), 0);
const tool = searchParams.get("tool") || undefined;
const success = parseBooleanParam(searchParams.get("success"));
const apiKeyId = searchParams.get("apiKeyId") || undefined;
const result = await queryAuditEntries({
limit,
offset,
tool,
success,
apiKeyId,
});
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP audit log";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { getAuditStats } from "@omniroute/open-sse/mcp-server/audit";
export async function GET() {
try {
const stats = await getAuditStats();
return NextResponse.json(stats);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP audit stats";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { getAuditStats, queryAuditEntries } from "@omniroute/open-sse/mcp-server/audit";
import {
isMcpHeartbeatOnline,
isProcessAlive,
readMcpHeartbeat,
resolveMcpHeartbeatPath,
} from "@omniroute/open-sse/mcp-server/runtimeHeartbeat";
export async function GET() {
try {
const [heartbeat, stats, lastCallPage] = await Promise.all([
readMcpHeartbeat(),
getAuditStats(),
queryAuditEntries({ limit: 1, offset: 0 }),
]);
const online = isMcpHeartbeatOnline(heartbeat, { requireLivePid: true });
const lastCall = lastCallPage.entries[0] || null;
const now = Date.now();
const lastHeartbeatAtMs = heartbeat ? new Date(heartbeat.lastHeartbeatAt).getTime() : null;
const startedAtMs = heartbeat ? new Date(heartbeat.startedAt).getTime() : null;
const heartbeatAgeMs =
typeof lastHeartbeatAtMs === "number" && Number.isFinite(lastHeartbeatAtMs)
? Math.max(0, now - lastHeartbeatAtMs)
: null;
const uptimeMs =
typeof startedAtMs === "number" && Number.isFinite(startedAtMs)
? Math.max(0, now - startedAtMs)
: null;
return NextResponse.json({
status: online ? "online" : "offline",
online,
heartbeatPath: resolveMcpHeartbeatPath(),
heartbeat: heartbeat
? {
...heartbeat,
pidAlive: isProcessAlive(heartbeat.pid),
heartbeatAgeMs,
uptimeMs,
}
: null,
activity: {
totalCalls24h: stats.totalCalls,
successRate: stats.successRate,
avgDurationMs: stats.avgDurationMs,
topTools: stats.topTools,
lastCallAt: lastCall?.createdAt || null,
lastCallTool: lastCall?.toolName || null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP status";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { MCP_TOOLS, MCP_TOOL_MAP } from "@omniroute/open-sse/mcp-server/schemas/tools";
export async function GET() {
try {
return NextResponse.json({
total: MCP_TOOLS.length,
mappedTotal: Object.keys(MCP_TOOL_MAP).length,
tools: MCP_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
scopes: [...tool.scopes],
phase: tool.phase,
auditLevel: tool.auditLevel,
sourceEndpoints: [...tool.sourceEndpoints],
})),
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load MCP tools";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -46,6 +46,7 @@ const TOC_ITEMS = [
{ href: "#supported-providers", labelKey: "supportedProvidersToc" },
{ href: "#use-cases", labelKey: "commonUseCases" },
{ href: "#client-compatibility", labelKey: "clientCompatibility" },
{ href: "#protocols", labelKey: "protocolsToc" },
{ href: "#api-reference", labelKey: "apiReference" },
{ href: "#model-prefixes", labelKey: "modelPrefixes" },
{ href: "#troubleshooting", labelKey: "troubleshooting" },
@@ -366,6 +367,49 @@ export default function DocsPage() {
</div>
</section>
<section id="protocols" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("protocolsTitle")}</h2>
<p className="text-sm text-text-muted mt-2">{t("protocolsDescription")}</p>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-4 text-sm">
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolMcpTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolMcpDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolMcpStep1")}</li>
<li>{t("protocolMcpStep2")}</li>
<li>{t("protocolMcpStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`omniroute --mcp`}</code>
</pre>
</article>
<article className="rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolA2aTitle")}</h3>
<p className="text-text-muted mt-1">{t("protocolA2aDesc")}</p>
<ol className="mt-3 list-decimal list-inside space-y-1 text-text-muted">
<li>{t("protocolA2aStep1")}</li>
<li>{t("protocolA2aStep2")}</li>
<li>{t("protocolA2aStep3")}</li>
</ol>
<pre className="mt-3 p-3 rounded-lg border border-border bg-bg overflow-x-auto text-xs">
<code>{`GET /.well-known/agent.json
POST /a2a (JSON-RPC: message/send | message/stream)`}</code>
</pre>
</article>
</div>
<div className="mt-4 rounded-lg border border-border p-4 bg-bg">
<h3 className="font-semibold">{t("protocolTroubleshootingTitle")}</h3>
<ul className="mt-2 list-disc list-inside text-sm text-text-muted space-y-1">
<li>{t("protocolTroubleshooting1")}</li>
<li>{t("protocolTroubleshooting2")}</li>
<li>{t("protocolTroubleshooting3")}</li>
</ul>
</div>
</section>
<section id="api-reference" className="rounded-2xl border border-border bg-bg-subtle p-6">
<h2 className="text-xl font-semibold">{t("apiReference")}</h2>
<div className="mt-4 overflow-x-auto">

View File

@@ -75,6 +75,8 @@
"docs": "Docs",
"issues": "Issues",
"endpoint": "Endpoint",
"mcp": "MCP",
"a2a": "A2A",
"apiManager": "API Manager",
"logs": "Logs",
"auditLog": "Audit Log",
@@ -128,6 +130,10 @@
"homeDescription": "Welcome to OmniRoute",
"endpoint": "Endpoint",
"endpointDescription": "API endpoint configuration",
"mcp": "MCP Management",
"mcpDescription": "Monitor MCP server process, tools, and operational controls",
"a2a": "A2A Management",
"a2aDescription": "Monitor Agent2Agent status, tasks, and streaming activity",
"settings": "Settings",
"settingsDescription": "Manage your preferences",
"openaiCompatible": "OpenAI Compatible",
@@ -658,7 +664,155 @@
"embedding": "Embedding",
"image": "Image",
"custom": "custom",
"modelsCount": "{count, plural, one {# model} other {# models}}"
"modelsCount": "{count, plural, one {# model} other {# models}}",
"sectionTitle": "Integration Surface",
"sectionDescription": "OpenAI-compatible APIs and operational protocol endpoints",
"tabApis": "OpenAI-compatible APIs",
"tabProtocols": "Protocols",
"tabsAria": "Endpoint sections",
"protocolsTitle": "Protocols",
"protocolsDescription": "MCP and A2A are first-class endpoints with dedicated observability and controls.",
"mcpCardTitle": "MCP Server",
"mcpCardDescription": "Model Context Protocol over stdio",
"a2aCardTitle": "A2A Server",
"a2aCardDescription": "Agent2Agent JSON-RPC endpoint",
"protocolToolsLabel": "Tools",
"protocolTasksLabel": "Tasks",
"protocolActiveStreamsLabel": "Active streams",
"protocolLastActivity": "Last activity",
"quickStart": "Quick Start",
"openMcpDashboard": "Open MCP management",
"openA2aDashboard": "Open A2A management",
"mcpQuickStartTitle": "MCP Quick Start",
"mcpQuickStartStep1": "Run the MCP server via `omniroute --mcp`.",
"mcpQuickStartStep2": "Configure your MCP client to connect over stdio transport.",
"mcpQuickStartStep3": "Invoke tools such as `omniroute_get_health` and `omniroute_list_combos`.",
"a2aQuickStartTitle": "A2A Quick Start",
"a2aQuickStartStep1": "Discover the agent card at `/.well-known/agent.json`.",
"a2aQuickStartStep2": "Send JSON-RPC requests to `POST /a2a` using `message/send` or `message/stream`.",
"a2aQuickStartStep3": "Track and control tasks using `tasks/get` and `tasks/cancel`."
},
"mcpDashboard": {
"loading": "Loading MCP dashboard...",
"activate": "activate",
"deactivate": "deactivate",
"confirmSwitchCombo": "Confirm {action} combo \"{combo}\"?",
"switchComboFailed": "Failed to switch combo state.",
"switchComboSuccess": "Combo \"{combo}\" updated.",
"confirmApplyProfile": "Apply resilience profile \"{profile}\"?",
"applyProfileFailed": "Failed to apply resilience profile.",
"applyProfileSuccess": "Profile \"{profile}\" applied.",
"confirmResetBreakers": "Reset all circuit breakers?",
"resetBreakersFailed": "Failed to reset circuit breakers.",
"resetBreakersSuccess": "Circuit breakers reset.",
"processStatus": "Process status",
"online": "Online",
"offline": "Offline",
"pid": "PID",
"sessionUptime": "Session uptime",
"lastHeartbeat": "Last heartbeat",
"activity24h": "Activity (24h)",
"totalCalls": "Total calls",
"successRate": "Success rate",
"avgLatency": "Avg latency",
"topTools": "Top tools",
"noToolCalls24h": "No tool calls in the last 24 hours.",
"runtimeDetails": "Runtime details",
"transport": "Transport",
"scopesEnforced": "Scopes enforced",
"yes": "yes",
"no": "no",
"lastCall": "Last call",
"heartbeatPath": "Heartbeat path",
"operationalControls": "Operational controls",
"switchCombo": "Switch combo",
"inactive": "inactive",
"active": "active",
"activateCombo": "Activate combo",
"deactivateCombo": "Deactivate combo",
"applyResilienceProfile": "Apply resilience profile",
"profileAggressive": "aggressive",
"profileBalanced": "balanced",
"profileConservative": "conservative",
"applyProfile": "Apply profile",
"resetCircuitBreakers": "Reset circuit breakers",
"resetCircuitBreakersHelp": "Clears current breaker state and failure counters for providers.",
"resetAllBreakers": "Reset all breakers",
"toolsAndScopes": "Tools and scopes",
"tableTool": "Tool",
"tableScopes": "Scopes",
"tablePhase": "Phase",
"tableAudit": "Audit",
"auditLog": "Audit log",
"auditSummary": "Calls: {total} | page {page} of {totalPages}",
"allTools": "All tools",
"allResults": "All results",
"success": "Success",
"failure": "Failure",
"apiKeyIdPlaceholder": "apiKeyId",
"loadingAuditEntries": "Loading audit entries...",
"noAuditEntriesForFilters": "No audit entries found for current filters.",
"tableTimestamp": "Timestamp",
"tableDuration": "Duration",
"tableResult": "Result",
"tableApiKey": "API key",
"failed": "failed",
"previous": "Previous",
"next": "Next"
},
"a2aDashboard": {
"loading": "Loading A2A dashboard...",
"confirmCancelTask": "Cancel task {taskId}?",
"cancelTaskFailed": "Failed to cancel task.",
"cancelTaskSuccess": "Task {taskId} cancelled.",
"smokeSendFailed": "message/send smoke test failed.",
"smokeSendSuccessWithTask": "message/send ok (task {taskId}).",
"smokeSendSuccess": "message/send ok.",
"smokeStreamFailed": "message/stream smoke test failed.",
"smokeStreamSuccessWithTask": "message/stream ok (task {taskId}{stateSuffix}).",
"smokeStreamNoTaskId": "message/stream finished without task id.",
"health": "Health",
"ok": "ok",
"totalTasks": "Total tasks",
"activeStreams": "Active streams",
"lastTask": "Last task",
"taskStateOverview": "Task state overview",
"state": {
"submitted": "submitted",
"working": "working",
"completed": "completed",
"failed": "failed",
"cancelled": "cancelled"
},
"agentCard": "Agent card",
"version": "Version",
"url": "URL",
"capabilities": "Capabilities",
"agentCardNotAvailable": "Agent card not available.",
"quickValidation": "Quick validation",
"quickValidationDescription": "Executes smoke calls through the live `/a2a` endpoint.",
"runMessageSend": "Run message/send",
"runMessageStream": "Run message/stream",
"taskManagement": "Task management",
"taskSummary": "{total} tasks | page {page} of {totalPages}",
"allStates": "all",
"allSkills": "all skills",
"loadingTasks": "Loading tasks...",
"noTasksForFilters": "No tasks found for current filters.",
"tableTask": "Task",
"tableSkill": "Skill",
"tableState": "State",
"tableUpdated": "Updated",
"tableActions": "Actions",
"view": "View",
"cancel": "Cancel",
"previous": "Previous",
"next": "Next",
"taskDetail": "Task detail",
"close": "Close",
"metadata": "Metadata",
"events": "Events",
"artifacts": "Artifacts"
},
"health": {
"title": "System Health",
@@ -1959,6 +2113,7 @@
"supportedProvidersToc": "Providers",
"commonUseCases": "Common Use Cases",
"clientCompatibility": "Client Compatibility",
"protocolsToc": "Protocols",
"apiReference": "API Reference",
"method": "Method",
"path": "Path",
@@ -2031,6 +2186,22 @@
"clientClaudeBullet1Prefix": "Use",
"clientClaudeBullet1Middle": "(Claude) or",
"clientClaudeBullet1Suffix": "(Antigravity) prefix.",
"protocolsTitle": "Protocols: MCP & A2A",
"protocolsDescription": "OmniRoute exposes two operational protocols in addition to OpenAI-compatible APIs: MCP for tool execution and A2A for agent-to-agent workflows.",
"protocolMcpTitle": "MCP (Model Context Protocol)",
"protocolMcpDesc": "Use MCP over stdio to let clients discover and call OmniRoute tools with audit visibility.",
"protocolMcpStep1": "Start MCP transport with `omniroute --mcp`.",
"protocolMcpStep2": "Point your MCP client to stdio transport.",
"protocolMcpStep3": "Call `omniroute_get_health` and `omniroute_list_combos` to validate connectivity.",
"protocolA2aTitle": "A2A (Agent2Agent)",
"protocolA2aDesc": "Use A2A JSON-RPC to submit tasks synchronously or via SSE streaming.",
"protocolA2aStep1": "Read `/.well-known/agent.json` for agent discovery.",
"protocolA2aStep2": "Send `message/send` or `message/stream` requests to `POST /a2a`.",
"protocolA2aStep3": "Manage task lifecycle with `tasks/get` and `tasks/cancel`.",
"protocolTroubleshootingTitle": "Protocol Troubleshooting",
"protocolTroubleshooting1": "If MCP status is offline, verify the stdio process is running and heartbeat file is updating.",
"protocolTroubleshooting2": "If A2A tasks stay in `working`, inspect `/api/a2a/tasks/:id` and stream events for terminal state.",
"protocolTroubleshooting3": "Use `/dashboard/mcp` and `/dashboard/a2a` for operational controls and audit visibility.",
"endpointChatNote": "OpenAI-compatible chat endpoint (default).",
"endpointResponsesNote": "Responses API endpoint (Codex, o-series).",
"endpointModelsNote": "Model catalog for all connected providers.",

View File

@@ -75,6 +75,8 @@
"docs": "Documentação",
"issues": "Problemas",
"endpoint": "Endpoint",
"mcp": "MCP",
"a2a": "A2A",
"apiManager": "Gerenciador API",
"logs": "Logs",
"auditLog": "Log de Auditoria",
@@ -108,6 +110,10 @@
"homeDescription": "Bem-vindo ao OmniRoute",
"endpoint": "Endpoint",
"endpointDescription": "Configuração de endpoint da API",
"mcp": "Gestão MCP",
"mcpDescription": "Monitore processo MCP, ferramentas e controles operacionais",
"a2a": "Gestão A2A",
"a2aDescription": "Monitore status Agent2Agent, tarefas e atividade de streaming",
"settings": "Configurações",
"settingsDescription": "Gerencie suas preferências",
"openaiCompatible": "Compatível com OpenAI",
@@ -636,7 +642,155 @@
"settingsApiDesc": "Ler e modificar a configuração do OmniRoute via API",
"categoryCore": "APIs Principais",
"categoryMedia": "Mídia e Multi-Modal",
"categoryUtility": "Utilidades e Gerenciamento"
"categoryUtility": "Utilidades e Gerenciamento",
"sectionTitle": "Superfície de Integração",
"sectionDescription": "APIs compatíveis com OpenAI e endpoints operacionais de protocolos",
"tabApis": "APIs compatíveis com OpenAI",
"tabProtocols": "Protocolos",
"tabsAria": "Seções de endpoint",
"protocolsTitle": "Protocolos",
"protocolsDescription": "MCP e A2A são endpoints de primeira classe com observabilidade e controles dedicados.",
"mcpCardTitle": "Servidor MCP",
"mcpCardDescription": "Model Context Protocol via stdio",
"a2aCardTitle": "Servidor A2A",
"a2aCardDescription": "Endpoint Agent2Agent JSON-RPC",
"protocolToolsLabel": "Ferramentas",
"protocolTasksLabel": "Tarefas",
"protocolActiveStreamsLabel": "Streams ativos",
"protocolLastActivity": "Última atividade",
"quickStart": "Início rápido",
"openMcpDashboard": "Abrir gestão MCP",
"openA2aDashboard": "Abrir gestão A2A",
"mcpQuickStartTitle": "MCP Início rápido",
"mcpQuickStartStep1": "Inicie o servidor MCP com `omniroute --mcp`.",
"mcpQuickStartStep2": "Configure o cliente MCP para conectar por transporte stdio.",
"mcpQuickStartStep3": "Execute ferramentas como `omniroute_get_health` e `omniroute_list_combos`.",
"a2aQuickStartTitle": "A2A Início rápido",
"a2aQuickStartStep1": "Descubra o agent card em `/.well-known/agent.json`.",
"a2aQuickStartStep2": "Envie requisições JSON-RPC para `POST /a2a` usando `message/send` ou `message/stream`.",
"a2aQuickStartStep3": "Acompanhe e controle tarefas com `tasks/get` e `tasks/cancel`."
},
"mcpDashboard": {
"loading": "Carregando painel MCP...",
"activate": "ativar",
"deactivate": "desativar",
"confirmSwitchCombo": "Confirmar {action} combo \"{combo}\"?",
"switchComboFailed": "Falha ao alternar estado do combo.",
"switchComboSuccess": "Combo \"{combo}\" atualizado.",
"confirmApplyProfile": "Aplicar perfil de resiliência \"{profile}\"?",
"applyProfileFailed": "Falha ao aplicar perfil de resiliência.",
"applyProfileSuccess": "Perfil \"{profile}\" aplicado.",
"confirmResetBreakers": "Resetar todos os circuit breakers?",
"resetBreakersFailed": "Falha ao resetar circuit breakers.",
"resetBreakersSuccess": "Circuit breakers resetados.",
"processStatus": "Status do processo",
"online": "Online",
"offline": "Offline",
"pid": "PID",
"sessionUptime": "Uptime da sessão",
"lastHeartbeat": "Último heartbeat",
"activity24h": "Atividade (24h)",
"totalCalls": "Total de chamadas",
"successRate": "Taxa de sucesso",
"avgLatency": "Latência média",
"topTools": "Top ferramentas",
"noToolCalls24h": "Sem chamadas de ferramenta nas últimas 24 horas.",
"runtimeDetails": "Detalhes de runtime",
"transport": "Transporte",
"scopesEnforced": "Scopes aplicados",
"yes": "sim",
"no": "não",
"lastCall": "Última chamada",
"heartbeatPath": "Caminho do heartbeat",
"operationalControls": "Controles operacionais",
"switchCombo": "Trocar combo",
"inactive": "inativo",
"active": "ativo",
"activateCombo": "Ativar combo",
"deactivateCombo": "Desativar combo",
"applyResilienceProfile": "Aplicar perfil de resiliência",
"profileAggressive": "agressivo",
"profileBalanced": "balanceado",
"profileConservative": "conservador",
"applyProfile": "Aplicar perfil",
"resetCircuitBreakers": "Resetar circuit breakers",
"resetCircuitBreakersHelp": "Limpa o estado atual de breaker e os contadores de falha dos provedores.",
"resetAllBreakers": "Resetar todos os breakers",
"toolsAndScopes": "Ferramentas e scopes",
"tableTool": "Ferramenta",
"tableScopes": "Scopes",
"tablePhase": "Fase",
"tableAudit": "Auditoria",
"auditLog": "Log de auditoria",
"auditSummary": "Chamadas: {total} | página {page} de {totalPages}",
"allTools": "Todas as ferramentas",
"allResults": "Todos os resultados",
"success": "Sucesso",
"failure": "Falha",
"apiKeyIdPlaceholder": "apiKeyId",
"loadingAuditEntries": "Carregando registros de auditoria...",
"noAuditEntriesForFilters": "Nenhum registro de auditoria para os filtros atuais.",
"tableTimestamp": "Timestamp",
"tableDuration": "Duração",
"tableResult": "Resultado",
"tableApiKey": "Chave API",
"failed": "falhou",
"previous": "Anterior",
"next": "Próxima"
},
"a2aDashboard": {
"loading": "Carregando painel A2A...",
"confirmCancelTask": "Cancelar tarefa {taskId}?",
"cancelTaskFailed": "Falha ao cancelar tarefa.",
"cancelTaskSuccess": "Tarefa {taskId} cancelada.",
"smokeSendFailed": "Falha no smoke test de message/send.",
"smokeSendSuccessWithTask": "message/send ok (tarefa {taskId}).",
"smokeSendSuccess": "message/send ok.",
"smokeStreamFailed": "Falha no smoke test de message/stream.",
"smokeStreamSuccessWithTask": "message/stream ok (tarefa {taskId}{stateSuffix}).",
"smokeStreamNoTaskId": "message/stream finalizado sem task id.",
"health": "Saúde",
"ok": "ok",
"totalTasks": "Total de tarefas",
"activeStreams": "Streams ativos",
"lastTask": "Última tarefa",
"taskStateOverview": "Visão de estados das tarefas",
"state": {
"submitted": "submetida",
"working": "executando",
"completed": "concluída",
"failed": "falhou",
"cancelled": "cancelada"
},
"agentCard": "Cartão do agente",
"version": "Versão",
"url": "URL",
"capabilities": "Capacidades",
"agentCardNotAvailable": "Cartão do agente indisponível.",
"quickValidation": "Validação rápida",
"quickValidationDescription": "Executa chamadas de smoke pelo endpoint `/a2a` em produção.",
"runMessageSend": "Executar message/send",
"runMessageStream": "Executar message/stream",
"taskManagement": "Gestão de tarefas",
"taskSummary": "{total} tarefas | página {page} de {totalPages}",
"allStates": "todos",
"allSkills": "todas as skills",
"loadingTasks": "Carregando tarefas...",
"noTasksForFilters": "Nenhuma tarefa encontrada para os filtros atuais.",
"tableTask": "Tarefa",
"tableSkill": "Skill",
"tableState": "Estado",
"tableUpdated": "Atualizada",
"tableActions": "Ações",
"view": "Ver",
"cancel": "Cancelar",
"previous": "Anterior",
"next": "Próxima",
"taskDetail": "Detalhe da tarefa",
"close": "Fechar",
"metadata": "Metadados",
"events": "Eventos",
"artifacts": "Artefatos"
},
"health": {
"title": "Saúde do Sistema",
@@ -1938,6 +2092,7 @@
"supportedProvidersToc": "Provedores",
"commonUseCases": "Casos de Uso Comuns",
"clientCompatibility": "Compatibilidade de Clientes",
"protocolsToc": "Protocolos",
"apiReference": "Referência da API",
"method": "Método",
"path": "Caminho",
@@ -2010,6 +2165,22 @@
"clientClaudeBullet1Prefix": "Use",
"clientClaudeBullet1Middle": "(Claude) ou",
"clientClaudeBullet1Suffix": "(Antigravity) como prefixo.",
"protocolsTitle": "Protocolos: MCP e A2A",
"protocolsDescription": "O OmniRoute expõe dois protocolos operacionais além das APIs compatíveis com OpenAI: MCP para execução de ferramentas e A2A para fluxos agente-para-agente.",
"protocolMcpTitle": "MCP (Model Context Protocol)",
"protocolMcpDesc": "Use MCP via stdio para permitir descoberta e execução de ferramentas OmniRoute com visibilidade de auditoria.",
"protocolMcpStep1": "Inicie o transporte MCP com `omniroute --mcp`.",
"protocolMcpStep2": "Aponte seu cliente MCP para transporte stdio.",
"protocolMcpStep3": "Chame `omniroute_get_health` e `omniroute_list_combos` para validar conectividade.",
"protocolA2aTitle": "A2A (Agent2Agent)",
"protocolA2aDesc": "Use A2A JSON-RPC para submeter tarefas de forma síncrona ou via SSE streaming.",
"protocolA2aStep1": "Leia `/.well-known/agent.json` para descoberta do agente.",
"protocolA2aStep2": "Envie `message/send` ou `message/stream` para `POST /a2a`.",
"protocolA2aStep3": "Gerencie ciclo de vida das tarefas com `tasks/get` e `tasks/cancel`.",
"protocolTroubleshootingTitle": "Troubleshooting de protocolos",
"protocolTroubleshooting1": "Se o status MCP estiver offline, verifique se o processo stdio está rodando e atualizando o heartbeat.",
"protocolTroubleshooting2": "Se tarefas A2A ficarem em `working`, inspecione `/api/a2a/tasks/:id` e os eventos de stream até estado terminal.",
"protocolTroubleshooting3": "Use `/dashboard/mcp` e `/dashboard/a2a` para controles operacionais e visibilidade de auditoria.",
"endpointChatNote": "Endpoint de chat compatível com OpenAI (padrão).",
"endpointResponsesNote": "Endpoint da API Responses (Codex, o-series).",
"endpointModelsNote": "Catálogo de modelos para todos os provedores conectados.",

View File

@@ -92,12 +92,17 @@ export function createA2AStream(
executeSkill: (
task: A2ATask
) => Promise<{ artifacts: Array<{ content: string }>; metadata: Record<string, unknown> }>,
abortSignal?: AbortSignal
abortSignal?: AbortSignal,
lifecycle?: {
onStart?: () => void;
onEnd?: () => void;
}
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
async start(controller) {
lifecycle?.onStart?.();
// Heartbeat interval
const heartbeatInterval = setInterval(() => {
try {
@@ -136,6 +141,7 @@ export function createA2AStream(
controller.enqueue(encoder.encode(createFailureEvent(task.id, msg)));
} finally {
clearInterval(heartbeatInterval);
lifecycle?.onEnd?.();
controller.close();
}
},

View File

@@ -47,6 +47,20 @@ export interface A2ATask {
expiresAt: string;
}
export interface TaskListFilter {
state?: TaskState;
skill?: string;
limit?: number;
offset?: number;
}
export interface A2ATaskStats {
counts: Record<TaskState, number>;
total: number;
activeStreams: number;
lastTaskAt: string | null;
}
// ============ Valid Transitions ============
const VALID_TRANSITIONS: Record<TaskState, TaskState[]> = {
@@ -63,6 +77,7 @@ export class A2ATaskManager {
private tasks = new Map<string, A2ATask>();
private readonly ttlMs: number;
private cleanupInterval: ReturnType<typeof setInterval>;
private activeStreams = 0;
constructor(ttlMinutes: number = 5) {
this.ttlMs = ttlMinutes * 60 * 1000;
@@ -125,12 +140,59 @@ export class A2ATaskManager {
return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client");
}
listTasks(filter?: { state?: TaskState; skill?: string; limit?: number }): A2ATask[] {
countTasks(filter?: Pick<TaskListFilter, "state" | "skill">): number {
let tasks = [...this.tasks.values()];
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
return tasks.length;
}
listTasks(filter?: TaskListFilter): A2ATask[] {
let tasks = [...this.tasks.values()];
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return tasks.slice(0, filter?.limit || 50);
const offset = Math.max(0, filter?.offset || 0);
const limit =
typeof filter?.limit === "number" && Number.isFinite(filter.limit)
? Math.max(1, Math.floor(filter.limit))
: 50;
return tasks.slice(offset, offset + limit);
}
beginStream() {
this.activeStreams += 1;
}
endStream() {
this.activeStreams = Math.max(0, this.activeStreams - 1);
}
getStats(): A2ATaskStats {
const counts: Record<TaskState, number> = {
submitted: 0,
working: 0,
completed: 0,
failed: 0,
cancelled: 0,
};
let lastTaskAt: string | null = null;
for (const task of this.tasks.values()) {
counts[task.state] += 1;
const updatedAt = new Date(task.updatedAt).getTime();
if (!Number.isFinite(updatedAt)) continue;
if (!lastTaskAt || updatedAt > new Date(lastTaskAt).getTime()) {
lastTaskAt = task.updatedAt;
}
}
return {
counts,
total: this.tasks.size,
activeStreams: this.activeStreams,
lastTaskAt,
};
}
private cleanupExpired() {

View File

@@ -86,6 +86,10 @@ function usePageInfo(pathname: string | null) {
return { title: t("cliTools"), description: t("cliToolsDescription"), breadcrumbs: [] };
if (pathname === "/dashboard")
return { title: t("home"), description: t("homeDescription"), breadcrumbs: [] };
if (pathname.includes("/mcp"))
return { title: t("mcp"), description: t("mcpDescription"), breadcrumbs: [] };
if (pathname.includes("/a2a"))
return { title: t("a2a"), description: t("a2aDescription"), breadcrumbs: [] };
if (pathname.includes("/endpoint"))
return { title: t("endpoint"), description: t("endpointDescription"), breadcrumbs: [] };
if (pathname.includes("/profile"))

View File

@@ -15,6 +15,8 @@ import { useTranslations } from "next-intl";
const navItemDefs = [
{ href: "/dashboard", i18nKey: "home", icon: "home", exact: true },
{ href: "/dashboard/endpoint", i18nKey: "endpoint", icon: "api" },
{ href: "/dashboard/mcp", i18nKey: "mcp", icon: "hub" },
{ href: "/dashboard/a2a", i18nKey: "a2a", icon: "group_work" },
{ href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" },
{ href: "/dashboard/providers", i18nKey: "providers", icon: "dns" },
{ href: "/dashboard/combos", i18nKey: "combos", icon: "layers" },