mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
Fix endpoint visibility, A2A status, and API catalog (#1806)
Integrated into release/v3.7.6
This commit is contained in:
41
README.md
41
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:
|
||||
<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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
16
public/sw.js
16
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;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "当某个侧边栏分组中的所有项目都被隐藏时,该分组会自动隐藏",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
118
tests/unit/a2a-enabled-route.test.ts
Normal file
118
tests/unit/a2a-enabled-route.test.ts
Normal 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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user