mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat: Introduce new A2A and MCP API routes, enhance dashboard UI, update READMEs, and add E2E tests.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
36
src/app/api/a2a/status/route.ts
Normal file
36
src/app/api/a2a/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/a2a/tasks/[id]/cancel/route.ts
Normal file
15
src/app/api/a2a/tasks/[id]/cancel/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
17
src/app/api/a2a/tasks/[id]/route.ts
Normal file
17
src/app/api/a2a/tasks/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/a2a/tasks/route.ts
Normal file
46
src/app/api/a2a/tasks/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
39
src/app/api/mcp/audit/route.ts
Normal file
39
src/app/api/mcp/audit/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/mcp/audit/stats/route.ts
Normal file
12
src/app/api/mcp/audit/stats/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
57
src/app/api/mcp/status/route.ts
Normal file
57
src/app/api/mcp/status/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
22
src/app/api/mcp/tools/route.ts
Normal file
22
src/app/api/mcp/tools/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user