Fix endpoint visibility, A2A status, and API catalog (#1806)

Integrated into release/v3.7.6
This commit is contained in:
Randi
2026-04-30 10:46:19 -04:00
committed by GitHub
parent ef622834db
commit 2bdc72a3ea
24 changed files with 516 additions and 63 deletions

View File

@@ -1060,7 +1060,7 @@ docker compose --profile base up -d
docker compose --profile cli up -d
```
Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL.
Dashboard support for Docker deployments now includes a one-click **Cloudflare Quick Tunnel** on `Dashboard → Endpoints`. The first enable downloads `cloudflared` only when needed, starts a temporary tunnel to your current `/v1` endpoint, and shows the generated `https://*.trycloudflare.com/v1` URL directly below your normal public URL. Endpoint tunnel panels, including Cloudflare, Tailscale, and ngrok, can be shown or hidden from `Settings → Appearance` without changing active tunnel state.
Notes:
@@ -1502,24 +1502,24 @@ OmniRoute v3.6 is built as an operational platform, not just a relay proxy.
### ☁️ Deployment & Platform
| Feature | What It Does |
| ------------------------------ | --------------------------------------------------------------------- |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloud environments |
| 🚇 **Cloudflare Tunnel** 🆕 | One-click Quick Tunnel integration from the dashboard |
| 🔑 **API Key Model Filtering** | Native /v1/models response filtered via assigned Bearer context roles |
| ⚡ **Smart Cache Bypass** | Configurable TTL heuristics and forced refetch controls |
| 🔄 **Backup/Restore** | Export/import and disaster recovery flows |
| 🧙 **Onboarding Wizard** | First-run guided setup |
| 🔧 **CLI Tools Dashboard** | One-click setup for popular coding tools |
| 🎮 **Model Playground** | Test any provider/model/endpoint from the dashboard |
| 🔏 **CLI Fingerprint Toggle** | Per-provider fingerprint matching in Settings > Security |
| 🌐 **i18n (40+ languages)** | Full dashboard + docs language support with RTL coverage |
| 🧹 **Clear All Models** | One-click model list clearing in provider details |
| 👁️ **Sidebar Controls** 🆕 | Hide components and integrations from Appearance Settings |
| 📋 **Issue Templates** | Standardized GitHub templates for bugs and features |
| 📂 **Custom Data Directory** | `DATA_DIR` override for storage location |
| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` |
| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support |
| Feature | What It Does |
| ------------------------------ | ---------------------------------------------------------------------- |
| 🌐 **Deploy Anywhere** | Localhost, VPS, Docker, Cloud environments |
| 🚇 **Cloudflare Tunnel** 🆕 | One-click Quick Tunnel integration from the dashboard |
| 🔑 **API Key Model Filtering** | Native /v1/models response filtered via assigned Bearer context roles |
| ⚡ **Smart Cache Bypass** | Configurable TTL heuristics and forced refetch controls |
| 🔄 **Backup/Restore** | Export/import and disaster recovery flows |
| 🧙 **Onboarding Wizard** | First-run guided setup |
| 🔧 **CLI Tools Dashboard** | One-click setup for popular coding tools |
| 🎮 **Model Playground** | Test any provider/model/endpoint from the dashboard |
| 🔏 **CLI Fingerprint Toggle** | Per-provider fingerprint matching in Settings > Security |
| 🌐 **i18n (40+ languages)** | Full dashboard + docs language support with RTL coverage |
| 🧹 **Clear All Models** | One-click model list clearing in provider details |
| 👁️ **Visibility Controls** 🆕 | Hide sidebar items and Endpoint tunnel panels from Appearance Settings |
| 📋 **Issue Templates** | Standardized GitHub templates for bugs and features |
| 📂 **Custom Data Directory** | `DATA_DIR` override for storage location |
| 🌐 **V1 WebSocket Bridge** 🆕 | OpenAI-compatible WebSocket traffic proxied via `/v1/ws` |
| 🔑 **Sync Tokens & Bundle** 🆕 | Config sync tokens + versioned bundle endpoint with ETag support |
### Feature Deep Dive
@@ -1611,6 +1611,9 @@ Useful APIs for automation:
<details>
<summary><b>🤝 A2A Setup (Agent2Agent)</b></summary>
Enable A2A from **Endpoints → A2A** before sending JSON-RPC tasks. When the toggle is off,
`/api/a2a/status` reports `disabled` and `POST /a2a` returns HTTP 503.
Discover the agent:
```bash

View File

@@ -22,6 +22,12 @@ Authorization: Bearer YOUR_OMNIROUTE_API_KEY
If no API key is configured on the server, authentication is bypassed.
## Enablement
A2A is controlled by the **Endpoints → A2A** toggle and is disabled by default. When disabled,
`GET /api/a2a/status` reports `status: "disabled"` and `online: false`; JSON-RPC calls to
`POST /a2a` return HTTP 503 with JSON-RPC error code `-32000`.
---
## JSON-RPC 2.0 Methods
@@ -148,6 +154,7 @@ submitted → working → completed
| -32601 | Method or skill not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| -32000 | A2A endpoint is disabled |
---

View File

@@ -259,6 +259,8 @@ Response example:
| -------------------------- | ------ | ----------------------------------------------------------------------- |
| `/api/tunnels/cloudflared` | GET | Read Cloudflare Quick Tunnel install/runtime status for the dashboard |
| `/api/tunnels/cloudflared` | POST | Enable or disable the Cloudflare Quick Tunnel (`action=enable/disable`) |
| `/api/tunnels/ngrok` | GET | Read ngrok Tunnel runtime status for the dashboard |
| `/api/tunnels/ngrok` | POST | Enable or disable the ngrok Tunnel (`action=enable/disable`) |
### CLI Tools

View File

@@ -70,7 +70,7 @@ Customizable color themes for the entire dashboard. Choose from 7 preset colors
Comprehensive settings panel with tabs:
- **General** — System storage, backup management (export/import database)
- **Appearance** — Theme selector (dark/light/system), color theme presets and custom colors, health log visibility, sidebar item visibility controls
- **Appearance** — Theme selector (dark/light/system), color theme presets and custom colors, health log visibility, sidebar item visibility controls, Endpoint tunnel visibility controls
- **Security** — API endpoint protection, custom provider blocking, IP filtering, session info
- **Routing** — Model aliases, background task degradation
- **Resilience** — Rate limit persistence, circuit breaker tuning, auto-disable banned accounts, provider expiration monitoring, **Context Relay** handoff threshold and summary model configuration
@@ -177,7 +177,7 @@ Real-time request logging with filtering by provider, model, account, and API ke
## 🌐 API Endpoint
Your unified API endpoint with capability breakdown: Chat Completions, Responses API, Embeddings, Image Generation, Reranking, Audio Transcription, Text-to-Speech, Moderations, and registered API keys. Cloudflare Quick Tunnel integration and cloud proxy support for remote access.
Your unified API endpoint with capability breakdown: Chat Completions, Responses API, Embeddings, Image Generation, Reranking, Audio Transcription, Text-to-Speech, Moderations, and registered API keys. Cloudflare Quick Tunnel, Tailscale Funnel, ngrok Tunnel, and cloud proxy support are available for remote access.
![Endpoint Dashboard](screenshots/09-endpoint.png)

View File

@@ -685,6 +685,7 @@ Returns models grouped by provider with types (`chat`, `embedding`, `image`).
- Managed Quick Tunnels default to HTTP/2 transport to avoid noisy QUIC UDP buffer warnings in constrained containers
- Set `CLOUDFLARED_PROTOCOL=quic` or `auto` if you want to override the managed transport choice
- Set `CLOUDFLARED_BIN` if you prefer using a preinstalled `cloudflared` binary instead of the managed download
- Cloudflare Quick Tunnel, Tailscale Funnel, and ngrok Tunnel panels can be shown or hidden in **Settings → Appearance**. Hiding a panel does not stop a running tunnel.
### LLM Gateway Intelligence (Phase 9)
@@ -837,14 +838,14 @@ curl -X POST http://localhost:20128/api/db-backups/import \
The settings page is organized into 6 tabs for easy navigation:
| Tab | Contents |
| -------------- | -------------------------------------------------------------------------------------------- |
| **General** | System storage tools, appearance settings, theme controls, and per-item sidebar visibility |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5) |
| Tab | Contents |
| -------------- | ------------------------------------------------------------------------------------------------------------- |
| **General** | System storage tools, appearance settings, theme controls, sidebar visibility, and Endpoint tunnel visibility |
| **Security** | Login/Password settings, IP Access Control, API auth for `/models`, and Provider Blocking |
| **Routing** | Global routing strategy (6 options), wildcard model aliases, fallback chains, combo defaults |
| **Resilience** | Request queue, connection cooldown, provider breaker config, and wait-for-cooldown behavior |
| **AI** | Thinking budget configuration, global system prompt injection, prompt cache stats |
| **Advanced** | Global proxy configuration (HTTP/SOCKS5) |
---

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = "omniroute-pwa-v1";
const CACHE_NAME = "omniroute-pwa-v2";
const APP_SHELL = [
"/",
"/offline",
@@ -38,6 +38,7 @@ self.addEventListener("fetch", (event) => {
const isExcludedPath = EXCLUDED_PATH_PREFIXES.some((prefix) =>
requestUrl.pathname.startsWith(prefix)
);
const isNextAsset = requestUrl.pathname.startsWith("/_next/");
const destination = event.request.destination;
const isStaticAsset = ["style", "script", "image", "font"].includes(destination);
const isNavigateRequest = event.request.mode === "navigate";
@@ -61,6 +62,19 @@ self.addEventListener("fetch", (event) => {
return fetch(event.request);
}
if (isNextAsset) {
try {
const networkResponse = await fetch(event.request);
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
}
return networkResponse;
} catch {
return (await caches.match(event.request)) || Response.error();
}
}
const cachedResponse = await caches.match(event.request);
if (cachedResponse) {
return cachedResponse;

View File

@@ -66,6 +66,7 @@ const WEBHOOK_EVENTS = [
/* ─── Main Component ─────────────────────────────────── */
export default function ApiEndpointsTab() {
const [catalog, setCatalog] = useState<CatalogData | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [section, setSection] = useState<"catalog" | "webhooks">("catalog");
const [search, setSearch] = useState("");
@@ -93,17 +94,26 @@ export default function ApiEndpointsTab() {
const res = await fetch("/api/openapi/spec");
if (res.ok) {
const data = await res.json();
return data;
return { data: data as CatalogData, error: null };
}
} catch {}
return null;
const body = await res.json().catch(() => null);
const message =
body && typeof body.error === "string"
? body.error
: `API catalog request failed with HTTP ${res.status}`;
return { data: null, error: message };
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load API catalog";
return { data: null, error: message };
}
};
useEffect(() => {
let cancelled = false;
loadCatalog().then((data) => {
loadCatalog().then((result) => {
if (!cancelled) {
setCatalog(data);
setCatalog(result.data);
setCatalogError(result.error);
setLoading(false);
}
});
@@ -340,6 +350,32 @@ export default function ApiEndpointsTab() {
</div>
{/* ═══ API CATALOG ═══ */}
{section === "catalog" && !catalog && (
<Card className="p-6">
<div className="flex items-start gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-red-500/10">
<span className="material-symbols-outlined text-[20px] text-red-500">error</span>
</div>
<div>
<h3 className="text-sm font-semibold text-text-main">API catalog unavailable</h3>
<p className="text-xs text-text-muted mt-1">
{catalogError || "The OpenAPI specification could not be loaded."}
</p>
<a
href="/api/openapi/spec"
target="_blank"
rel="noopener"
className="inline-flex items-center gap-1 mt-3 px-2.5 py-1.5 text-xs font-medium rounded-lg
bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
Open JSON response
</a>
</div>
</div>
</Card>
)}
{section === "catalog" && catalog && (
<>
{/* Search & filter */}

View File

@@ -113,11 +113,13 @@ type CopyHandler = (text: string, key?: string) => void | Promise<void>;
type EndpointTunnelVisibility = {
showCloudflaredTunnel: boolean;
showTailscaleFunnel: boolean;
showNgrokTunnel: boolean;
};
const DEFAULT_TUNNEL_VISIBILITY: EndpointTunnelVisibility = {
showCloudflaredTunnel: true,
showTailscaleFunnel: true,
showNgrokTunnel: true,
};
function runEndpointBackgroundTask(taskName: string, task: () => Promise<unknown>) {
@@ -313,7 +315,9 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
if (tunnelVisibility.showTailscaleFunnel) {
runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true));
}
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
if (tunnelVisibility.showNgrokTunnel) {
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
}
};
void loadPage();
@@ -427,6 +431,7 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
const tunnelVisibility = {
showCloudflaredTunnel: data.hideEndpointCloudflaredTunnel !== true,
showTailscaleFunnel: data.hideEndpointTailscaleFunnel !== true,
showNgrokTunnel: data.hideEndpointNgrokTunnel !== true,
};
if (!shouldApplyState()) {
@@ -445,6 +450,7 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
}
setShowCloudflaredTunnel(tunnelVisibility.showCloudflaredTunnel);
setShowTailscaleFunnel(tunnelVisibility.showTailscaleFunnel);
setShowNgrokTunnel(tunnelVisibility.showNgrokTunnel);
if (!tunnelVisibility.showCloudflaredTunnel) {
setCloudflaredStatus(null);
@@ -454,6 +460,10 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
setTailscaleStatus(null);
setTailscaleNotice(null);
}
if (!tunnelVisibility.showNgrokTunnel) {
setNgrokStatus(null);
setNgrokNotice(null);
}
return tunnelVisibility;
}
@@ -501,6 +511,13 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
}
}, [tailscaleNotice]);
useEffect(() => {
if (ngrokNotice) {
const timer = setTimeout(() => setNgrokNotice(null), 5000);
return () => clearTimeout(timer);
}
}, [ngrokNotice]);
useEffect(() => {
const interval = setInterval(() => {
void fetchProtocolStatus();
@@ -510,9 +527,19 @@ export default function APIPageClient({ machineId }: APIPageClientProps) {
if (showTailscaleFunnel) {
void fetchTailscaleStatus(true);
}
if (showNgrokTunnel) {
void fetchNgrokStatus(true);
}
}, 30000);
return () => clearInterval(interval);
}, [fetchCloudflaredStatus, fetchTailscaleStatus, showCloudflaredTunnel, showTailscaleFunnel]);
}, [
fetchCloudflaredStatus,
fetchNgrokStatus,
fetchTailscaleStatus,
showCloudflaredTunnel,
showNgrokTunnel,
showTailscaleFunnel,
]);
const dispatchCloudChange = () => {
globalThis.dispatchEvent(new Event("cloud-status-changed"));

View File

@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ApiEndpointsTab from "../ApiEndpointsTab";
function jsonResponse(data: unknown, status = 200) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => data,
} as Response;
}
const cleanupCallbacks: Array<() => void> = [];
async function waitForText(text: string) {
const startedAt = Date.now();
while (!document.body.textContent?.includes(text)) {
if (Date.now() - startedAt > 1000) {
throw new Error(`Timed out waiting for text: ${text}`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
function renderApiEndpointsTab() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
let mounted = true;
act(() => {
root.render(<ApiEndpointsTab />);
});
const unmount = () => {
if (!mounted) return;
act(() => {
root.unmount();
});
container.remove();
mounted = false;
};
cleanupCallbacks.push(unmount);
}
describe("ApiEndpointsTab", () => {
const fetchMock = vi.fn();
beforeEach(() => {
fetchMock.mockReset();
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
it("shows an API catalog error state instead of a blank page", async () => {
fetchMock.mockResolvedValue(jsonResponse({ error: "openapi.yaml not found" }, 404));
renderApiEndpointsTab();
await waitForText("API catalog unavailable");
expect(document.body.textContent).toContain("openapi.yaml not found");
expect(document.body.textContent).toContain("Open JSON response");
});
it("renders catalog content when the OpenAPI catalog loads", async () => {
fetchMock.mockResolvedValue(
jsonResponse({
info: { title: "OmniRoute API", version: "3.7.6" },
servers: [],
tags: [{ name: "Chat" }],
endpoints: [
{
method: "POST",
path: "/api/v1/chat/completions",
tags: ["Chat"],
summary: "Create chat completion",
description: "Create chat completion",
security: true,
parameters: [],
requestBody: true,
responses: ["200"],
},
],
schemas: [],
})
);
renderApiEndpointsTab();
await waitForText("OmniRoute API");
expect(document.body.textContent).toContain("1 endpoints across 1 categories");
expect(document.body.textContent).toContain("/api/v1/chat/completions");
});
});

View File

@@ -170,6 +170,7 @@ describe("EndpointPageClient", () => {
cloudConfigured: false,
hideEndpointCloudflaredTunnel: true,
hideEndpointTailscaleFunnel: true,
hideEndpointNgrokTunnel: true,
machineId: "machine-12345678",
})
);
@@ -196,6 +197,7 @@ describe("EndpointPageClient", () => {
expect(fetchMock).toHaveBeenCalledWith("/v1/models");
expect(fetchMock).not.toHaveBeenCalledWith("/api/tunnels/cloudflared", expect.anything());
expect(fetchMock).not.toHaveBeenCalledWith("/api/tunnels/tailscale", expect.anything());
expect(fetchMock).not.toHaveBeenCalledWith("/api/tunnels/ngrok", expect.anything());
modelsDeferred.resolve(
jsonResponse({
@@ -238,6 +240,7 @@ describe("EndpointPageClient", () => {
cloudConfigured: false,
hideEndpointCloudflaredTunnel: false,
hideEndpointTailscaleFunnel: false,
hideEndpointNgrokTunnel: false,
})
);
await settingsDeferred.promise;

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { SegmentedControl } from "@/shared/components";
import { Card, SegmentedControl } from "@/shared/components";
import EndpointPageClient from "./EndpointPageClient";
import McpDashboardPage from "./components/MCPDashboard";
import A2ADashboardPage from "./components/A2ADashboard";
@@ -30,40 +30,39 @@ function ServiceToggle({
onToggle: () => void;
toggling: boolean;
}) {
const online = enabled && status.online;
const loading = enabled && status.loading;
return (
<div className="flex items-center gap-3 ml-auto">
<div
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border"
style={{
borderColor: status.loading
borderColor: loading
? "var(--color-border)"
: status.online
: online
? "rgba(34,197,94,0.3)"
: "rgba(239,68,68,0.3)",
background: status.loading
background: loading
? "transparent"
: status.online
: online
? "rgba(34,197,94,0.1)"
: "rgba(239,68,68,0.1)",
color: status.loading
? "var(--color-text-muted)"
: status.online
? "rgb(34,197,94)"
: "rgb(239,68,68)",
color: loading ? "var(--color-text-muted)" : online ? "rgb(34,197,94)" : "rgb(239,68,68)",
}}
>
<span
className="inline-block w-2 h-2 rounded-full"
style={{
background: status.loading
background: loading
? "var(--color-text-muted)"
: status.online
: online
? "rgb(34,197,94)"
: "rgb(239,68,68)",
animation: status.online ? "pulse 2s infinite" : "none",
animation: online ? "pulse 2s infinite" : "none",
}}
/>
{status.loading ? "..." : status.online ? "Online" : "Offline"}
{loading ? "..." : online ? "Online" : "Offline"}
</div>
<button
@@ -97,6 +96,38 @@ function ServiceToggle({
);
}
function DisabledServicePanel({ title, description }: { title: string; description: string }) {
return (
<Card className="p-6">
<div className="flex items-start gap-3">
<div
className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg"
style={{ background: "var(--color-bg-tertiary)" }}
>
<span
aria-hidden="true"
className="relative block size-5 rounded-full border-2"
style={{ borderColor: "var(--color-text-muted)", color: "var(--color-text-muted)" }}
>
<span
className="absolute left-1/2 top-[-3px] h-3 w-0.5 -translate-x-1/2 rounded-full"
style={{ background: "var(--color-text-muted)" }}
/>
</span>
</div>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
{title}
</h2>
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{description}
</p>
</div>
</div>
</Card>
);
}
/* ────── Transport Selector ────── */
function TransportSelector({
value,
@@ -378,7 +409,15 @@ export default function EndpointPage() {
{activeTab === "endpoint-proxy" && <EndpointPageClient machineId="" />}
{activeTab === "mcp" && <McpDashboardPage />}
{activeTab === "a2a" && <A2ADashboardPage />}
{activeTab === "a2a" &&
(a2aEnabled ? (
<A2ADashboardPage />
) : (
<DisabledServicePanel
title="A2A is disabled"
description="Enable A2A above to view task telemetry, agent details, and validation tools."
/>
))}
{activeTab === "api-endpoints" && <ApiEndpointsTab />}
</div>
);

View File

@@ -38,6 +38,7 @@ export default function AppearanceTab() {
const comboConfigMode = normalizeComboConfigMode(settings[COMBO_CONFIG_MODE_SETTING_KEY]);
const showCloudflaredTunnel = settings.hideEndpointCloudflaredTunnel !== true;
const showTailscaleFunnel = settings.hideEndpointTailscaleFunnel !== true;
const showNgrokTunnel = settings.hideEndpointNgrokTunnel !== true;
const getSettingsLabel = (key: string, fallback: string) =>
typeof t.has === "function" && t.has(key) ? t(key) : fallback;
@@ -308,6 +309,23 @@ export default function AppearanceTab() {
disabled={loading}
/>
</div>
<div className="flex items-center justify-between gap-4 px-4 py-3">
<div>
<p className="font-medium">{getSettingsLabel("showNgrokTunnel", "ngrok Tunnel")}</p>
<p className="text-sm text-text-muted">
{getSettingsLabel(
"showNgrokTunnelDesc",
"Show ngrok Tunnel controls on the Endpoint page."
)}
</p>
</div>
<Toggle
checked={showNgrokTunnel}
onChange={(checked) => updateSetting("hideEndpointNgrokTunnel", !checked)}
disabled={loading}
/>
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import { getTaskManager } from "@/lib/a2a/taskManager";
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
import { getSettings } from "@/lib/db/settings";
type A2AMessage = { role: string; content: string };
@@ -86,6 +87,22 @@ function jsonRpcResult(id: string | number | null, result: unknown) {
return NextResponse.json({ jsonrpc: "2.0", id, result });
}
async function rejectIfA2ADisabled(id: string | number | null) {
const settings = await getSettings();
if (settings.a2aEnabled === true) return null;
return NextResponse.json(
{
jsonrpc: "2.0",
id,
error: {
code: -32000,
message: "A2A endpoint is disabled. Enable it from the Endpoints page.",
},
},
{ status: 503 }
);
}
// ============ Route Handler ============
export async function POST(req: NextRequest) {
@@ -107,6 +124,9 @@ export async function POST(req: NextRequest) {
return jsonRpcError(id || null, -32600, "Invalid request: missing jsonrpc or method");
}
const disabledResponse = await rejectIfA2ADisabled(id ?? null);
if (disabledResponse) return disabledResponse;
const tm = getTaskManager();
switch (method) {

View File

@@ -1,22 +1,30 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { getSettings } from "@/lib/db/settings";
export async function GET() {
try {
const tm = getTaskManager();
const stats = tm.getStats();
const [settings, stats] = await Promise.all([
getSettings(),
Promise.resolve(getTaskManager().getStats()),
]);
const enabled = settings.a2aEnabled === true;
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;
if (enabled) {
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",
status: enabled ? "ok" : "disabled",
online: enabled,
enabled,
tasks: stats,
agent: agentCard
? {

View File

@@ -3213,6 +3213,14 @@
"themeLight": "Light",
"themeDark": "Dark",
"themeSystem": "System",
"endpointTunnelVisibility": "Endpoint tunnel visibility",
"endpointTunnelVisibilityDesc": "Hide tunnel controls from the Endpoint page without changing tunnel state.",
"showCloudflareTunnel": "Cloudflare Quick Tunnel",
"showCloudflareTunnelDesc": "Show Cloudflare Quick Tunnel controls on the Endpoint page.",
"showTailscaleFunnel": "Tailscale Funnel",
"showTailscaleFunnelDesc": "Show Tailscale Funnel controls on the Endpoint page.",
"showNgrokTunnel": "ngrok Tunnel",
"showNgrokTunnelDesc": "Show ngrok Tunnel controls on the Endpoint page.",
"sidebarVisibility": "Hide sidebar items",
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter without disabling any features",
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when all of its entries are hidden",

View File

@@ -2930,6 +2930,14 @@
"themeLight": "浅色",
"themeDark": "深色",
"themeSystem": "系统",
"endpointTunnelVisibility": "端点隧道可见性",
"endpointTunnelVisibilityDesc": "隐藏端点页面中的隧道控制项,但不改变隧道运行状态。",
"showCloudflareTunnel": "Cloudflare 快速隧道",
"showCloudflareTunnelDesc": "在端点页面显示 Cloudflare Quick Tunnel 控制项。",
"showTailscaleFunnel": "Tailscale Funnel",
"showTailscaleFunnelDesc": "在端点页面显示 Tailscale Funnel 控制项。",
"showNgrokTunnel": "ngrok Tunnel",
"showNgrokTunnelDesc": "在端点页面显示 ngrok Tunnel 控制项。",
"sidebarVisibility": "隐藏侧边栏项目",
"sidebarVisibilityDesc": "可以隐藏任意侧边栏导航项,以减少视觉负担,但不会禁用任何功能",
"sidebarVisibilityHint": "当某个侧边栏分组中的所有项目都被隐藏时,该分组会自动隐藏",

View File

@@ -53,9 +53,12 @@ export async function getSettings() {
maxRetryIntervalSec: 30,
antigravitySignatureCacheMode: "enabled",
requireLogin: true,
mcpEnabled: false,
a2aEnabled: false,
hiddenSidebarItems: [],
hideEndpointCloudflaredTunnel: false,
hideEndpointTailscaleFunnel: false,
hideEndpointNgrokTunnel: false,
comboConfigMode: "guided",
alwaysPreserveClientCache: "auto",
idempotencyWindowMs: 5000,

View File

@@ -436,6 +436,9 @@ export const updateSettingsSchema = z.object({
setupComplete: z.boolean().optional(),
blockedProviders: z.array(z.string().max(100)).optional(),
hideHealthCheckLogs: z.boolean().optional(),
hideEndpointCloudflaredTunnel: z.boolean().optional(),
hideEndpointTailscaleFunnel: z.boolean().optional(),
hideEndpointNgrokTunnel: z.boolean().optional(),
bruteForceProtection: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),

View File

@@ -47,6 +47,7 @@ export const updateSettingsSchema = z.object({
hideHealthCheckLogs: z.boolean().optional(),
hideEndpointCloudflaredTunnel: z.boolean().optional(),
hideEndpointTailscaleFunnel: z.boolean().optional(),
hideEndpointNgrokTunnel: z.boolean().optional(),
debugMode: z.boolean().optional(),
hiddenSidebarItems: z.array(z.enum(HIDEABLE_SIDEBAR_ITEM_IDS)).optional(),
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),

View File

@@ -19,9 +19,13 @@ export interface Settings {
requestRetry: number;
maxRetryIntervalSec: number;
jwtSecret?: string;
mcpEnabled?: boolean;
mcpTransport?: "stdio" | "sse" | "streamable-http";
a2aEnabled?: boolean;
hideHealthCheckLogs?: boolean;
hideEndpointCloudflaredTunnel?: boolean;
hideEndpointTailscaleFunnel?: boolean;
hideEndpointNgrokTunnel?: boolean;
hiddenSidebarItems?: HideableSidebarItemId[];
resilienceSettings?: ResilienceSettings;
}

View File

@@ -0,0 +1,118 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { NextRequest } from "next/server";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-a2a-enabled-route-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_OMNIROUTE_API_KEY = process.env.OMNIROUTE_API_KEY;
process.env.DATA_DIR = TEST_DATA_DIR;
delete process.env.OMNIROUTE_API_KEY;
const core = await import("../../src/lib/db/core.ts");
const settingsDb = await import("../../src/lib/db/settings.ts");
const statusRoute = await import("../../src/app/api/a2a/status/route.ts");
const a2aRoute = await import("../../src/app/a2a/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
function makeJsonRpcRequest(body: unknown): NextRequest {
return new Request("http://localhost/a2a", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as unknown as NextRequest;
}
test.beforeEach(async () => {
delete process.env.OMNIROUTE_API_KEY;
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
if (ORIGINAL_OMNIROUTE_API_KEY === undefined) {
delete process.env.OMNIROUTE_API_KEY;
} else {
process.env.OMNIROUTE_API_KEY = ORIGINAL_OMNIROUTE_API_KEY;
}
});
test("A2A status reports disabled and offline when the endpoint is off", async () => {
const response = await statusRoute.GET();
const body = (await response.json()) as Record<string, unknown>;
assert.equal(response.status, 200);
assert.equal(body.status, "disabled");
assert.equal(body.enabled, false);
assert.equal(body.online, false);
assert.equal(body.agent, null);
});
test("A2A JSON-RPC rejects requests while the endpoint is disabled", async () => {
const response = await a2aRoute.POST(
makeJsonRpcRequest({
jsonrpc: "2.0",
id: "disabled-check",
method: "message/send",
params: { message: { role: "user", content: "hello" } },
})
);
const body = (await response.json()) as {
id?: string | number | null;
error?: { code?: number; message?: string };
};
assert.equal(response.status, 503);
assert.equal(body.id, "disabled-check");
assert.equal(body.error?.code, -32000);
assert.match(body.error?.message || "", /disabled/i);
});
test("A2A JSON-RPC checks auth before returning disabled state", async () => {
process.env.OMNIROUTE_API_KEY = "test-secret";
const response = await a2aRoute.POST(
makeJsonRpcRequest({
jsonrpc: "2.0",
id: "unauthorized-disabled-check",
method: "message/send",
params: { message: { role: "user", content: "hello" } },
})
);
const body = (await response.json()) as {
error?: { code?: number; message?: string };
};
assert.equal(response.status, 400);
assert.equal(body.error?.code, -32600);
assert.match(body.error?.message || "", /Unauthorized/i);
});
test("A2A status reports online only after enabling the endpoint", async () => {
await settingsDb.updateSettings({ a2aEnabled: true });
const response = await statusRoute.GET();
const body = (await response.json()) as Record<string, unknown>;
assert.equal(response.status, 200);
assert.equal(body.status, "ok");
assert.equal(body.enabled, true);
assert.equal(body.online, true);
assert.equal(typeof body.tasks, "object");
});

View File

@@ -70,6 +70,8 @@ test("getSettings exposes defaults and updateSettings persists typed values", as
assert.equal(defaults.maxRetryIntervalSec, 30);
assert.equal(defaults.antigravitySignatureCacheMode, "enabled");
assert.equal(defaults.comboConfigMode, "guided");
assert.equal(defaults.mcpEnabled, false);
assert.equal(defaults.a2aEnabled, false);
assert.equal(updated.requireLogin, false);
assert.equal(updated.cloudEnabled, true);
assert.equal(updated.stickyRoundRobinLimit, 7);

View File

@@ -141,18 +141,21 @@ describe("Settings API - persisted preferences", () => {
body: {
hideEndpointCloudflaredTunnel: true,
hideEndpointTailscaleFunnel: true,
hideEndpointNgrokTunnel: true,
},
})
);
const body = (await response.json()) as any;
const body = (await response.json()) as Record<string, unknown>;
assert.equal(response.status, 200);
assert.equal(body.hideEndpointCloudflaredTunnel, true);
assert.equal(body.hideEndpointTailscaleFunnel, true);
assert.equal(body.hideEndpointNgrokTunnel, true);
const settings = await harness.getSettings();
assert.equal(settings.hideEndpointCloudflaredTunnel, true);
assert.equal(settings.hideEndpointTailscaleFunnel, true);
assert.equal(settings.hideEndpointNgrokTunnel, true);
});
test("PUT /api/settings reuses the PATCH update flow", async () => {
@@ -162,7 +165,7 @@ describe("Settings API - persisted preferences", () => {
body: { antigravitySignatureCacheMode: "bypass" },
})
);
const body = (await response.json()) as any;
const body = (await response.json()) as Record<string, unknown>;
assert.equal(response.status, 200);
assert.equal(body.antigravitySignatureCacheMode, "bypass");

View File

@@ -48,3 +48,21 @@ test("settings schemas accept combo configuration modes", () => {
assert.equal(settingsRouteSchema.safeParse({ comboConfigMode: "compact" }).success, false);
assert.equal(sharedSettingsSchema.safeParse({ comboConfigMode: "compact" }).success, false);
});
test("settings schemas accept endpoint tunnel visibility toggles", () => {
const payload = {
hideEndpointCloudflaredTunnel: true,
hideEndpointTailscaleFunnel: true,
hideEndpointNgrokTunnel: true,
};
const routeParsed = settingsRouteSchema.parse(payload);
const sharedParsed = sharedSettingsSchema.parse(payload);
assert.equal(routeParsed.hideEndpointCloudflaredTunnel, true);
assert.equal(routeParsed.hideEndpointTailscaleFunnel, true);
assert.equal(routeParsed.hideEndpointNgrokTunnel, true);
assert.equal(sharedParsed.hideEndpointCloudflaredTunnel, true);
assert.equal(sharedParsed.hideEndpointTailscaleFunnel, true);
assert.equal(sharedParsed.hideEndpointNgrokTunnel, true);
});