From 2bdc72a3ea6c261ecb6d5c7289fc6933a65836f2 Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:46:19 -0400 Subject: [PATCH] Fix endpoint visibility, A2A status, and API catalog (#1806) Integrated into release/v3.7.6 --- README.md | 41 +++--- docs/A2A-SERVER.md | 7 ++ docs/API_REFERENCE.md | 2 + docs/FEATURES.md | 4 +- docs/USER_GUIDE.md | 17 +-- public/sw.js | 16 ++- .../dashboard/endpoint/ApiEndpointsTab.tsx | 46 ++++++- .../dashboard/endpoint/EndpointPageClient.tsx | 31 ++++- .../__tests__/ApiEndpointsTab.test.tsx | 107 ++++++++++++++++ .../__tests__/EndpointPageClient.test.tsx | 3 + .../(dashboard)/dashboard/endpoint/page.tsx | 69 +++++++--- .../settings/components/AppearanceTab.tsx | 18 +++ src/app/a2a/route.ts | 20 +++ src/app/api/a2a/status/route.ts | 26 ++-- src/i18n/messages/en.json | 8 ++ src/i18n/messages/zh-CN.json | 8 ++ src/lib/db/settings.ts | 3 + src/shared/validation/schemas.ts | 3 + src/shared/validation/settingsSchemas.ts | 1 + src/types/settings.ts | 4 + tests/unit/a2a-enabled-route.test.ts | 118 ++++++++++++++++++ tests/unit/db-settings-crud.test.ts | 2 + tests/unit/settings-api.test.ts | 7 +- ...settings-schema-routing-strategies.test.ts | 18 +++ 24 files changed, 516 insertions(+), 63 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx create mode 100644 tests/unit/a2a-enabled-route.test.ts diff --git a/README.md b/README.md index a26c25f47b..0f678178a5 100644 --- a/README.md +++ b/README.md @@ -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:
🤝 A2A Setup (Agent2Agent) +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 diff --git a/docs/A2A-SERVER.md b/docs/A2A-SERVER.md index 9d61dd1870..cb8b18dd2c 100644 --- a/docs/A2A-SERVER.md +++ b/docs/A2A-SERVER.md @@ -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 | --- diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 087d5619f3..3b4c38ec29 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -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 diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 3dc803380e..d68c6a6db9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1ea97b1278..e1c3348a8b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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) | --- diff --git a/public/sw.js b/public/sw.js index eaa1d86398..2d3e2261d1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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; diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 3a82e76f0e..1bfaee3f81 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -66,6 +66,7 @@ const WEBHOOK_EVENTS = [ /* ─── Main Component ─────────────────────────────────── */ export default function ApiEndpointsTab() { const [catalog, setCatalog] = useState(null); + const [catalogError, setCatalogError] = useState(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() { {/* ═══ API CATALOG ═══ */} + {section === "catalog" && !catalog && ( + +
+
+ error +
+
+

API catalog unavailable

+

+ {catalogError || "The OpenAPI specification could not be loaded."} +

+ + open_in_new + Open JSON response + +
+
+
+ )} + {section === "catalog" && catalog && ( <> {/* Search & filter */} diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 15b855ad8b..10f3e25412 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -113,11 +113,13 @@ type CopyHandler = (text: string, key?: string) => void | Promise; 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) { @@ -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")); diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx new file mode 100644 index 0000000000..ce993e3663 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx @@ -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(); + }); + + 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"); + }); +}); diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx index 6b54fcf62f..3c92edfe9a 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx @@ -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; diff --git a/src/app/(dashboard)/dashboard/endpoint/page.tsx b/src/app/(dashboard)/dashboard/endpoint/page.tsx index fb97242642..4d5d159e3e 100644 --- a/src/app/(dashboard)/dashboard/endpoint/page.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/page.tsx @@ -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 (
- {status.loading ? "..." : status.online ? "Online" : "Offline"} + {loading ? "..." : online ? "Online" : "Offline"}
); diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index 50b6d102cb..ce24000c43 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -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} /> + +
+
+

{getSettingsLabel("showNgrokTunnel", "ngrok Tunnel")}

+

+ {getSettingsLabel( + "showNgrokTunnelDesc", + "Show ngrok Tunnel controls on the Endpoint page." + )} +

+
+ updateSetting("hideEndpointNgrokTunnel", !checked)} + disabled={loading} + /> +
diff --git a/src/app/a2a/route.ts b/src/app/a2a/route.ts index 1f9d7d0ec2..109fd53885 100644 --- a/src/app/a2a/route.ts +++ b/src/app/a2a/route.ts @@ -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) { diff --git a/src/app/api/a2a/status/route.ts b/src/app/api/a2a/status/route.ts index cc089883ee..a36e5335a6 100644 --- a/src/app/api/a2a/status/route.ts +++ b/src/app/api/a2a/status/route.ts @@ -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 ? { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b90bff4bc1..7c5c207a08 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index fc4e1c31b6..883b829edf 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -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": "当某个侧边栏分组中的所有项目都被隐藏时,该分组会自动隐藏", diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index fd5800e6ce..7157f6caad 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -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, diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 81dadfd652..d997a0b2cd 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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(), diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 91b70585a1..acec76d9b2 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -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(), diff --git a/src/types/settings.ts b/src/types/settings.ts index 1c956a708c..43edc4064f 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -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; } diff --git a/tests/unit/a2a-enabled-route.test.ts b/tests/unit/a2a-enabled-route.test.ts new file mode 100644 index 0000000000..2c52cdf9ac --- /dev/null +++ b/tests/unit/a2a-enabled-route.test.ts @@ -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; + + 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; + + 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"); +}); diff --git a/tests/unit/db-settings-crud.test.ts b/tests/unit/db-settings-crud.test.ts index 6c00895dba..9601243b13 100644 --- a/tests/unit/db-settings-crud.test.ts +++ b/tests/unit/db-settings-crud.test.ts @@ -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); diff --git a/tests/unit/settings-api.test.ts b/tests/unit/settings-api.test.ts index e5a1bd58c0..743a300a1a 100644 --- a/tests/unit/settings-api.test.ts +++ b/tests/unit/settings-api.test.ts @@ -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; 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; assert.equal(response.status, 200); assert.equal(body.antigravitySignatureCacheMode, "bypass"); diff --git a/tests/unit/settings-schema-routing-strategies.test.ts b/tests/unit/settings-schema-routing-strategies.test.ts index 170dc94a0c..fdacf9d37b 100644 --- a/tests/unit/settings-schema-routing-strategies.test.ts +++ b/tests/unit/settings-schema-routing-strategies.test.ts @@ -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); +});