diff --git a/next.config.mjs b/next.config.mjs index 96024ac858..b0c1cdb32c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -145,7 +145,7 @@ const nextConfig = { "process", ], transpilePackages: ["@omniroute/open-sse", "@lobehub/icons"], - allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.*"], + allowedDevOrigins: ["localhost", "127.0.0.1", "192.168.0.250"], typescript: { // TODO: Re-enable after fixing all sub-component useTranslations scope issues ignoreBuildErrors: true, diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 8e3522fd55..6c2590eb55 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -919,6 +919,31 @@ export const REGISTRY: Record = { ], }, + opencode: { + id: "opencode", + alias: "oc", + format: "openai", + executor: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + modelsUrl: "https://opencode.ai/zen/v1/models", + authType: "apikey", + authHeader: "Authorization", + authPrefix: "Bearer", + passthroughModels: true, + defaultContextLength: 200000, + models: [ + { id: "big-pickle", name: "Big Pickle" }, + { id: "minimax-m2.5-free", name: "MiniMax M2.5 Free", contextLength: 204800 }, + { id: "ling-2.6-1t-free", name: "Ling 2.6 Free", contextLength: 262000 }, + { + id: "trinity-large-preview-free", + name: "Trinity Large Preview Free", + contextLength: 131000, + }, + { id: "nemotron-3-super-free", name: "Nemotron 3 Super Free", contextLength: 1000000 }, + ], + }, + "opencode-go": { id: "opencode-go", alias: "opencode-go", diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 5c4ae67411..5351677789 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -1193,12 +1193,16 @@ export class CodexExecutor extends BaseExecutor { return null; } const result = await getAccessToken("codex", credentials, log); - if (!result || result.error) { + if (!result) { + log?.warn?.("TOKEN_REFRESH", "Codex: token refresh failed — re-authentication required"); + return null; + } + if (result.error) { log?.warn?.( "TOKEN_REFRESH", - `Codex: token refresh failed${result?.error ? ` (${result.error})` : ""} — re-authentication required` + `Codex: token refresh failed (${result.error}) — re-authentication required` ); - return null; + return result; } return result; } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c7f1498683..8029fdc9c3 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -14,7 +14,7 @@ import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.ts"; import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts"; import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts"; -import { refreshWithRetry } from "../services/tokenRefresh.ts"; +import { refreshWithRetry, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts"; import { createRequestLogger } from "../utils/requestLogger.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { @@ -3530,6 +3530,9 @@ export async function handleChatCore({ } } else { log?.warn?.("TOKEN", `${provider.toUpperCase()} | refresh failed`); + if (isUnrecoverableRefreshError(newCredentials) && onCredentialsRefreshed) { + await onCredentialsRefreshed({ testStatus: "expired", isActive: false }); + } } } diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 974f53497d..73dba89590 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -349,11 +349,20 @@ export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: un ); if (!response.ok) { - const errorText = await response.text(); + let errorBody: { error?: string; error_description?: string } = {}; + try { + errorBody = await response.json(); + } catch { + const text = await response.text().catch(() => "unknown"); + errorBody = { error: text }; + } log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, - error: errorText, + error: errorBody, }); + if (errorBody.error === "invalid_grant" || errorBody.error === "invalid_request") { + return { error: errorBody.error, code: `http_${response.status}` }; + } return null; } @@ -1280,6 +1289,13 @@ export async function refreshWithRetry( try { const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS); + if (isUnrecoverableRefreshError(result)) { + log?.warn?.( + "TOKEN_REFRESH", + `Unrecoverable refresh error for ${provider}: ${result.error} — skipping retries` + ); + return result; + } if (result) { recordSuccess(provider); return result; diff --git a/package-lock.json b/package-lock.json index c4545fbca3..54afbe5ad1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", + "@xyflow/react": "^12.10.2", "axios": "^1.16.1", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", @@ -5339,6 +5340,66 @@ "dev": true, "license": "MIT" }, + "node_modules/@xyflow/react": { + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.76", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -6443,6 +6504,12 @@ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", diff --git a/package.json b/package.json index 21c8004997..2587a07474 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", + "@xyflow/react": "^12.10.2", "axios": "^1.16.1", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", diff --git a/scripts/dev/run-next.mjs b/scripts/dev/run-next.mjs index 1a108d40d2..61e490311a 100644 --- a/scripts/dev/run-next.mjs +++ b/scripts/dev/run-next.mjs @@ -10,6 +10,17 @@ import { createOmnirouteWsBridge } from "./v1-ws-bridge.mjs"; import { createResponsesWsProxy } from "./responses-ws-proxy.mjs"; import { randomUUID } from "node:crypto"; +// Pre-read DATA_DIR from local .env before bootstrap resolves paths +if (!process.env.DATA_DIR) { + try { + const raw = fs.readFileSync(path.join(process.cwd(), ".env"), "utf8"); + const match = raw.match(/^DATA_DIR=(.+)$/m); + if (match?.[1]?.trim()) process.env.DATA_DIR = match[1].trim(); + } catch { + /* .env ausente ou ilegível — ok, bootstrap usa o padrão */ + } +} + // Add check for conflicting app/ directory (Issue #1206) const rootAppDir = path.join(process.cwd(), "app"); if (fs.existsSync(rootAppDir) && fs.statSync(rootAppDir).isDirectory()) { @@ -86,7 +97,7 @@ async function start() { await new Promise((resolve) => server.close(resolve)); await nextApp.close(); } catch (error) { - console.error(`[SHUTDOWN] Failed during ${signal}:`, error); + console.error("[SHUTDOWN] Failed during signal:", signal, error); } finally { process.exit(0); } diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index c9b4d06e34..b2a7ad6a49 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -3,7 +3,7 @@ import { useTranslations } from "next-intl"; import { useState, useEffect, useMemo, useCallback } from "react"; -import Image from "next/image"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Card, CardSkeleton, Button, Modal } from "@/shared/components"; @@ -11,6 +11,8 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers"; import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; + +const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); import type { NewsAnnouncement } from "@/shared/utils/releaseNotes"; import { TierCoverageWidget } from "./TierCoverageWidget"; @@ -180,21 +182,6 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { return models.filter((m) => providerKeys.has(m.provider)); }, [selectedProvider, models]); - const quickStartLinks = [ - { label: t("documentation"), href: "/docs", icon: "menu_book" }, - { label: ts("providers"), href: "/dashboard/providers", icon: "dns" }, - { label: ts("combos"), href: "/dashboard/combos", icon: "layers" }, - { label: ts("analytics"), href: "/dashboard/analytics", icon: "analytics" }, - { label: t("healthMonitor"), href: "/dashboard/health", icon: "health_and_safety" }, - { label: ts("cliTools"), href: "/dashboard/cli-tools", icon: "terminal" }, - { - label: t("reportIssue"), - href: "https://github.com/diegosouzapw/OmniRoute/issues", - external: true, - icon: "bug_report", - }, - ]; - const pollBackgroundUpdate = useCallback( async ({ channel, @@ -729,73 +716,38 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { - -
- {quickStartLinks.map((link) => ( - - - {link.icon || (link.external ? "open_in_new" : "arrow_forward")} - - {link.label} - - ))} -
{/* Tier Coverage */} - {/* Providers Overview */} + {/* Provider Topology */} -
+
-

{t("providersOverview")}

-

- {t("configuredOf", { - configured: providerStats.filter((item) => item.total > 0).length, - total: providerStats.length, - })} +

Provider Topology

+

+ Connected providers routing through OmniRoute in real time

-
-
- - {tc("free")} - - - {t("oauthLabel")} - - - {t("apiKeyLabel")} - -
- - settings - {tc("manage")} - +
+ + Active + + + Recent + + + Error +
- -
- {providerStats.map((item) => ( - setSelectedProvider(item)} - /> - ))} -
+ p.total > 0) + .map((p) => ({ id: p.id, provider: p.id, name: p.provider.name }))} + /> {/* Provider Models Modal */} diff --git a/src/app/(dashboard)/dashboard/a2a/page.tsx b/src/app/(dashboard)/dashboard/a2a/page.tsx new file mode 100644 index 0000000000..89b163f3e7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/a2a/page.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card } from "@/shared/components"; +import A2ADashboardPage from "../endpoint/components/A2ADashboard"; + +type ServiceStatus = { online: boolean; loading: boolean }; + +function ServiceToggle({ + label, + status, + enabled, + onToggle, + toggling, +}: { + label: string; + status: ServiceStatus; + enabled: boolean; + onToggle: () => void; + toggling: boolean; +}) { + const online = enabled && status.online; + const loading = enabled && status.loading; + + return ( +
+
+ + {loading ? "..." : online ? "Online" : "Offline"} +
+ + + + + {toggling ? "..." : enabled ? "ON" : "OFF"} + +
+ ); +} + +function DisabledPanel() { + return ( + +
+
+
+
+

+ A2A is disabled +

+

+ Enable A2A above to view task telemetry, agent details, and validation tools. +

+
+
+
+ ); +} + +export default function A2APage() { + const [a2aStatus, setA2aStatus] = useState({ online: false, loading: true }); + const [a2aEnabled, setA2aEnabled] = useState(false); + const [a2aToggling, setA2aToggling] = useState(false); + + const patchSetting = useCallback(async (body: Record) => { + return fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + }, []); + + useEffect(() => { + const fetchSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const data = await res.json(); + setA2aEnabled(!!data.a2aEnabled); + } + } catch { + // defaults stay + } + }; + void fetchSettings(); + }, []); + + const refreshStatus = useCallback(async () => { + setA2aStatus((prev) => ({ ...prev, loading: true })); + try { + const res = await fetch("/api/a2a/status"); + const data = res.ok ? await res.json() : null; + setA2aStatus({ online: data?.status === "ok", loading: false }); + } catch { + setA2aStatus({ online: false, loading: false }); + } + }, []); + + useEffect(() => { + void refreshStatus(); + const interval = setInterval(() => void refreshStatus(), 30000); + return () => clearInterval(interval); + }, [refreshStatus]); + + const toggleA2a = useCallback(async () => { + const newValue = !a2aEnabled; + setA2aToggling(true); + try { + const res = await patchSetting({ a2aEnabled: newValue }); + if (res.ok) setA2aEnabled(newValue); + } catch { + // keep current + } finally { + setA2aToggling(false); + } + }, [a2aEnabled, patchSetting]); + + return ( +
+ +
+
+

+ Agent2Agent JSON-RPC 2.0 endpoint — send tasks, stream responses, cancel in-flight + jobs. +

+
    +
  1. + Discover the agent card at /.well-known/agent.json. +
  2. +
  3. + Send JSON-RPC to POST /a2a using{" "} + message/send or{" "} + message/stream. +
  4. +
  5. + Track and cancel tasks with tasks/get and{" "} + tasks/cancel. +
  6. +
+
+
+ void toggleA2a()} + toggling={a2aToggling} + /> +
+
+
+ + {a2aEnabled ? : } +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/agent-skills/page.tsx b/src/app/(dashboard)/dashboard/agent-skills/page.tsx index 37b74023a2..6eeab3f8aa 100644 --- a/src/app/(dashboard)/dashboard/agent-skills/page.tsx +++ b/src/app/(dashboard)/dashboard/agent-skills/page.tsx @@ -16,16 +16,14 @@ function CopyButton({ url }: { url: string }) { return ( -
- - {/* Keys List Card */}
@@ -588,6 +567,17 @@ export default function ApiManagerPageClient() {

+

{t("keysSecurityNote")}

diff --git a/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx new file mode 100644 index 0000000000..1cd2f7c2dc --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; +import type { A2ATask, TaskState } from "@/lib/a2a/taskManager"; + +type TaskListResponse = { + tasks: A2ATask[]; + total: number; + limit: number; + offset: number; +}; + +const A2A_PAGE_SIZE = 25; + +const STATE_STYLES: Record = { + submitted: "border-amber-500/30 bg-amber-500/10 text-amber-600", + working: "border-blue-500/30 bg-blue-500/10 text-blue-600", + completed: "border-emerald-500/30 bg-emerald-500/10 text-emerald-600", + failed: "border-red-500/30 bg-red-500/10 text-red-600", + cancelled: "border-border bg-sidebar/40 text-text-muted", +}; + +function taskDuration(task: A2ATask): string { + const ms = new Date(task.updatedAt).getTime() - new Date(task.createdAt).getTime(); + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +export default function A2aAuditTab() { + const t = useTranslations("compliance"); + const [data, setData] = useState({ + tasks: [], + total: 0, + limit: A2A_PAGE_SIZE, + offset: 0, + }); + const [loading, setLoading] = useState(true); + const [skillFilter, setSkillFilter] = useState(""); + const [stateFilter, setStateFilter] = useState("all"); + const [offset, setOffset] = useState(0); + + const fetchTasks = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + params.set("limit", String(A2A_PAGE_SIZE)); + params.set("offset", String(offset)); + if (skillFilter) params.set("skill", skillFilter); + if (stateFilter !== "all") params.set("state", stateFilter); + + const response = await fetch(`/api/a2a/tasks?${params.toString()}`); + const json = (await response.json().catch(() => ({}))) as Partial; + setData({ + tasks: Array.isArray(json.tasks) ? json.tasks : [], + total: Number(json.total || 0), + limit: Number(json.limit || A2A_PAGE_SIZE), + offset: Number(json.offset || offset), + }); + } finally { + setLoading(false); + } + }, [offset, skillFilter, stateFilter]); + + useEffect(() => { + void fetchTasks(); + }, [fetchTasks]); + + return ( +
+ +
+
+

{t("a2aAudit")}

+

{t("a2aAuditDesc")}

+

+ {t("a2aShowingTasks", { count: data.tasks.length, total: data.total })} +

+
+ +
+
+ + +
+ + +
+ +
+
+
+ + + {loading ? ( +
{t("a2aLoadingTasks")}
+ ) : data.tasks.length === 0 ? ( +
+ + device_hub + +

{t("a2aNoTasks")}

+
+ ) : ( +
+ + + + + + + + + + + + + + {data.tasks.map((task) => ( + + + + + + + + + + ))} + +
{t("timestamp")}{t("a2aTaskId")}{t("a2aSkill")}{t("a2aState")}{t("duration")}{t("a2aEvents")}{t("a2aArtifacts")}
+ {new Date(task.createdAt).toLocaleString()} + + {task.id.slice(0, 8)}… + {task.skill} + + {task.state} + + {taskDuration(task)}{task.events.length}{task.artifacts.length}
+
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx b/src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx new file mode 100644 index 0000000000..3dda466001 --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/McpAuditTab.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; + +type McpAuditEntry = { + id: number; + toolName: string; + inputHash: string; + outputSummary: string; + durationMs: number; + apiKeyId: string | null; + success: boolean; + errorCode: string | null; + createdAt: string; +}; + +type McpAuditResponse = { + entries: McpAuditEntry[]; + total: number; + limit: number; + offset: number; +}; + +type McpAuditStats = { + totalCalls: number; + successRate: number; + avgDurationMs: number; + topTools: Array<{ tool: string; count: number }>; +}; + +const MCP_PAGE_SIZE = 25; + +export default function McpAuditTab() { + const t = useTranslations("compliance"); + const [data, setData] = useState({ + entries: [], + total: 0, + limit: MCP_PAGE_SIZE, + offset: 0, + }); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [toolFilter, setToolFilter] = useState(""); + const [successFilter, setSuccessFilter] = useState<"all" | "true" | "false">("all"); + const [offset, setOffset] = useState(0); + + const fetchStats = useCallback(async () => { + try { + const res = await fetch("/api/mcp/audit/stats"); + if (res.ok) setStats((await res.json()) as McpAuditStats); + } catch { + // non-fatal + } + }, []); + + useEffect(() => { + void fetchStats(); + }, [fetchStats]); + + const fetchAudit = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + params.set("limit", String(MCP_PAGE_SIZE)); + params.set("offset", String(offset)); + if (toolFilter) params.set("tool", toolFilter); + if (successFilter !== "all") params.set("success", successFilter); + + const response = await fetch(`/api/mcp/audit?${params.toString()}`); + const json = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(json.error || t("failedFetchMcpAudit")); + } + + setData({ + entries: Array.isArray(json.entries) ? json.entries : [], + total: Number(json.total || 0), + limit: Number(json.limit || MCP_PAGE_SIZE), + offset: Number(json.offset || offset), + }); + } finally { + setLoading(false); + } + }, [offset, successFilter, t, toolFilter]); + + useEffect(() => { + void fetchAudit(); + }, [fetchAudit]); + + return ( +
+ +
+
+

{t("mcpAudit")}

+

{t("mcpAuditDesc")}

+

+ {t("showing", { count: data.entries.length, total: data.total })} +

+
+ +
+
+ + {stats && ( +
+ {[ + { + label: "Calls (24h)", + value: stats.totalCalls.toLocaleString(), + icon: "terminal", + }, + { + label: "Success rate", + value: `${Math.round(stats.successRate * 100)}%`, + icon: "check_circle", + highlight: stats.successRate >= 0.9, + }, + { + label: "Avg duration", + value: `${Math.round(stats.avgDurationMs)}ms`, + icon: "timer", + }, + { + label: "Top tool", + value: stats.topTools[0]?.tool ?? "—", + icon: "star", + }, + ].map((item) => ( + +
+ + {item.icon} + + + {item.label} + +
+

+ {item.value} +

+
+ ))} +
+ )} + + +
+ + +
+ +
+
+
+ + + {loading ? ( +
{t("loading")}
+ ) : data.entries.length === 0 ? ( +
+ terminal +

{t("noMcpEvents")}

+
+ ) : ( +
+ + + + + + + + + + + + + {data.entries.map((entry) => ( + + + + + + + + + ))} + +
{t("timestamp")}{t("tool")}{t("duration")}{t("result")}{t("apiKey")}{t("output")}
+ {new Date(entry.createdAt).toLocaleString()} + {entry.toolName}{entry.durationMs}ms + + {entry.success ? t("success") : entry.errorCode || t("failure")} + + + {entry.apiKeyId || t("notAvailable")} + + {entry.outputSummary || t("notAvailable")} +
+
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/audit/a2a/page.tsx b/src/app/(dashboard)/dashboard/audit/a2a/page.tsx new file mode 100644 index 0000000000..e06144538b --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/a2a/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import A2aAuditTab from "../A2aAuditTab"; + +export default function AuditA2aPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/audit/mcp/page.tsx b/src/app/(dashboard)/dashboard/audit/mcp/page.tsx new file mode 100644 index 0000000000..2d67d52583 --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/mcp/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import McpAuditTab from "../McpAuditTab"; + +export default function AuditMcpPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/audit/page.tsx b/src/app/(dashboard)/dashboard/audit/page.tsx index 0755c9b957..4871d7dd72 100644 --- a/src/app/(dashboard)/dashboard/audit/page.tsx +++ b/src/app/(dashboard)/dashboard/audit/page.tsx @@ -1,246 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslations } from "next-intl"; -import { Card, SegmentedControl } from "@/shared/components"; import ComplianceTab from "./ComplianceTab"; -type McpAuditEntry = { - id: number; - toolName: string; - inputHash: string; - outputSummary: string; - durationMs: number; - apiKeyId: string | null; - success: boolean; - errorCode: string | null; - createdAt: string; -}; - -type McpAuditResponse = { - entries: McpAuditEntry[]; - total: number; - limit: number; - offset: number; -}; - -const MCP_PAGE_SIZE = 25; - -function McpAuditTab() { - const t = useTranslations("compliance"); - const [data, setData] = useState({ - entries: [], - total: 0, - limit: MCP_PAGE_SIZE, - offset: 0, - }); - const [loading, setLoading] = useState(true); - const [toolFilter, setToolFilter] = useState(""); - const [successFilter, setSuccessFilter] = useState<"all" | "true" | "false">("all"); - const [offset, setOffset] = useState(0); - - const fetchAudit = useCallback(async () => { - setLoading(true); - try { - const params = new URLSearchParams(); - params.set("limit", String(MCP_PAGE_SIZE)); - params.set("offset", String(offset)); - if (toolFilter) params.set("tool", toolFilter); - if (successFilter !== "all") params.set("success", successFilter); - - const response = await fetch(`/api/mcp/audit?${params.toString()}`); - const json = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(json.error || t("failedFetchMcpAudit")); - } - - setData({ - entries: Array.isArray(json.entries) ? json.entries : [], - total: Number(json.total || 0), - limit: Number(json.limit || MCP_PAGE_SIZE), - offset: Number(json.offset || offset), - }); - } finally { - setLoading(false); - } - }, [offset, successFilter, t, toolFilter]); - - useEffect(() => { - void fetchAudit(); - }, [fetchAudit]); - - return ( -
- -
-
-

{t("mcpAudit")}

-

{t("mcpAuditDesc")}

-

- {t("showing", { count: data.entries.length, total: data.total })} -

-
- -
-
- - -
- - -
- -
-
-
- - - {loading ? ( -
{t("loading")}
- ) : data.entries.length === 0 ? ( -
- terminal -

{t("noMcpEvents")}

-
- ) : ( -
- - - - - - - - - - - - - {data.entries.map((entry) => ( - - - - - - - - - ))} - -
{t("timestamp")}{t("tool")}{t("duration")}{t("result")}{t("apiKey")}{t("output")}
- {new Date(entry.createdAt).toLocaleString()} - {entry.toolName}{entry.durationMs}ms - - {entry.success ? t("success") : entry.errorCode || t("failure")} - - - {entry.apiKeyId || t("notAvailable")} - - {entry.outputSummary || t("notAvailable")} -
-
- )} -
- -
- - -
-
- ); -} - export default function AuditPage() { - const t = useTranslations("compliance"); - const [activeTab, setActiveTab] = useState("compliance"); - - return ( -
-
-
- policy -

{t("auditTitle")}

-
-

{t("auditDescription")}

-
- - - - {activeTab === "compliance" ? : } -
- ); + return ; } diff --git a/src/app/(dashboard)/dashboard/batch/files/page.tsx b/src/app/(dashboard)/dashboard/batch/files/page.tsx new file mode 100644 index 0000000000..54fb54b41d --- /dev/null +++ b/src/app/(dashboard)/dashboard/batch/files/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import FilesListTab from "../FilesListTab"; +import { mapFileApiToRecord, mapBatchApiToRecord } from "../batch-utils"; +import { FileRecord } from "@/lib/db/files"; +import { BatchRecord } from "@/lib/db/batches"; + +export default function BatchFilesPage() { + const [files, setFiles] = useState([]); + const [batches, setBatches] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [filesRes, batchesRes] = await Promise.all([ + fetch("/api/v1/files?limit=20"), + fetch("/api/v1/batches?limit=20"), + ]); + if (filesRes.ok) { + const data = await filesRes.json(); + setFiles((data.data || []).map(mapFileApiToRecord)); + } + if (batchesRes.ok) { + const data = await batchesRes.json(); + setBatches((data.data || []).map(mapBatchApiToRecord)); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/batch/page.tsx b/src/app/(dashboard)/dashboard/batch/page.tsx index 6afc254877..13ba9de876 100644 --- a/src/app/(dashboard)/dashboard/batch/page.tsx +++ b/src/app/(dashboard)/dashboard/batch/page.tsx @@ -1,9 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { SegmentedControl } from "@/shared/components"; import BatchListTab from "./BatchListTab"; -import FilesListTab from "./FilesListTab"; import { FileRecord } from "@/lib/db/files"; import { BatchRecord } from "@/lib/db/batches"; import { mapBatchApiToRecord, mapFileApiToRecord } from "./batch-utils"; @@ -12,39 +10,28 @@ export default function BatchPage() { const [batches, setBatches] = useState([]); const [files, setFiles] = useState([]); const [batchesTotal, setBatchesTotal] = useState(0); - const [filesTotal, setFilesTotal] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); - const [activeTab, setActiveTab] = useState<"batches" | "files">("batches"); const [batchesHasMore, setBatchesHasMore] = useState(false); const [batchesLastId, setBatchesLastId] = useState(null); - const [filesHasMore, setFilesHasMore] = useState(false); - const [filesLastId, setFilesLastId] = useState(null); const bottomRefBatches = useRef(null); - const bottomRefFiles = useRef(null); - const listContainerRef = useRef(null); const refreshTimeoutRef = useRef(null); const isFetchingRef = useRef(false); const fetchDataRef = useRef(null); const fetchData = useCallback( - async ( - isBackground = false, - opts: { appendBatches?: boolean; appendFiles?: boolean; limit?: number } = {} - ) => { + async (isBackground = false, opts: { appendBatches?: boolean; limit?: number } = {}) => { if (isFetchingRef.current) return; if (!isBackground) setLoading(true); - if (opts.appendBatches || opts.appendFiles) setLoadingMore(true); + if (opts.appendBatches) setLoadingMore(true); isFetchingRef.current = true; const limit = opts.limit ?? 20; try { const batchUrl = `/api/v1/batches?limit=${limit}` + (opts.appendBatches && batchesLastId ? `&after=${batchesLastId}` : ""); - const filesUrl = - `/api/v1/files?limit=${limit}` + - (opts.appendFiles && filesLastId ? `&after=${filesLastId}` : ""); + const filesUrl = `/api/v1/files?limit=${limit}`; const [batchesRes, filesRes] = await Promise.all([fetch(batchUrl), fetch(filesUrl)]); @@ -79,13 +66,7 @@ export default function BatchPage() { if (filesRes.ok) { const data = await filesRes.json(); const mapped = (data.data || []).map(mapFileApiToRecord); - - if (opts.appendFiles) { - setFiles((prev) => [...prev, ...mapped]); - setFilesHasMore(Boolean(data.has_more)); - setFilesLastId(data.last_id || null); - } else if (isBackground) { - // Background refresh: merge new items with existing ones, preserve pagination state + if (isBackground) { setFiles((prev) => { const fileMap = new Map(prev.map((f) => [f.id, f])); for (const m of mapped) { @@ -95,23 +76,19 @@ export default function BatchPage() { (a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id) ); }); - // Don't reset filesLastId or filesHasMore on background refresh } else { setFiles(mapped); - setFilesHasMore(Boolean(data.has_more)); - setFilesLastId(data.last_id || null); } - setFilesTotal(data.total_count || 0); } } catch (error) { console.error("Failed to fetch batches/files", error); } finally { isFetchingRef.current = false; if (!isBackground) setLoading(false); - if (opts.appendBatches || opts.appendFiles) setLoadingMore(false); + if (opts.appendBatches) setLoadingMore(false); } }, - [batchesLastId, filesLastId] + [batchesLastId] ); // Keep fetchData ref in sync @@ -147,46 +124,33 @@ export default function BatchPage() { }; }, []); // Empty deps - only run once, uses ref for latest fetchData - // IntersectionObserver for infinite scroll - re-created only when tab or hasMore state changes - // (NOT when loadingMore changes, to avoid re-triggering immediately after load) + // IntersectionObserver for infinite scroll on batches useEffect(() => { - const currentBottomRef = activeTab === "batches" ? bottomRefBatches : bottomRefFiles; - const observer = new IntersectionObserver( (entries) => { - if (entries[0].isIntersecting) { - if (activeTab === "batches" && batchesHasMore && !loadingMoreRef.current) { - fetchDataRef.current?.(true, { appendBatches: true }); - } else if (activeTab === "files" && filesHasMore && !loadingMoreRef.current) { - fetchDataRef.current?.(true, { appendFiles: true }); - } + if (entries[0].isIntersecting && batchesHasMore && !loadingMoreRef.current) { + fetchDataRef.current?.(true, { appendBatches: true }); } }, { threshold: 0.1 } ); - if (currentBottomRef.current) { - observer.observe(currentBottomRef.current); + if (bottomRefBatches.current) { + observer.observe(bottomRefBatches.current); } return () => observer.disconnect(); - }, [activeTab, batchesHasMore, filesHasMore]); + }, [batchesHasMore]); const batchesCount = batches.length; - const filesCount = files.length; return (
{/* Toolbar */}
- setActiveTab(v as "batches" | "files")} - /> + + {batchesTotal ? `${batchesTotal} batches` : "Batches"} +
- {/* Tab content with scroll container for position preservation */} -
- {activeTab === "batches" ? ( - <> - fetchData(false)} - /> - {loadingMore && batchesCount > 0 && ( -
Loading more…
- )} -
- - ) : ( - <> - fetchData(false)} - batches={batches} - /> - {loadingMore && filesCount > 0 && ( -
Loading more…
- )} -
- +
+ fetchData(false)} + /> + {loadingMore && batchesCount > 0 && ( +
Loading more…
)} +
); diff --git a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx index ae5b69f32e..1e97577beb 100644 --- a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx @@ -726,12 +726,6 @@ export default function MediaPageClient() { return (
- {/* Header */} -
-

{t("title")}

-

{t("subtitle")}

-
- {/* Modality Tabs */}
{(Object.keys(MODALITY_CONFIG) as Modality[]).map((key) => { diff --git a/src/app/(dashboard)/dashboard/cache/page.tsx b/src/app/(dashboard)/dashboard/cache/page.tsx index 62d7c1cf1a..42080fb85d 100644 --- a/src/app/(dashboard)/dashboard/cache/page.tsx +++ b/src/app/(dashboard)/dashboard/cache/page.tsx @@ -428,11 +428,7 @@ export default function CachePage() { return (
-
-
-

{t("title")}

-

{t("description")}

-
+
- ))} -
- {/* ═══ API CATALOG ═══ */} - {section === "catalog" && !catalog && ( + {!catalog && (
@@ -378,7 +243,7 @@ export default function ApiEndpointsTab() { )} - {section === "catalog" && catalog && ( + {catalog && ( <> {/* Search & filter */}
@@ -649,232 +514,6 @@ export default function ApiEndpointsTab() { )} )} - - {/* ═══ WEBHOOKS ═══ */} - {section === "webhooks" && ( - <> - -
-
- webhook -
-

Event Webhooks

-

- Receive HTTP callbacks when events occur in OmniRoute -

-
-
- {!showAddWebhook && ( - - )} -
- - {/* Add webhook form */} - {showAddWebhook && ( -
-
-
- - setWhUrl(e.target.value)} - placeholder="https://example.com/webhook" - className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10 - bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" - /> -
-
- - setWhDesc(e.target.value)} - placeholder="Production monitoring" - className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10 - bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" - /> -
-
-
- -
- - {WEBHOOK_EVENTS.map((ev) => ( - - ))} -
-
-
- - -
-
- )} - - {/* Webhooks list */} - {webhooksLoading ? ( -
Loading...
- ) : webhooks.length === 0 ? ( -
- - webhook - -

- No webhooks configured. Add one to receive event notifications. -

-
- ) : ( -
- {webhooks.map((wh) => ( -
-
-
- {wh.url} - {wh.failure_count > 0 && ( - - {wh.failure_count} failures - - )} -
-
- {wh.description && ( - {wh.description} - )} - - Events: {wh.events.join(", ")} - - {wh.last_triggered_at && ( - - Last: {new Date(wh.last_triggered_at).toLocaleString()} - {wh.last_status ? ` (${wh.last_status})` : ""} - - )} -
-
-
- - - -
-
- ))} -
- )} -
- - {/* Webhook signature info */} - -
- vpn_key -

Webhook Signatures

-
-

- Each webhook delivery includes an{" "} - - X-Webhook-Signature - {" "} - header signed with HMAC-SHA256 using the webhook secret. Verify the signature to - ensure the payload is authentic. -

-
- - {`const crypto = require('crypto');\nconst sig = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');\nif (sig !== req.headers['x-webhook-signature']) throw new Error('Invalid signature');`} - -
-
- - )}
); } diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 7e047d1f52..e82fe9d9e0 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -126,7 +126,7 @@ const DEFAULT_TUNNEL_VISIBILITY: EndpointTunnelVisibility = { function runEndpointBackgroundTask(taskName: string, task: () => Promise) { void task().catch((error) => { - console.log(`Error running endpoint background task (${taskName}):`, error); + console.log("Error running endpoint background task:", taskName, error); }); } @@ -152,7 +152,6 @@ export default function APIPageClient({ machineId }: Readonly(null); const [a2aStatus, setA2aStatus] = useState(null); const [searchProviders, setSearchProviders] = useState([]); @@ -173,6 +172,9 @@ export default function APIPageClient({ machineId }: Readonly(null); const [ngrokToken, setNgrokToken] = useState(""); const [showNgrokTunnel, setShowNgrokTunnel] = useState(true); + const [expandedTunnel, setExpandedTunnel] = useState(null); + const [lanUrls, setLanUrls] = useState([]); + const [tailscaleIpUrl, setTailscaleIpUrl] = useState(null); const { copied, copy } = useCopyToClipboard(); @@ -310,6 +312,20 @@ export default function APIPageClient({ machineId }: Readonly { + try { + const res = await fetch("/api/network/info"); + if (res.ok) { + const data = await res.json(); + if (mounted) { + setLanUrls(data.lanUrls ?? []); + if (data.tailscaleIpUrl) setTailscaleIpUrl(data.tailscaleIpUrl); + } + } + } catch { + // non-critical + } + }); if (tunnelVisibility.showCloudflaredTunnel) { runEndpointBackgroundTask("cloudflared-status", () => fetchCloudflaredStatus(true)); @@ -396,10 +412,22 @@ export default function APIPageClient({ machineId }: Readonly Object.values(endpointData).filter((models) => models.length > 0).length + 2, - [endpointData] - ); + const availableEndpointCount = useMemo(() => { + const chatCount = endpointData.chat.length > 0 ? 4 : 0; // chat + responses + completions + messages + const imageCount = endpointData.images.length > 0 ? 2 : 0; // image gen + image edits + const otherMedia = [ + endpointData.embeddings, + endpointData.audioTranscription, + endpointData.audioSpeech, + endpointData.music, + endpointData.video, + ].filter((m) => m.length > 0).length; + const utilityFixed = 3; // batch + files + list models (always available) + const modelUtility = + (endpointData.rerank.length > 0 ? 1 : 0) + (endpointData.moderation.length > 0 ? 1 : 0); + const searchCount = searchProviders.length > 0 ? 1 : 0; + return chatCount + imageCount + otherMedia + utilityFixed + modelUtility + searchCount; + }, [endpointData, searchProviders]); const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => { const controller = new AbortController(); @@ -453,6 +481,7 @@ export default function APIPageClient({ machineId }: Readonly {/* Endpoint Card */} - -
-
-

{t("title")}

-
- -
- {resolvedMachineId && ( -

- {t("machineId", { id: resolvedMachineId.slice(0, 8) })} -

- )} -
-
- {cloudEnabled ? ( - - ) : cloudConfigured ? ( - - ) : ( - - Cloud not configured - - )} -
-
+ +

{t("title")}

{/* Cloud Status Toast */} {cloudStatus && ( @@ -1230,63 +1243,177 @@ export default function APIPageClient({ machineId }: Readonly )} - {/* Endpoint URL */} -
- - -
- - {showCloudflaredTunnel && ( -
-
-
-
-
-

- {translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")} -

- - {cloudflaredPhaseMeta[cloudflaredPhase].label} + {/* Active URLs bar */} + {activeUrls.length > 0 && ( +
+

+ Active Endpoints +

+
+ {activeUrls.map(({ label, url, key }) => ( +
+ + {label} + + {url} + +
+
+ ))} +
+
+ )} + {/* Connection rows */} +
+ {/* Local Server */} +
+ + computer + +
+
+ Local Server + {resolvedMachineId && ( + · {resolvedMachineId.slice(0, 8)} + )} + {lanUrls.map((url) => ( + + ))} +
+
+ + + Running + + +
+ + {/* Tunnels section header */} +
+ + network_node + + + Tunnels + +
+ + {activeTunnelCount} / {visibleTunnelCount} active + +
+ + {/* Cloud OmniRoute */} +
+ + cloud + +
+ Cloud OmniRoute +
+ + + {cloudEnabled ? "Active" : "Disabled"} + + {cloudEnabled ? ( + + ) : cloudConfigured ? ( + + ) : ( + + Not configured + + )} +
+ + {/* Cloudflare Quick Tunnel */} + {showCloudflaredTunnel && ( +
+
+ + cloud_queue + +
+ + {translateOrFallback("cloudflaredTitle", "Cloudflare Quick Tunnel")} + +
+ + {cloudflaredPhaseMeta[cloudflaredPhase].label} + {cloudflaredStatus?.supported !== false && ( )}
- {cloudflaredNotice && (
)} - -

{cloudflaredUrlNotice}

-
- - -
{cloudflaredStatus?.lastError && ( -

+

{translateOrFallback("cloudflaredLastError", "Last error: {error}", { error: cloudflaredStatus.lastError, })}

)}
-
- )} + )} - {showTailscaleFunnel && ( -
-
-
-
-
-

+ {/* Tailscale Funnel */} + {showTailscaleFunnel && ( +
+
setExpandedTunnel(expandedTunnel === "ts" ? null : "ts")} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setExpandedTunnel(expandedTunnel === "ts" ? null : "ts"); + } + }} + > + + vpn_lock + +
+
+ {translateOrFallback("tailscaleTitle", "Tailscale Funnel")} -

- - {tailscalePhaseMeta[tailscalePhase].label} + {tailscaleIpUrl && ( + + )}
- + + {tailscalePhaseMeta[tailscalePhase].label} + {tailscaleStatus?.supported !== false && ( )} -
- - {tailscaleNotice && ( -
- - {tailscaleNotice.type === "success" - ? "check_circle" - : tailscaleNotice.type === "info" - ? "info" - : "error"} - - {tailscaleNotice.message} - -
- )} - -

{tailscaleUrlNotice}

- {tailscaleStatus?.phase === "needs_login" && ( -

- {translateOrFallback( - "tailscaleNeedsLoginHint", - "Authenticate this machine with Tailscale, then enable Funnel." + expand_more + +

+ {expandedTunnel === "ts" && ( +
+ {tailscaleNotice && ( +
+ + {tailscaleNotice.type === "success" + ? "check_circle" + : tailscaleNotice.type === "info" + ? "info" + : "error"} + + {tailscaleNotice.message} + +
)} -

- )} - {/* Sudo password input — shown when Tailscale is installed but not running (needs sudo to start daemon) */} - {tailscaleStatus?.installed && - !tailscaleStatus?.running && - tailscaleStatus?.platform !== "win32" && ( -
-
- )} -
- - -
- {tailscaleStatus?.binaryPath && ( -

- {translateOrFallback("tailscaleBinaryPath", "Binary: {path}", { - path: tailscaleStatus.binaryPath, - })} -

- )} - {tailscaleStatus?.lastError && ( -

- {translateOrFallback("tailscaleLastError", "Last error: {error}", { - error: tailscaleStatus.lastError, - })} -

+

+ )} + {tailscaleStatus?.installed && tailscaleStatus?.platform !== "win32" && ( +
+ + setTailscalePassword(event.target.value)} + placeholder={translateOrFallback( + "tailscaleSudoPlaceholder", + "Optional sudo password" + )} + disabled={tailscaleBusy} + className="font-mono text-sm" + /> +
+ )} + {tailscaleStatus?.binaryPath && ( +

+ {translateOrFallback("tailscaleBinaryPath", "Binary: {path}", { + path: tailscaleStatus.binaryPath, + })} +

+ )} + {tailscaleStatus?.lastError && ( +

+ {translateOrFallback("tailscaleLastError", "Last error: {error}", { + error: tailscaleStatus.lastError, + })} +

+ )} +
)}
-
- )} + )} - {showNgrokTunnel && ( -
-
-
-
-
-

- {translateOrFallback("ngrokTitle", "ngrok Tunnel")} -

- - {ngrokPhaseMeta[ngrokPhase].label} - -
+ {/* ngrok Tunnel */} + {showNgrokTunnel && ( +
+
setExpandedTunnel(expandedTunnel === "ngrok" ? null : "ngrok")} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setExpandedTunnel(expandedTunnel === "ngrok" ? null : "ngrok"); + } + }} + > + + public + +
+ + {translateOrFallback("ngrokTitle", "ngrok Tunnel")} +
- + + {ngrokPhaseMeta[ngrokPhase].label} + {ngrokStatus?.supported !== false && ( )} -
- - {ngrokNotice && ( -
- - {ngrokNotice.type === "success" - ? "check_circle" - : ngrokNotice.type === "info" - ? "info" - : "error"} - - {ngrokNotice.message} - -
- )} - -

{ngrokUrlNotice}

- {ngrokStatus?.phase === "needs_auth" && ( -
- - setNgrokToken(event.target.value)} - placeholder={translateOrFallback( - "ngrokAuthTokenPlaceholder", - "Enter your ngrok authtoken" - )} - disabled={ngrokBusy} - className="font-mono text-sm" - /> -
- )} -
- - + expand_more +
- {ngrokStatus?.lastError && ( -

- {translateOrFallback("ngrokLastError", "Last error: {error}", { - error: ngrokStatus.lastError, - })} -

+ {expandedTunnel === "ngrok" && ( +
+ {ngrokNotice && ( +
+ + {ngrokNotice.type === "success" + ? "check_circle" + : ngrokNotice.type === "info" + ? "info" + : "error"} + + {ngrokNotice.message} + +
+ )} +

{ngrokUrlNotice}

+ {ngrokStatus?.phase === "needs_auth" && ( +
+ + setNgrokToken(event.target.value)} + placeholder={translateOrFallback( + "ngrokAuthTokenPlaceholder", + "Enter your ngrok authtoken" + )} + disabled={ngrokBusy} + className="font-mono text-sm" + /> +
+ )} + {ngrokStatus?.lastError && ( +

+ {translateOrFallback("ngrokLastError", "Last error: {error}", { + error: ngrokStatus.lastError, + })} +

+ )} +
)}
-
- )} - - - -
-
-

{t("sectionTitle") || "Integration Surface"}

-

- {t("sectionDescription") || - "OpenAI-compatible APIs and operational protocol endpoints"} -

- - OpenAI API Reference - open_in_new - -
- + )}
- {viewTab === "api" ? ( - -
-
-

{t("available")}

-

- {modelsLoading - ? translateOrFallback("loadingModels", "Loading available models...") - : t("modelsAcrossEndpoints", { - models: totalEndpointModelCount, - endpoints: availableEndpointCount, - })} -

-
+ +
+
+

{t("available")}

+

+ {modelsLoading + ? translateOrFallback("loadingModels", "Loading available models...") + : t("modelsAcrossEndpoints", { + models: totalEndpointModelCount, + endpoints: availableEndpointCount, + })} +

+
- {/* Core APIs */} -
+ {/* Core APIs */} +
+
+ hub +

+ {t("categoryCore") || "Core APIs"} +

+
+
+
+ + + + +
+
+ + {/* Media & Multi-Modal */} +
+
+ perm_media +

+ {t("categoryMedia") || "Media & Multi-Modal"} +

+
+
+
+ + + + + + + +
+
+ + {/* Search & Discovery */} + {searchProviders.length > 0 && ( +
- hub + + travel_explore +

- {t("categoryCore") || "Core APIs"} + {t("categorySearch") || "Search & Discovery"}

-
- {/* Chat Completions */} - setExpandedEndpoint(expandedEndpoint === "chat" ? null : "chat")} - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Responses API */} - - setExpandedEndpoint(expandedEndpoint === "responses" ? null : "responses") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Legacy Completions */} - - setExpandedEndpoint(expandedEndpoint === "completions" ? null : "completions") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> -
-
- - {/* Media & Multi-Modal */} -
-
- perm_media -

- {t("categoryMedia") || "Media & Multi-Modal"} -

-
-
-
- {/* Embeddings */} - - setExpandedEndpoint(expandedEndpoint === "embeddings" ? null : "embeddings") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Image Generation */} - - setExpandedEndpoint(expandedEndpoint === "images" ? null : "images") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Audio Transcription */} - - setExpandedEndpoint( - expandedEndpoint === "audioTranscription" ? null : "audioTranscription" - ) - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Audio Speech (TTS) */} - + - setExpandedEndpoint(expandedEndpoint === "audioSpeech" ? null : "audioSpeech") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Music Generation */} - setExpandedEndpoint(expandedEndpoint === "music" ? null : "music")} - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Video Generation */} - setExpandedEndpoint(expandedEndpoint === "video" ? null : "video")} - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> -
-
- - {/* Search & Discovery */} - {searchProviders.length > 0 && ( -
-
- - travel_explore - -

- {t("categorySearch") || "Search & Discovery"} -

-
-
-
- ({ - id: p.id, - name: p.name, - owned_by: p.id, - type: "search", - }))} - expanded={expandedEndpoint === "search"} - onToggle={() => - setExpandedEndpoint(expandedEndpoint === "search" ? null : "search") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - /> -
-
- )} - - {/* Utility & Management */} -
-
- build -

- {t("categoryUtility") || "Utility & Management"} -

-
-
-
- {/* Rerank */} - - setExpandedEndpoint(expandedEndpoint === "rerank" ? null : "rerank") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* Moderations */} - - setExpandedEndpoint(expandedEndpoint === "moderation" ? null : "moderation") - } - copy={copy} - copied={copied} - baseUrl={currentEndpoint} - modelsLoading={modelsLoading} - /> - - {/* List Models */} - - setExpandedEndpoint(expandedEndpoint === "models" ? null : "models") - } + title={t("webSearch") || "Web Search"} + path="/v1/search" + models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))} copy={copy} copied={copied} baseUrl={currentEndpoint} />
- - ) : ( - -
-
-

{t("protocolsTitle") || "Protocols"}

-

- {t("protocolsDescription") || - "MCP and A2A are first-class endpoints with dedicated observability and controls."} -

-
+ )} -
-
-
-
-

- - hub - - {t("mcpCardTitle") || "MCP Server"} -

-

- {t("mcpCardDescription") || "Model Context Protocol over stdio"} -

-
- - {mcpOnline ? tc("active") : tc("inactive")} - -
-
-

- {t("protocolToolsLabel") || "Tools"}:{" "} - {mcpToolCount || 29} -

-

- {t("protocolLastActivity") || "Last activity"}:{" "} - - {mcpStatus?.activity?.lastCallAt - ? new Date(mcpStatus.activity.lastCallAt).toLocaleString() - : "—"} - -

-
-
-

{t("quickStart") || "Quick Start"}

- omniroute --mcp -
-
- - {t("openMcpDashboard") || "Open MCP management"} → - -
-
- -
-
-
-

- - group_work - - {t("a2aCardTitle") || "A2A Server"} -

-

- {t("a2aCardDescription") || "Agent2Agent JSON-RPC endpoint"} -

-
- - {a2aOnline ? tc("active") : tc("inactive")} - -
-
-

- {t("protocolTasksLabel") || "Tasks"}:{" "} - - {a2aStatus?.tasks?.total || 0} - -

-

- {t("protocolActiveStreamsLabel") || "Active streams"}:{" "} - {a2aActiveStreams} -

-
-
-

{t("quickStart") || "Quick Start"}

- - {baseUrl.replace(/\/v1$/, "")}/a2a - -
-
- - {t("openA2aDashboard") || "Open A2A management"} → - -
-
-
- -
-
-

- {t("mcpQuickStartTitle") || "MCP Quick Start"} -

-
    -
  1. {t("mcpQuickStartStep1") || "Run the MCP server via `omniroute --mcp`."}
  2. -
  3. - {t("mcpQuickStartStep2") || - "Configure your MCP client to connect over stdio transport."} -
  4. -
  5. - {t("mcpQuickStartStep3") || - "Invoke tools such as `omniroute_get_health` and `omniroute_list_combos`."} -
  6. -
-
-
-

- {t("a2aQuickStartTitle") || "A2A Quick Start"} -

-
    -
  1. - {t("a2aQuickStartStep1") || - "Discover the agent card at `/.well-known/agent.json`."} -
  2. -
  3. - {t("a2aQuickStartStep2") || - "Send JSON-RPC requests to `POST /a2a` using `message/send` or `message/stream`."} -
  4. -
  5. - {t("a2aQuickStartStep3") || - "Track and control tasks using `tasks/get` and `tasks/cancel`."} -
  6. -
-
-
+ {/* Utility & Management */} +
+
+ build +

+ {t("categoryUtility") || "Utility & Management"} +

+
- - )} +
+ + + + + +
+
+ {/* Cloud Enable Modal */} ) { + const t = useTranslations("endpoint"); + const copyId = `endpoint_${path}`; + const fullUrl = `${baseUrl.replace(/\/v1$/, "")}${path}`; + + return ( +
+
+
+ {icon} +
+
+
+ {title} + {badge && ( + + {badge} + + )} +
+ + {models === null + ? "—" + : modelsLoading + ? "..." + : t("modelsCount", { count: models.length })} + +
+
+
+ + {path} + + +
+
+ ); +} + function EndpointSection({ icon, iconColor, diff --git a/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx index 521bb922ae..c7bd9081e9 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx @@ -295,15 +295,11 @@ export default function A2ADashboardPage() { }; if (loading) { - return ( -
-
{t("loading")}
-
- ); + return
{t("loading")}
; } return ( -
+
diff --git a/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx b/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx index e4bd6de419..0016b00508 100644 --- a/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx @@ -355,15 +355,11 @@ export default function McpDashboardPage() { const topTools = status?.activity?.topTools || []; if (loading) { - return ( -
-
{t("loading")}
-
- ); + return
{t("loading")}
; } return ( -
+
diff --git a/src/app/(dashboard)/dashboard/endpoint/page.tsx b/src/app/(dashboard)/dashboard/endpoint/page.tsx index 4d5d159e3e..740a86fbbc 100644 --- a/src/app/(dashboard)/dashboard/endpoint/page.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/page.tsx @@ -1,424 +1,5 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { Card, SegmentedControl } from "@/shared/components"; import EndpointPageClient from "./EndpointPageClient"; -import McpDashboardPage from "./components/MCPDashboard"; -import A2ADashboardPage from "./components/A2ADashboard"; -import ApiEndpointsTab from "./ApiEndpointsTab"; -import { useTranslations } from "next-intl"; -import { copyToClipboard } from "@/shared/utils/clipboard"; -type ServiceStatus = { - online: boolean; - loading: boolean; -}; - -type McpTransport = "stdio" | "sse" | "streamable-http"; - -/* ────── Toggle Switch ────── */ -function ServiceToggle({ - label, - status, - enabled, - onToggle, - toggling, -}: { - label: string; - status: ServiceStatus; - enabled: boolean; - onToggle: () => void; - toggling: boolean; -}) { - const online = enabled && status.online; - const loading = enabled && status.loading; - - return ( -
-
- - {loading ? "..." : online ? "Online" : "Offline"} -
- - - - - {toggling ? "..." : enabled ? "ON" : "OFF"} - -
- ); -} - -function DisabledServicePanel({ title, description }: { title: string; description: string }) { - return ( - -
-
-
-
-

- {title} -

-

- {description} -

-
-
-
- ); -} - -/* ────── Transport Selector ────── */ -function TransportSelector({ - value, - onChange, - disabled, - baseUrl, -}: { - value: McpTransport; - onChange: (t: McpTransport) => void; - disabled: boolean; - baseUrl: string; -}) { - const options: { value: McpTransport; label: string; desc: string }[] = [ - { value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" }, - { value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" }, - { - value: "streamable-http", - label: "Streamable HTTP", - desc: "Remote — Modern bidirectional HTTP", - }, - ]; - - const urlMap: Record = { - stdio: "omniroute --mcp", - sse: `${baseUrl}/api/mcp/sse`, - "streamable-http": `${baseUrl}/api/mcp/stream`, - }; - - return ( -
-
- - swap_horiz - - - Transport Mode - -
- -
- {options.map((opt) => ( - - ))} -
- - {/* Connection info */} -
- - {value === "stdio" ? "terminal" : "link"} - - - {urlMap[value]} - - {value !== "stdio" && ( - - )} -
-
- ); -} - -/* ────── Main Page ────── */ export default function EndpointPage() { - const [activeTab, setActiveTab] = useState("endpoint-proxy"); - const t = useTranslations("endpoints"); - - const [mcpStatus, setMcpStatus] = useState({ online: false, loading: true }); - const [a2aStatus, setA2aStatus] = useState({ online: false, loading: true }); - const [mcpEnabled, setMcpEnabled] = useState(false); - const [a2aEnabled, setA2aEnabled] = useState(false); - const [mcpToggling, setMcpToggling] = useState(false); - const [a2aToggling, setA2aToggling] = useState(false); - const [mcpTransport, setMcpTransport] = useState("stdio"); - const [transportSaving, setTransportSaving] = useState(false); - - const [baseUrl, setBaseUrl] = useState(""); - - // Detect base URL from browser - useEffect(() => { - if (typeof window !== "undefined") { - setBaseUrl(`${window.location.protocol}//${window.location.host}`); - } - }, []); - - // Fetch initial settings - useEffect(() => { - const fetchSettings = async () => { - try { - const res = await fetch("/api/settings"); - if (res.ok) { - const data = await res.json(); - setMcpEnabled(!!data.mcpEnabled); - setA2aEnabled(!!data.a2aEnabled); - setMcpTransport((data.mcpTransport as McpTransport) || "stdio"); - } - } catch { - // defaults stay - } - }; - void fetchSettings(); - }, []); - - const patchSetting = useCallback(async (body: Record) => { - return fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - }, []); - - const toggleService = useCallback( - async (service: "mcp" | "a2a") => { - const setToggling = service === "mcp" ? setMcpToggling : setA2aToggling; - const setEnabled = service === "mcp" ? setMcpEnabled : setA2aEnabled; - const currentlyEnabled = service === "mcp" ? mcpEnabled : a2aEnabled; - const newValue = !currentlyEnabled; - - setToggling(true); - try { - const res = await patchSetting({ - [service === "mcp" ? "mcpEnabled" : "a2aEnabled"]: newValue, - }); - if (res.ok) setEnabled(newValue); - } catch { - // keep current state - } finally { - setToggling(false); - } - }, - [mcpEnabled, a2aEnabled, patchSetting] - ); - - const changeTransport = useCallback( - async (newTransport: McpTransport) => { - setTransportSaving(true); - try { - const res = await patchSetting({ mcpTransport: newTransport }); - if (res.ok) setMcpTransport(newTransport); - } catch { - // keep current - } finally { - setTransportSaving(false); - } - }, - [patchSetting] - ); - - const refreshMcpStatus = useCallback(async () => { - setMcpStatus((prev) => ({ ...prev, loading: true })); - try { - const res = await fetch("/api/mcp/status"); - if (res.ok) { - const data = await res.json(); - setMcpStatus({ online: !!data.online, loading: false }); - } else { - setMcpStatus({ online: false, loading: false }); - } - } catch { - setMcpStatus({ online: false, loading: false }); - } - }, []); - - const refreshA2aStatus = useCallback(async () => { - setA2aStatus((prev) => ({ ...prev, loading: true })); - try { - const res = await fetch("/api/a2a/status"); - if (res.ok) { - const data = await res.json(); - setA2aStatus({ online: data.status === "ok", loading: false }); - } else { - setA2aStatus({ online: false, loading: false }); - } - } catch { - setA2aStatus({ online: false, loading: false }); - } - }, []); - - useEffect(() => { - const load = () => { - void refreshMcpStatus(); - void refreshA2aStatus(); - }; - load(); - const interval = setInterval(load, 30000); - return () => clearInterval(interval); - }, [refreshMcpStatus, refreshA2aStatus]); - - return ( -
-
- - - {activeTab === "mcp" && ( - void toggleService("mcp")} - toggling={mcpToggling} - /> - )} - {activeTab === "a2a" && ( - void toggleService("a2a")} - toggling={a2aToggling} - /> - )} -
- - {/* Transport selector for MCP */} - {activeTab === "mcp" && mcpEnabled && ( - void changeTransport(t)} - disabled={transportSaving} - baseUrl={baseUrl} - /> - )} - - {activeTab === "endpoint-proxy" && } - {activeTab === "mcp" && } - {activeTab === "a2a" && - (a2aEnabled ? ( - - ) : ( - - ))} - {activeTab === "api-endpoints" && } -
- ); + return ; } diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index 8d1265b390..edbd9972b6 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -157,7 +157,7 @@ export default function HealthPage() { if (!data && !error) { return ( -
+

{t("loadingHealth")}

@@ -168,7 +168,7 @@ export default function HealthPage() { if (error && !data) { return ( -
+
error

{t("failedToLoad", { error })}

@@ -197,31 +197,24 @@ export default function HealthPage() { const lockoutEntries = Object.entries(lockouts || {}); return ( -
- {/* Header */} -
-
-

{t("title")}

-

{t("description")}

-
-
- {lastRefresh && ( - - {t("updatedAt", { time: lastRefresh.toLocaleTimeString() })} - - )} - -
+
+
+ {lastRefresh && ( + + {t("updatedAt", { time: lastRefresh.toLocaleTimeString() })} + + )} +
{/* Status Banner */} diff --git a/src/app/(dashboard)/dashboard/logs/activity/page.tsx b/src/app/(dashboard)/dashboard/logs/activity/page.tsx new file mode 100644 index 0000000000..02e5bd4ca7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/logs/activity/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import AuditLogTab from "../AuditLogTab"; + +export default function LogsActivityPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/logs/console/page.tsx b/src/app/(dashboard)/dashboard/logs/console/page.tsx new file mode 100644 index 0000000000..720e574b02 --- /dev/null +++ b/src/app/(dashboard)/dashboard/logs/console/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer"; + +export default function LogsConsolePage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 48e677fabc..7eb482c952 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -1,11 +1,8 @@ "use client"; import { useState, useRef, useEffect } from "react"; -import { useSearchParams } from "next/navigation"; -import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components"; -import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer"; +import { RequestLoggerV2 } from "@/shared/components"; import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel"; -import AuditLogTab from "./AuditLogTab"; import { useTranslations } from "next-intl"; const TIME_RANGES = [ @@ -15,30 +12,14 @@ const TIME_RANGES = [ { label: "24h", hours: 24 }, ]; -const TAB_TO_LOG_TYPE: Record = { - "request-logs": "request-logs", - "proxy-logs": "proxy-logs", - "audit-logs": "call-logs", - console: "call-logs", -}; +const LOG_TYPE = "request-logs"; export default function LogsPage() { - const searchParams = useSearchParams(); - const requestedTab = searchParams.get("tab"); - const [activeTab, setActiveTab] = useState( - requestedTab && TAB_TO_LOG_TYPE[requestedTab] ? requestedTab : "request-logs" - ); const [showExport, setShowExport] = useState(false); const [exporting, setExporting] = useState(false); const dropdownRef = useRef(null); const t = useTranslations("logs"); - useEffect(() => { - if (requestedTab && TAB_TO_LOG_TYPE[requestedTab] && requestedTab !== activeTab) { - setActiveTab(requestedTab); - } - }, [activeTab, requestedTab]); - useEffect(() => { function handleClickOutside(e: MouseEvent) { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { @@ -53,14 +34,13 @@ export default function LogsPage() { setExporting(true); setShowExport(false); try { - const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs"; - const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`); + const res = await fetch(`/api/logs/export?hours=${hours}&type=${LOG_TYPE}`); if (!res.ok) throw new Error(t("exportFailed")); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `omniroute-${logType}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`; + a.download = `omniroute-${LOG_TYPE}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -74,18 +54,7 @@ export default function LogsPage() { return (
-
- - +
- {/* Content */} - {activeTab === "request-logs" && ( -
- - -
- )} - {activeTab === "proxy-logs" && } - {activeTab === "audit-logs" && } - {activeTab === "console" && } +
+ + +
); } diff --git a/src/app/(dashboard)/dashboard/logs/proxy/page.tsx b/src/app/(dashboard)/dashboard/logs/proxy/page.tsx new file mode 100644 index 0000000000..8651d522f8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/logs/proxy/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ProxyLogger from "@/shared/components/ProxyLogger"; + +export default function LogsProxyPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/mcp/page.tsx b/src/app/(dashboard)/dashboard/mcp/page.tsx new file mode 100644 index 0000000000..bb6d677505 --- /dev/null +++ b/src/app/(dashboard)/dashboard/mcp/page.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card } from "@/shared/components"; +import { copyToClipboard } from "@/shared/utils/clipboard"; +import McpDashboardPage from "../endpoint/components/MCPDashboard"; + +type ServiceStatus = { online: boolean; loading: boolean }; +type McpTransport = "stdio" | "sse" | "streamable-http"; + +function ServiceToggle({ + label, + status, + enabled, + onToggle, + toggling, +}: { + label: string; + status: ServiceStatus; + enabled: boolean; + onToggle: () => void; + toggling: boolean; +}) { + const online = enabled && status.online; + const loading = enabled && status.loading; + + return ( +
+
+ + {loading ? "..." : online ? "Online" : "Offline"} +
+ + + + + {toggling ? "..." : enabled ? "ON" : "OFF"} + +
+ ); +} + +function TransportSelector({ + value, + onChange, + disabled, + baseUrl, +}: { + value: McpTransport; + onChange: (t: McpTransport) => void; + disabled: boolean; + baseUrl: string; +}) { + const options: { value: McpTransport; label: string; desc: string }[] = [ + { value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" }, + { value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" }, + { + value: "streamable-http", + label: "Streamable HTTP", + desc: "Remote — Modern bidirectional HTTP", + }, + ]; + + const urlMap: Record = { + stdio: "omniroute --mcp", + sse: `${baseUrl}/api/mcp/sse`, + "streamable-http": `${baseUrl}/api/mcp/stream`, + }; + + return ( +
+
+ + swap_horiz + + + Transport Mode + +
+ +
+ {options.map((opt) => ( + + ))} +
+ +
+ + {value === "stdio" ? "terminal" : "link"} + + + {urlMap[value]} + + {value !== "stdio" && ( + + )} +
+
+ ); +} + +function DisabledPanel() { + return ( + +
+
+
+
+

+ MCP is disabled +

+

+ Enable MCP above to configure transport mode and view server telemetry. +

+
+
+
+ ); +} + +export default function McpPage() { + const [mcpStatus, setMcpStatus] = useState({ online: false, loading: true }); + const [mcpEnabled, setMcpEnabled] = useState(false); + const [mcpToggling, setMcpToggling] = useState(false); + const [mcpTransport, setMcpTransport] = useState("stdio"); + const [transportSaving, setTransportSaving] = useState(false); + const [baseUrl, setBaseUrl] = useState(""); + + useEffect(() => { + if (typeof window !== "undefined") { + setBaseUrl(`${window.location.protocol}//${window.location.host}`); + } + }, []); + + const patchSetting = useCallback(async (body: Record) => { + return fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + }, []); + + useEffect(() => { + const fetchSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const data = await res.json(); + setMcpEnabled(!!data.mcpEnabled); + setMcpTransport((data.mcpTransport as McpTransport) || "stdio"); + } + } catch { + // defaults stay + } + }; + void fetchSettings(); + }, []); + + const refreshStatus = useCallback(async () => { + setMcpStatus((prev) => ({ ...prev, loading: true })); + try { + const res = await fetch("/api/mcp/status"); + setMcpStatus({ online: res.ok ? !!(await res.json()).online : false, loading: false }); + } catch { + setMcpStatus({ online: false, loading: false }); + } + }, []); + + useEffect(() => { + void refreshStatus(); + const interval = setInterval(() => void refreshStatus(), 30000); + return () => clearInterval(interval); + }, [refreshStatus]); + + const toggleMcp = useCallback(async () => { + const newValue = !mcpEnabled; + setMcpToggling(true); + try { + const res = await patchSetting({ mcpEnabled: newValue }); + if (res.ok) setMcpEnabled(newValue); + } catch { + // keep current + } finally { + setMcpToggling(false); + } + }, [mcpEnabled, patchSetting]); + + const changeTransport = useCallback( + async (newTransport: McpTransport) => { + setTransportSaving(true); + try { + const res = await patchSetting({ mcpTransport: newTransport }); + if (res.ok) setMcpTransport(newTransport); + } catch { + // keep current + } finally { + setTransportSaving(false); + } + }, + [patchSetting] + ); + + return ( +
+ +
+
+

+ Model Context Protocol — 37 tools across 13 scopes, 3 transports (stdio / SSE / + Streamable HTTP). +

+
    +
  1. + Run via omniroute --mcp +
  2. +
  3. Configure your MCP client to connect over stdio transport.
  4. +
  5. + Invoke tools like omniroute_get_health and{" "} + omniroute_list_combos. +
  6. +
+
+
+ void toggleMcp()} + toggling={mcpToggling} + /> +
+
+
+ + {mcpEnabled && ( + void changeTransport(t)} + disabled={transportSaving} + baseUrl={baseUrl} + /> + )} + + {mcpEnabled ? : } +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/memory/page.tsx b/src/app/(dashboard)/dashboard/memory/page.tsx index 6b36e5834b..87e29e15eb 100644 --- a/src/app/(dashboard)/dashboard/memory/page.tsx +++ b/src/app/(dashboard)/dashboard/memory/page.tsx @@ -203,31 +203,28 @@ export default function MemoryPage() { } return ( -
-
-
-

{t("title")}

-
- {health !== null && ( - - )} - {health === null && !checkingHealth && ( - - )} - -
+
+
+
+ {health !== null && ( + + )} + {health === null && !checkingHealth && ( + + )} +
- {isBootstrapped && } - - - ); +export default function DashboardPage() { + redirect("/home"); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 3cd0031338..58651ada52 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -19,9 +19,11 @@ import { Toggle, Select, ProxyConfigModal, + NoAuthProviderCard, } from "@/shared/components"; import { LOCAL_PROVIDERS, + FREE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, @@ -1086,6 +1088,7 @@ export default function ProviderDetailPage() { providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free"; const providerSupportsPat = supportsApiKeyOnFreeProvider(providerId); const isOAuth = providerSupportsOAuth && !providerSupportsPat; + const isFreeNoAuth = FREE_PROVIDERS[providerId]?.noAuth === true; const registryModels = getModelsByProviderId(providerId); // Prefer synced API-discovered models when available, then merge built-ins // and user-managed custom models without duplicating IDs. @@ -2545,7 +2548,7 @@ export default function ProviderDetailPage() { } }; - const canImportModels = connections.some((conn) => conn.isActive !== false); + const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false); // Auto-sync toggle state: read from first active connection's providerSpecificData const autoSyncConnection = connections.find((conn: any) => conn.isActive !== false); @@ -3174,7 +3177,8 @@ export default function ProviderDetailPage() { )} {/* Connections */} - {!isUpstreamProxyProvider && ( + {!isUpstreamProxyProvider && isFreeNoAuth && } + {!isUpstreamProxyProvider && !isFreeNoAuth && (
diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx index ed6c405d32..60710bdfed 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -51,6 +51,7 @@ const DOT_COLORS: Record = { audio: "bg-rose-500", local: "bg-emerald-500", "upstream-proxy": "bg-indigo-500", + "cloud-agent": "bg-violet-500", }; function getStatusDisplay( @@ -149,25 +150,27 @@ export default function ProviderCard({
{staticIconPath ? ( {provider.name} ) : ( - + )}
-

- +

+ {provider.name} {provider.deprecated && ( @@ -183,9 +186,15 @@ export default function ProviderCard({ )} + {provider.hasFree === true && authType !== "free" && ( + + )}

{allDisabled ? ( @@ -198,18 +207,6 @@ export default function ProviderCard({ ) : ( <> {getStatusDisplay(connected, error, stats.errorCode, t, codexFastChip)} - {(authType === "free" || provider.hasFree === true) && ( - - - redeem - {t("freeTier")} - - - )} {stats.expiryStatus === "expired" && ( {t("expiredBadge")} @@ -247,7 +244,7 @@ export default function ProviderCard({ {Number(stats.total || 0) > 0 && (
{}} title={allDisabled ? t("enableProvider") : t("disableProvider")} diff --git a/src/app/(dashboard)/dashboard/providers/error.tsx b/src/app/(dashboard)/dashboard/providers/error.tsx index 413570f9c0..086ef9d5b6 100644 --- a/src/app/(dashboard)/dashboard/providers/error.tsx +++ b/src/app/(dashboard)/dashboard/providers/error.tsx @@ -9,7 +9,7 @@ export default function ProvidersError({ }) { return (
diff --git a/src/app/(dashboard)/dashboard/providers/loading.tsx b/src/app/(dashboard)/dashboard/providers/loading.tsx index b02bba2e76..7a05100584 100644 --- a/src/app/(dashboard)/dashboard/providers/loading.tsx +++ b/src/app/(dashboard)/dashboard/providers/loading.tsx @@ -4,7 +4,7 @@ import { CardSkeleton, Skeleton } from "@/shared/components/Loading"; export default function ProvidersLoading() { return ( -
+
{[0, 1, 2].map((index) => ( diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index ab3a1c583b..bdeda2ad53 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { CardSkeleton, Badge, Button, Input, Toggle } from "@/shared/components"; +import { Card, CardSkeleton, Badge, Button, Input, Toggle } from "@/shared/components"; import { FREE_PROVIDERS, OAUTH_PROVIDERS, @@ -532,6 +532,57 @@ export default function ProvidersPage() { searchQuery ); + const FREE_SECTION_IDS = new Set([ + "kiro", + "amazon-q", + "gemini-cli", + "qoder", + "pollinations", + "llm7", + "opencode", + "gemini", + "groq", + "cerebras", + "mistral", + "nvidia", + "openrouter", + "cloudflare-ai", + "together", + "siliconflow", + "deepseek", + "longcat", + "glhf", + "morph", + "bazaarlink", + "uncloseai", + "completions", + "freetheai", + "enally", + "puter", + "blackbox", + ]); + const freeSectionEntriesAll = [...oauthProviderEntriesAll, ...apiKeyProviderEntriesAll].filter( + (e) => FREE_SECTION_IDS.has(e.providerId) + ); + const freeSectionEntries = filterConfiguredProviderEntries( + freeSectionEntriesAll, + showConfiguredOnly, + searchQuery + ); + + const oauthOnlyEntriesAll = oauthProviderEntriesAll.filter((e) => e.toggleAuthType === "oauth"); + const summaryStats = { + all: { + configured: + oauthProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length + + apiKeyProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length, + total: oauthProviderEntriesAll.length + apiKeyProviderEntriesAll.length, + }, + free: countConfigured(freeSectionEntriesAll), + oauth: countConfigured(oauthOnlyEntriesAll), + apikey: countConfigured(apiKeyProviderEntriesAll), + }; + if (loading) { return (
@@ -543,29 +594,164 @@ export default function ProvidersPage() { return (
- {/* Search Bar */} -
-
- - search - - setSearchQuery(e.target.value)} - placeholder={t("searchProviders")} - aria-label={t("searchProviders")} - className="pl-10 pr-10" - /> - {searchQuery && ( + {/* Provider Summary Card */} + +
+ {/* Row 1: Search + Controls */} +
+
+ setSearchQuery(e.target.value)} + placeholder={t("searchProviders")} + aria-label={t("searchProviders")} + icon="search" + inputClassName={searchQuery ? "pr-9" : ""} + /> + {searchQuery && ( + + )} +
+ - )} +
+ + {/* Row 2: Legend */} +
+ {( + [ + ["bg-green-500", tc("free")], + ["bg-blue-500", t("oauthLabel")], + ["bg-amber-500", t("apiKeyLabel")], + ["bg-orange-500", t("compatibleLabel")], + ["bg-purple-500", t("webCookieProviders")], + ["bg-teal-500", t("searchProvidersHeading")], + ["bg-rose-500", t("audioProvidersHeading")], + ["bg-emerald-500", t("localProviders")], + ["bg-indigo-500", t("upstreamProxyProviders")], + ["bg-violet-500", t("cloudAgentProviders")], + ] as [string, string][] + ).map(([color, label]) => ( + + + {label} + + ))} +
+ + {/* Divider + Stats */} +
+ {( + [ + [null, t("providerSummaryAll"), summaryStats.all], + ["bg-green-500", tc("free"), summaryStats.free], + ["bg-blue-500", t("oauthLabel"), summaryStats.oauth], + ["bg-amber-500", t("apiKeyLabel"), summaryStats.apikey], + ] as [string | null, string, { configured: number; total: number }][] + ).map(([color, label, stat]) => ( + + {color && } + {label} + + {stat.configured} + /{stat.total} + + + ))} +
+
+ + {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} +
+
+

+ {t("compatibleProviders")}{" "} + + +

+
+ {(compatibleProviders.length > 0 || + anthropicCompatibleProviders.length > 0 || + ccCompatibleProviders.length > 0) && ( + + )} + {ccCompatibleProviderEnabled && ( + + )} + + +
+
+ {compatibleProviders.length === 0 && + anthropicCompatibleProviders.length === 0 && + ccCompatibleProviders.length === 0 ? ( +
+ extension + {t("noCompatibleYet")} +
+ ) : ( +
+ {compatibleProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+ )}
{/* Expiration Banner */} @@ -604,6 +790,51 @@ export default function ProvidersPage() {
)} + {/* Free Tier Providers */} + {freeSectionEntries.length > 0 && ( +
+
+
+

+ {t("freeTierProviders")} + + +

+

{t("freeTierProvidersDesc")}

+
+ +
+
+ {freeSectionEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + {/* OAuth Providers (including providers that expose free tiers via OAuth) */}
@@ -613,13 +844,6 @@ export default function ProvidersPage() {

-
-
+
{oauthProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( {t("llmProviders")} -
+
{llmProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( {t("aggregatorsGateways")} -
+
{aggregatorProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( {t("enterpriseCloud")} -
+
{enterpriseProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( {t("imageProviders")} -
+
{imageProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( {t("videoProviders")} -
+
{videoProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( {t("embeddingRerankProviders")} -
+
{embeddingRerankProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => (
-
- {webCookieProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} +
+ {webCookieProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))}
)} @@ -916,19 +1138,17 @@ export default function ProvidersPage() { {testingMode === "search" ? t("testing") : t("testAll")}
-
- {searchProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} +
+ {searchProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))}
)} @@ -961,19 +1181,17 @@ export default function ProvidersPage() { {testingMode === "audio" ? t("testing") : t("testAll")}
-
- {audioProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} +
+ {audioProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))}
)} @@ -1006,19 +1224,17 @@ export default function ProvidersPage() { {testingMode === "cloud-agent" ? t("testing") : t("testAll")}
-
- {cloudAgentProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} +
+ {cloudAgentProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))}
)} @@ -1048,19 +1264,17 @@ export default function ProvidersPage() { {testingMode === "local" ? t("testing") : t("testAll")}
-
- {localProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} +
+ {localProviderEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))}
)} @@ -1093,91 +1307,21 @@ export default function ProvidersPage() { {testingMode === "upstream-proxy" ? t("testing") : t("testAll")}
-
- {upstreamProxyEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} +
+ {upstreamProxyEntries.map(({ providerId, provider, stats, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ))}
)} - {/* API Key Compatible Providers — dynamic (OpenAI/Anthropic compatible) */} -
-
-

- {t("compatibleProviders")}{" "} - - -

-
- {(compatibleProviders.length > 0 || - anthropicCompatibleProviders.length > 0 || - ccCompatibleProviders.length > 0) && ( - - )} - {ccCompatibleProviderEnabled && ( - - )} - - -
-
- {compatibleProviders.length === 0 && - anthropicCompatibleProviders.length === 0 && - ccCompatibleProviders.length === 0 ? ( -
- - extension - -

{t("noCompatibleYet")}

-

{t("compatibleHint")}

-
- ) : ( -
- {compatibleProviderEntries.map( - ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} - /> - ) - )} -
- )} -
+ + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/ai/page.tsx b/src/app/(dashboard)/dashboard/settings/ai/page.tsx new file mode 100644 index 0000000000..beb997c07e --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/ai/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import ThinkingBudgetTab from "../components/ThinkingBudgetTab"; +import VisionBridgeSettingsTab from "../components/VisionBridgeSettingsTab"; +import SystemPromptTab from "../components/SystemPromptTab"; +import MemorySkillsTab from "../components/MemorySkillsTab"; +import ModelsDevSyncTab from "../components/ModelsDevSyncTab"; + +export default function SettingsAiPage() { + return ( +
+ + + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/appearance/page.tsx b/src/app/(dashboard)/dashboard/settings/appearance/page.tsx new file mode 100644 index 0000000000..c078882485 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/appearance/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import AppearanceTab from "../components/AppearanceTab"; + +export default function SettingsAppearancePage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx index ce24000c43..73a78060b6 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx @@ -15,6 +15,7 @@ import { HIDDEN_SIDEBAR_ITEMS_SETTING_KEY, SIDEBAR_SECTIONS, SIDEBAR_SETTINGS_UPDATED_EVENT, + getSectionItems, normalizeHiddenSidebarItems, type HideableSidebarItemId, } from "@/shared/constants/sidebarVisibility"; @@ -102,7 +103,7 @@ export default function AppearanceTab() { } } } catch (err) { - console.error(`Failed to update ${key}:`, err); + console.error("Failed to update", key, err); } }; @@ -148,7 +149,7 @@ export default function AppearanceTab() { ).map((section) => ({ ...section, title: getSidebarLabel(section.titleKey, section.titleFallback), - items: section.items.map((item) => ({ ...item, label: tSidebar(item.i18nKey) })), + items: getSectionItems(section).map((item) => ({ ...item, label: tSidebar(item.i18nKey) })), })); const toggleSidebarItem = (itemId: HideableSidebarItemId) => { diff --git a/src/app/(dashboard)/dashboard/settings/error.tsx b/src/app/(dashboard)/dashboard/settings/error.tsx index e5683558fa..4449ef5cb3 100644 --- a/src/app/(dashboard)/dashboard/settings/error.tsx +++ b/src/app/(dashboard)/dashboard/settings/error.tsx @@ -9,7 +9,7 @@ export default function SettingsError({ }) { return (
diff --git a/src/app/(dashboard)/dashboard/settings/general/page.tsx b/src/app/(dashboard)/dashboard/settings/general/page.tsx new file mode 100644 index 0000000000..ebbe161ab1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/general/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import SystemStorageTab from "../components/SystemStorageTab"; + +export default function SettingsGeneralPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/settings/loading.tsx b/src/app/(dashboard)/dashboard/settings/loading.tsx index 9ba5628e20..7130794500 100644 --- a/src/app/(dashboard)/dashboard/settings/loading.tsx +++ b/src/app/(dashboard)/dashboard/settings/loading.tsx @@ -4,7 +4,7 @@ import { Skeleton } from "@/shared/components/Loading"; export default function SettingsLoading() { return ( -
+
{[0, 1, 2, 3].map((index) => ( diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index ecc54f1133..53ef8d7d28 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -1,156 +1,5 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { cn } from "@/shared/utils/cn"; -import { APP_CONFIG } from "@/shared/constants/appConfig"; -import { useTranslations } from "next-intl"; -import SystemStorageTab from "./components/SystemStorageTab"; -import SecurityTab from "./components/SecurityTab"; -import RoutingTab from "./components/RoutingTab"; -import ComboDefaultsTab from "./components/ComboDefaultsTab"; -import AppearanceTab from "./components/AppearanceTab"; -import ThinkingBudgetTab from "./components/ThinkingBudgetTab"; -import SystemPromptTab from "./components/SystemPromptTab"; -import ModelAliasesUnified from "./components/ModelAliasesUnified"; -import BackgroundDegradationTab from "./components/BackgroundDegradationTab"; -import MemorySkillsTab from "./components/MemorySkillsTab"; -import ModelsDevSyncTab from "./components/ModelsDevSyncTab"; -import ResilienceTab from "./components/ResilienceTab"; -import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab"; -import PayloadRulesTab from "./components/PayloadRulesTab"; -import VisionBridgeSettingsTab from "./components/VisionBridgeSettingsTab"; -import RequestLimitsTab from "./components/RequestLimitsTab"; -import ModelRoutingSection from "@/shared/components/ModelRoutingSection"; - -const tabs = [ - { id: "general", labelKey: "general", icon: "settings" }, - { id: "appearance", labelKey: "appearance", icon: "palette" }, - { id: "ai", labelKey: "ai", icon: "smart_toy" }, - { id: "security", labelKey: "security", icon: "shield" }, - { id: "routing", labelKey: "routing", icon: "route" }, - { id: "resilience", labelKey: "resilience", icon: "electrical_services" }, - { id: "advanced", labelKey: "advanced", icon: "tune" }, -]; +import { redirect } from "next/navigation"; export default function SettingsPage() { - const t = useTranslations("settings"); - const searchParams = useSearchParams(); - const tabParam = searchParams.get("tab"); - const [userSelectedTab, setUserSelectedTab] = useState(null); - const activeTab = userSelectedTab || tabs.find((t) => t.id === tabParam)?.id || "general"; - - return ( -
-
- {/* Tab navigation */} -
-
- {tabs.map((tab) => ( - - ))} -
-
- - {/* Tab contents */} -
t2.id === activeTab)?.labelKey || "general")} - > - {activeTab === "general" && ( -
- -
- )} - - {activeTab === "appearance" && ( -
- -
- )} - - {activeTab === "ai" && ( -
- - - - - compress - - - - {t("compressionTitle")} - - {t("compressionDesc")} - - - - chevron_right - - - - - - -
- )} - - {activeTab === "security" && } - - {activeTab === "routing" && ( -
- - - - - -
- )} - - {activeTab === "resilience" && } - - {activeTab === "advanced" && ( -
- - - -
- )} -
- - {/* App Info */} -
-

- {APP_CONFIG.name} v{APP_CONFIG.version} -

-

{t("localMode")}

-
-
-
- ); + redirect("/dashboard/settings/general"); } diff --git a/src/app/(dashboard)/dashboard/settings/pricing/page.tsx b/src/app/(dashboard)/dashboard/settings/pricing/page.tsx index b2c3acf263..2a21ff7ddf 100644 --- a/src/app/(dashboard)/dashboard/settings/pricing/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/pricing/page.tsx @@ -1,170 +1,5 @@ -"use client"; +import { redirect } from "next/navigation"; -import { useState, useEffect } from "react"; -import { useRouter } from "next/navigation"; -import Card from "@/shared/components/Card"; -import PricingModal from "@/shared/components/PricingModal"; -import { useTranslations } from "next-intl"; - -export default function PricingSettingsPage() { - const router = useRouter(); - const [showModal, setShowModal] = useState(false); - const [currentPricing, setCurrentPricing] = useState(null); - const [loading, setLoading] = useState(true); - const t = useTranslations("settings"); - - useEffect(() => { - loadPricing(); - }, []); - - const loadPricing = async () => { - setLoading(true); - try { - const response = await fetch("/api/pricing"); - if (response.ok) { - const data = await response.json(); - setCurrentPricing(data); - } - } catch (error) { - console.error("Failed to load pricing:", error); - } finally { - setLoading(false); - } - }; - - const handlePricingUpdated = () => { - loadPricing(); - }; - - // Count total models with pricing - const getModelCount = () => { - if (!currentPricing) return 0; - let count = 0; - for (const provider in currentPricing) { - count += Object.keys(currentPricing[provider]).length; - } - return count; - }; - - // Get providers list - const getProviders = () => { - if (!currentPricing) return []; - return Object.keys(currentPricing).sort(); - }; - - return ( -
- {/* Header */} -
-
-

{t("pricingSettingsTitle")}

-

{t("modelPricingDesc")}

-
- -
- - {/* Quick Stats */} -
- -
{t("totalModels")}
-
{loading ? "..." : getModelCount()}
-
- -
{t("providers")}
-
{loading ? "..." : getProviders().length}
-
- -
{t("status")}
-
- {loading ? "..." : t("active")} -
-
-
- - {/* Info Section */} - -

{t("howPricingWorks")}

-
-

- {t("costCalculation")}: {t("costCalculationDesc")} -

-

- {t("pricingFormat")}: {t("pricingFormatDesc")} -

-

- {t("tokenTypes")}: -

-
    -
  • - {t("input")}: {t("inputTokenDesc")} -
  • -
  • - {t("output")}: {t("outputTokenDesc")} -
  • -
  • - {t("cached")}: {t("cachedTokenDesc")} -
  • -
  • - {t("reasoning")}: {t("reasoningTokenDesc")} -
  • -
  • - {t("cacheCreation")}: {t("cacheCreationTokenDesc")} -
  • -
-

{t("customPricingNote")}

-
-
- - {/* Current Pricing Preview */} - -
-

{t("currentPricing")}

- -
- - {loading ? ( -
{t("loadingPricing")}
- ) : currentPricing ? ( -
- {Object.keys(currentPricing) - .slice(0, 5) - .map((provider) => ( -
- {provider.toUpperCase()}:{" "} - - {Object.keys(currentPricing[provider]).length} {t("models")} - -
- ))} - {Object.keys(currentPricing).length > 5 && ( -
- + {t("moreProviders", { count: Object.keys(currentPricing).length - 5 })} -
- )} -
- ) : ( -
{t("noPricing")}
- )} -
- - {/* Pricing Modal */} - {showModal && ( - setShowModal(false)} - onSave={handlePricingUpdated} - /> - )} -
- ); +export default function SettingsPricingPage() { + redirect("/dashboard/costs/pricing"); } diff --git a/src/app/(dashboard)/dashboard/settings/resilience/page.tsx b/src/app/(dashboard)/dashboard/settings/resilience/page.tsx new file mode 100644 index 0000000000..9b71032bda --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/resilience/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ResilienceTab from "../components/ResilienceTab"; + +export default function SettingsResiliencePage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/settings/routing/page.tsx b/src/app/(dashboard)/dashboard/settings/routing/page.tsx new file mode 100644 index 0000000000..ae38e9cf13 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/routing/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import RoutingTab from "../components/RoutingTab"; +import ModelRoutingSection from "@/shared/components/ModelRoutingSection"; +import ComboDefaultsTab from "../components/ComboDefaultsTab"; +import ModelAliasesUnified from "../components/ModelAliasesUnified"; +import BackgroundDegradationTab from "../components/BackgroundDegradationTab"; + +export default function SettingsRoutingPage() { + return ( +
+ + + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/security/page.tsx b/src/app/(dashboard)/dashboard/settings/security/page.tsx new file mode 100644 index 0000000000..a711a26b2a --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/security/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import SecurityTab from "../components/SecurityTab"; + +export default function SettingsSecurityPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/skills/page.tsx b/src/app/(dashboard)/dashboard/skills/page.tsx index 2c2ed48408..48c42c29e7 100644 --- a/src/app/(dashboard)/dashboard/skills/page.tsx +++ b/src/app/(dashboard)/dashboard/skills/page.tsx @@ -312,11 +312,7 @@ export default function SkillsPage() { return (
-
-
-

OmniSkills

-

{t("description")}

-
+
- ))} -
-
- -
t.id === activeSubTab)?.labelKey || "proxy")} - > - {activeSubTab === "http" && ( -
- -
- )} - - {activeSubTab === "mitm" && ( -
- -
- )} - - {activeSubTab === "oneproxy" && ( -
- -
- )} -
-
-
- ); + return ; } diff --git a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx index 3d57ac02ad..213eb28a7f 100644 --- a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx +++ b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx @@ -72,27 +72,15 @@ export default function TranslatorPageClient() { }; return ( -
- {/* Header */} -
-
-

- translate - {t("playgroundTitle")} -

-

- {modeDescriptions[mode] || t("modeDescriptionFallback")} -

-
-
- -
+
+
+
diff --git a/src/app/(dashboard)/dashboard/webhooks/page.tsx b/src/app/(dashboard)/dashboard/webhooks/page.tsx index 9bc2470d0f..5205fdd07f 100644 --- a/src/app/(dashboard)/dashboard/webhooks/page.tsx +++ b/src/app/(dashboard)/dashboard/webhooks/page.tsx @@ -273,15 +273,8 @@ export default function WebhooksPage() { const isModalOpen = formMode !== null; return ( -
-
-
-
- webhook -

{t("title")}

-
-

{t("description")}

-
+
+
- {/* Page title with breadcrumbs - desktop */} -
- {breadcrumbs.length > 0 ? ( -
- {breadcrumbs.map((crumb, index) => ( -
- {index > 0 && ( - - chevron_right - - )} - {crumb.href ? ( - - {crumb.label} - - ) : ( -
- {crumb.image && ( - {crumb.label} { - e.currentTarget.style.display = "none"; - }} - /> - )} - {crumb.providerId && ( - - )} -

- {crumb.label} -

-
- )} -
- ))} + {/* Page title with icon - desktop */} +
+ {(icon || providerId) && ( +
+ {icon ? ( + {icon} + ) : ( + providerId && + )}
- ) : title ? ( + )} + {title && (
-

{title}

- {description &&

{description}

} +

{title}

+ {description &&

{description}

}
- ) : null} + )}
{/* Right actions */}
- {/* Language selector */} - - {/* Theme toggle */} - - {/* Degradation & Token health */} {!isE2EMode && } {!isE2EMode && } - - {/* Logout button */} + + + expand_more + +
+ + {isExpanded && ( +
+ {section.children.map((child: any) => { + if (child.type === "group") { + if (child.items.length === 0) return null; + return ( +
+ {/* Visual sub-group separator */} +
+
+ + {child.title} + +
+ {child.items.map(renderNavLink)} +
+ ); + } + return renderNavLink(child); + })} +
)} - {collapsed && showTitle && ( -
- )} - {section.items.map(renderNavLink)}
); })} @@ -301,10 +556,10 @@ export default function Sidebar({
+ {/* Styled tooltip for collapsed (mini) sidebar */} + {collapsed && hoveredItem && ( +
+
+
+ {hoveredItem.label} +
+
+ )} + setShowShutdownModal(false)} diff --git a/src/shared/components/Toggle.tsx b/src/shared/components/Toggle.tsx index e8b0e79cbf..521f50f14a 100644 --- a/src/shared/components/Toggle.tsx +++ b/src/shared/components/Toggle.tsx @@ -8,7 +8,7 @@ interface ToggleProps { label?: string; description?: string; disabled?: boolean; - size?: "sm" | "md" | "lg"; + size?: "xs" | "sm" | "md" | "lg"; className?: string; title?: string; ariaLabel?: string; @@ -26,6 +26,11 @@ export default function Toggle({ ariaLabel, }: ToggleProps) { const sizes = { + xs: { + track: "w-6 h-3", + thumb: "size-[8px]", + translate: "translate-x-3.5", + }, sm: { track: "w-8 h-4", thumb: "size-3", diff --git a/src/shared/components/index.tsx b/src/shared/components/index.tsx index 1039804883..f0174c2f1e 100644 --- a/src/shared/components/index.tsx +++ b/src/shared/components/index.tsx @@ -33,6 +33,7 @@ export { default as NotificationToast } from "./NotificationToast"; export { default as FilterBar } from "./FilterBar"; export { default as ColumnToggle } from "./ColumnToggle"; export { default as DataTable } from "./DataTable"; +export { default as NoAuthProviderCard } from "./NoAuthProviderCard"; // Layouts export * from "./layouts"; diff --git a/src/shared/components/layouts/DashboardLayout.tsx b/src/shared/components/layouts/DashboardLayout.tsx index a4b92b7005..62ba2e244b 100644 --- a/src/shared/components/layouts/DashboardLayout.tsx +++ b/src/shared/components/layouts/DashboardLayout.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from "react"; import Sidebar from "../Sidebar"; import Header from "../Header"; -import Breadcrumbs from "../Breadcrumbs"; import NotificationToast from "../NotificationToast"; import MaintenanceBanner from "../MaintenanceBanner"; import { useIsElectron } from "@/shared/hooks/useElectron"; @@ -80,10 +79,7 @@ export default function DashboardLayout({ children }) { {!isE2EMode && }
-
- - {children} -
+
{children}
diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index ed9aac0516..c4312b95cf 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -34,6 +34,22 @@ export const FREE_PROVIDERS = { authHint: "Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate.", }, + opencode: { + id: "opencode", + alias: "oc", + name: "OpenCode Free", + icon: "terminal", + color: "#E87040", + textIcon: "OC", + website: "https://opencode.ai", + noAuth: true, + authHint: "No API key required — uses OpenCode's public free endpoint.", + freeNote: + "No API key required — public OpenCode endpoint with Kimi, GLM, Qwen, MiMo, MiniMax models.", + notice: { + text: "OpenCode Free uses the public OpenCode endpoint (https://opencode.ai/zen/v1). No signup or API key needed. Rate limits apply.", + }, + }, }; export const FREE_APIKEY_PROVIDER_IDS = new Set(["qoder"]); diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 68622a0aa3..9058299a82 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -1,40 +1,91 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ + // Home "home", - "endpoints", + // OmniProxy — flat "api-manager", + "endpoints", "providers", "combos", - "batch", - "costs", - "analytics", - "cache", + "limits", + // OmniProxy > Compression Context "context-caveman", "context-rtk", "context-combos", - "limits", + // OmniProxy > Tools "cli-tools", "agents", "cloud-agents", + // OmniProxy > Integrations + "api-endpoints", + "webhooks", + // OmniProxy > Proxy + "proxy", + "mitm-proxy", + "1proxy", + // Analytics + "analytics", + "analytics-combo-health", + "analytics-utilization", + "costs", + "cache", + "analytics-compression", + "analytics-search", + "analytics-evals", + // Monitoring — flat + "logs", + "logs-proxy", + "logs-console", + "logs-activity", + "health", + // Monitoring > Costs Parameters + "costs-pricing", + "costs-budget", + // Monitoring > Audit + "audit", + "audit-mcp", + "audit-a2a", + // Dev Tools + "translator", + "playground", + "search-tools", + // Agentic Features "memory", "skills", "agent-skills", - "translator", - "playground", + "mcp", + "a2a", + // Other Features — flat "media", - "search-tools", - "logs", - "audit", - "webhooks", - "health", - "proxy", + // Other Features > Batch + "batch", + "batch-files", + // Configuration "settings", + "settings-general", + "settings-appearance", + "settings-ai", + "settings-routing", + "settings-resilience", + "settings-advanced", + "settings-security", + // Help "docs", "issues", "changelog", ] as const; export type HideableSidebarItemId = (typeof HIDEABLE_SIDEBAR_ITEM_IDS)[number]; -export type SidebarSectionId = "primary" | "context" | "cli" | "debug" | "system" | "help"; + +export type SidebarSectionId = + | "home" + | "omni-proxy" + | "analytics" + | "monitoring" + | "devtools" + | "agentic-features" + | "other-features" + | "configuration" + | "help"; export interface SidebarItemDefinition { id: HideableSidebarItemId; @@ -45,60 +96,193 @@ export interface SidebarItemDefinition { external?: boolean; } +export interface SidebarItemGroup { + type: "group"; + id: string; + titleKey: string; + titleFallback: string; + items: readonly SidebarItemDefinition[]; +} + +export type SidebarSectionChild = SidebarItemDefinition | SidebarItemGroup; + export interface SidebarSectionDefinition { id: SidebarSectionId; titleKey: string; titleFallback: string; - items: readonly SidebarItemDefinition[]; - showTitleInSidebar?: boolean; + children: readonly SidebarSectionChild[]; + showTitle?: boolean; visibility?: "always" | "debug"; + defaultPinned?: boolean; } -const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ - { id: "home", href: "/dashboard", i18nKey: "home", icon: "home", exact: true }, +export function getSectionItems( + section: SidebarSectionDefinition | { children: readonly SidebarSectionChild[] } +): readonly SidebarItemDefinition[] { + return section.children.flatMap((child) => + "type" in child && child.type === "group" ? child.items : [child as SidebarItemDefinition] + ); +} + +// ─── Item arrays ──────────────────────────────────────────────────────────── + +const HOME_ITEMS: readonly SidebarItemDefinition[] = [ + { id: "home", href: "/home", i18nKey: "home", icon: "home", exact: true }, +]; + +const OMNI_PROXY_ITEMS: readonly SidebarItemDefinition[] = [ { id: "endpoints", href: "/dashboard/endpoint", i18nKey: "endpoints", icon: "api" }, { id: "api-manager", href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" }, { id: "providers", href: "/dashboard/providers", i18nKey: "providers", icon: "dns" }, { id: "combos", href: "/dashboard/combos", i18nKey: "combos", icon: "layers" }, - { id: "batch", href: "/dashboard/batch", i18nKey: "batch", icon: "view_list" }, + { id: "limits", href: "/dashboard/limits", i18nKey: "quotaTracker", icon: "tune" }, +]; + +const COMPRESSION_CONTEXT_GROUP: SidebarItemGroup = { + type: "group", + id: "compression-context", + titleKey: "compressionContextGroup", + titleFallback: "Compression Context", + items: [ + { + id: "context-caveman", + href: "/dashboard/context/caveman", + i18nKey: "contextCaveman", + icon: "compress", + }, + { + id: "context-rtk", + href: "/dashboard/context/rtk", + i18nKey: "contextRtk", + icon: "filter_alt", + }, + { + id: "context-combos", + href: "/dashboard/context/combos", + i18nKey: "contextCombos", + icon: "hub", + }, + ], +}; + +const TOOLS_GROUP: SidebarItemGroup = { + type: "group", + id: "tools", + titleKey: "toolsGroup", + titleFallback: "Tools", + items: [ + { id: "cli-tools", href: "/dashboard/cli-tools", i18nKey: "cliTools", icon: "terminal" }, + { id: "agents", href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" }, + { id: "cloud-agents", href: "/dashboard/cloud-agents", i18nKey: "cloudAgents", icon: "cloud" }, + ], +}; + +const INTEGRATIONS_GROUP: SidebarItemGroup = { + type: "group", + id: "integrations", + titleKey: "integrationsGroup", + titleFallback: "Integrations", + items: [ + { id: "api-endpoints", href: "/dashboard/api-endpoints", i18nKey: "apiEndpoints", icon: "api" }, + { id: "webhooks", href: "/dashboard/webhooks", i18nKey: "webhooks", icon: "webhook" }, + ], +}; + +const PROXY_GROUP: SidebarItemGroup = { + type: "group", + id: "proxy", + titleKey: "proxyGroup", + titleFallback: "Proxy", + items: [ + { id: "proxy", href: "/dashboard/system/proxy", i18nKey: "proxy", icon: "dns" }, + { id: "mitm-proxy", href: "/dashboard/system/mitm-proxy", i18nKey: "mitmProxy", icon: "lan" }, + { id: "1proxy", href: "/dashboard/system/1proxy", i18nKey: "oneProxy", icon: "public" }, + ], +}; + +const ANALYTICS_ITEMS: readonly SidebarItemDefinition[] = [ + { id: "analytics", href: "/dashboard/analytics", i18nKey: "usage", icon: "analytics" }, + { + id: "analytics-combo-health", + href: "/dashboard/analytics/combo-health", + i18nKey: "analyticsComboHealth", + icon: "monitor_heart", + }, + { + id: "analytics-utilization", + href: "/dashboard/analytics/utilization", + i18nKey: "analyticsUtilization", + icon: "bar_chart", + }, { id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" }, - { id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" }, { id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" }, - { id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" }, - { id: "media", href: "/dashboard/cache/media", i18nKey: "media", icon: "perm_media" }, -]; - -const CLI_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ - { id: "cli-tools", href: "/dashboard/cli-tools", i18nKey: "cliToolsShort", icon: "terminal" }, - { id: "agents", href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" }, - { id: "cloud-agents", href: "/dashboard/cloud-agents", i18nKey: "cloudAgents", icon: "cloud" }, - { id: "memory", href: "/dashboard/memory", i18nKey: "memory", icon: "psychology" }, - { id: "skills", href: "/dashboard/skills", i18nKey: "omniSkills", icon: "auto_fix_high" }, - { id: "agent-skills", href: "/dashboard/agent-skills", i18nKey: "agentSkills", icon: "share" }, -]; - -const CONTEXT_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { - id: "context-caveman", - href: "/dashboard/context/caveman", - i18nKey: "contextCaveman", - icon: "compress", + id: "analytics-compression", + href: "/dashboard/analytics/compression", + i18nKey: "analyticsCompression", + icon: "data_compression", }, { - id: "context-rtk", - href: "/dashboard/context/rtk", - i18nKey: "contextRtk", - icon: "filter_alt", + id: "analytics-search", + href: "/dashboard/analytics/search", + i18nKey: "analyticsSearch", + icon: "manage_search", }, { - id: "context-combos", - href: "/dashboard/context/combos", - i18nKey: "contextCombos", - icon: "hub", + id: "analytics-evals", + href: "/dashboard/analytics/evals", + i18nKey: "analyticsEvals", + icon: "labs", }, ]; -const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ +const MONITORING_ITEMS: readonly SidebarItemDefinition[] = [ + { id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" }, + { id: "logs-proxy", href: "/dashboard/logs/proxy", i18nKey: "logsProxy", icon: "lan" }, + { id: "logs-console", href: "/dashboard/logs/console", i18nKey: "consoleLogs", icon: "terminal" }, + { + id: "logs-activity", + href: "/dashboard/logs/activity", + i18nKey: "logsActivity", + icon: "history", + }, + { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, +]; + +const COSTS_PARAMS_GROUP: SidebarItemGroup = { + type: "group", + id: "costs-parameters", + titleKey: "costsParametersGroup", + titleFallback: "Costs Parameters", + items: [ + { + id: "costs-pricing", + href: "/dashboard/costs/pricing", + i18nKey: "costsPricing", + icon: "price_change", + }, + { + id: "costs-budget", + href: "/dashboard/costs/budget", + i18nKey: "costsBudget", + icon: "savings", + }, + ], +}; + +const AUDIT_GROUP: SidebarItemGroup = { + type: "group", + id: "audit", + titleKey: "auditGroup", + titleFallback: "Audit", + items: [ + { id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "policy" }, + { id: "audit-mcp", href: "/dashboard/audit/mcp", i18nKey: "auditMcp", icon: "security" }, + { id: "audit-a2a", href: "/dashboard/audit/a2a", i18nKey: "auditA2a", icon: "device_hub" }, + ], +}; + +const DEVTOOLS_ITEMS: readonly SidebarItemDefinition[] = [ { id: "translator", href: "/dashboard/translator", i18nKey: "translator", icon: "translate" }, { id: "playground", href: "/dashboard/playground", i18nKey: "playground", icon: "science" }, { @@ -109,16 +293,84 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ }, ]; -const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ - { id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" }, - { id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "policy" }, - { id: "webhooks", href: "/dashboard/webhooks", i18nKey: "webhooks", icon: "webhook" }, - { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, - { id: "proxy", href: "/dashboard/system/proxy", i18nKey: "proxy", icon: "dns" }, - { id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }, +const MCP_GROUP: SidebarItemGroup = { + type: "group", + id: "mcp", + titleKey: "mcp", + titleFallback: "MCP Server", + items: [{ id: "mcp", href: "/dashboard/mcp", i18nKey: "mcp", icon: "hub" }], +}; + +const AGENTIC_FEATURES_ITEMS: readonly SidebarSectionChild[] = [ + { id: "memory", href: "/dashboard/memory", i18nKey: "memory", icon: "psychology" }, + { id: "skills", href: "/dashboard/skills", i18nKey: "omniSkills", icon: "auto_fix_high" }, + { id: "agent-skills", href: "/dashboard/agent-skills", i18nKey: "agentSkills", icon: "share" }, + MCP_GROUP, + { id: "a2a", href: "/dashboard/a2a", i18nKey: "a2a", icon: "device_hub" }, ]; -const HELP_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ +const OTHER_FEATURES_ITEMS: readonly SidebarItemDefinition[] = [ + { id: "media", href: "/dashboard/cache/media", i18nKey: "media", icon: "perm_media" }, +]; + +const BATCH_GROUP: SidebarItemGroup = { + type: "group", + id: "batch", + titleKey: "batchGroup", + titleFallback: "Batch", + items: [ + { id: "batch", href: "/dashboard/batch", i18nKey: "batch", icon: "view_list" }, + { id: "batch-files", href: "/dashboard/batch/files", i18nKey: "batchFiles", icon: "folder" }, + ], +}; + +const CONFIGURATION_ITEMS: readonly SidebarItemDefinition[] = [ + { id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }, + { + id: "settings-general", + href: "/dashboard/settings/general", + i18nKey: "settingsGeneral", + icon: "tune", + }, + { + id: "settings-appearance", + href: "/dashboard/settings/appearance", + i18nKey: "settingsAppearance", + icon: "palette", + }, + { + id: "settings-ai", + href: "/dashboard/settings/ai", + i18nKey: "settingsAi", + icon: "auto_awesome", + }, + { + id: "settings-routing", + href: "/dashboard/settings/routing", + i18nKey: "globalRouting", + icon: "route", + }, + { + id: "settings-resilience", + href: "/dashboard/settings/resilience", + i18nKey: "settingsResilience", + icon: "health_and_safety", + }, + { + id: "settings-advanced", + href: "/dashboard/settings/advanced", + i18nKey: "settingsAdvanced", + icon: "engineering", + }, + { + id: "settings-security", + href: "/dashboard/settings/security", + i18nKey: "settingsSecurity", + icon: "shield", + }, +]; + +const HELP_ITEMS: readonly SidebarItemDefinition[] = [ { id: "docs", href: "/docs", i18nKey: "docs", icon: "menu_book", external: true }, { id: "issues", @@ -130,47 +382,76 @@ const HELP_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "changelog", href: "/dashboard/changelog", i18nKey: "changelog", icon: "campaign" }, ]; +// ─── Sections ──────────────────────────────────────────────────────────────── + export const SIDEBAR_SECTIONS: readonly SidebarSectionDefinition[] = [ { - id: "primary", - titleKey: "primarySection", - titleFallback: "Main", - items: PRIMARY_SIDEBAR_ITEMS, - showTitleInSidebar: false, + id: "home", + titleKey: "home", + titleFallback: "Home", + children: HOME_ITEMS, + showTitle: false, }, { - id: "context", - titleKey: "contextSection", - titleFallback: "Context & Cache", - items: CONTEXT_SIDEBAR_ITEMS, + id: "omni-proxy", + titleKey: "omniProxySection", + titleFallback: "OmniProxy", + children: [ + ...OMNI_PROXY_ITEMS, + COMPRESSION_CONTEXT_GROUP, + TOOLS_GROUP, + INTEGRATIONS_GROUP, + PROXY_GROUP, + ], + defaultPinned: true, }, { - id: "cli", - titleKey: "cliSection", - titleFallback: "CLI", - items: CLI_SIDEBAR_ITEMS, + id: "analytics", + titleKey: "analyticsSection", + titleFallback: "Analytics", + children: ANALYTICS_ITEMS, }, { - id: "debug", - titleKey: "debugSection", - titleFallback: "Debug", - items: DEBUG_SIDEBAR_ITEMS, + id: "monitoring", + titleKey: "monitoringSection", + titleFallback: "Monitoring", + children: [...MONITORING_ITEMS, COSTS_PARAMS_GROUP, AUDIT_GROUP], + }, + { + id: "devtools", + titleKey: "devtoolsSection", + titleFallback: "Dev Tools", + children: DEVTOOLS_ITEMS, visibility: "debug", }, { - id: "system", - titleKey: "systemSection", - titleFallback: "System", - items: SYSTEM_SIDEBAR_ITEMS, + id: "agentic-features", + titleKey: "agenticFeaturesSection", + titleFallback: "Agentic Features", + children: AGENTIC_FEATURES_ITEMS, + }, + { + id: "other-features", + titleKey: "otherFeaturesSection", + titleFallback: "Other Features", + children: [...OTHER_FEATURES_ITEMS, BATCH_GROUP], + }, + { + id: "configuration", + titleKey: "configurationSection", + titleFallback: "Configuration", + children: CONFIGURATION_ITEMS, }, { id: "help", titleKey: "helpSection", titleFallback: "Help", - items: HELP_SIDEBAR_ITEMS, + children: HELP_ITEMS, }, ] as const; +// ─── Settings helpers ───────────────────────────────────────────────────────── + export const HIDDEN_SIDEBAR_ITEMS_SETTING_KEY = "hiddenSidebarItems"; export const SIDEBAR_SETTINGS_UPDATED_EVENT = "omniroute:settings-updated"; diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index ec4dc2774a..b9d34d1b8e 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -334,7 +334,8 @@ export async function executeChatWithBreaker({ // apiKey blob mid-request — forward it so the DB credential // doesn't go stale after Set-Cookie rotation. apiKey: newCreds.apiKey, - testStatus: "active", + testStatus: newCreds.testStatus ?? "active", + isActive: newCreds.isActive, }); }, onRequestSuccess: async () => { diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 75be7b3d27..2a42030229 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -41,7 +41,7 @@ import { PROVIDER_ERROR_TYPES, } from "@omniroute/open-sse/services/errorClassifier.ts"; import { getCodexModelScope } from "@omniroute/open-sse/executors/codex.ts"; -import { getProviderAlias, resolveProviderId } from "@/shared/constants/providers"; +import { getProviderAlias, resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers"; import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; import * as log from "../utils/logger"; import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck"; @@ -791,6 +791,29 @@ export async function getProviderCredentials( try { await currentMutex; + // noAuth free providers (e.g. opencode) need no DB connection — return synthetic credentials + // so the executor receives a valid credentials object without auth headers being added. + const resolvedId = resolveProviderId(provider); + if (FREE_PROVIDERS[resolvedId]?.noAuth) { + return { + apiKey: null, + accessToken: null, + refreshToken: null, + expiresAt: null, + projectId: null, + copilotToken: null, + providerSpecificData: {}, + connectionId: "noauth", + testStatus: "active", + lastError: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + rateLimitedUntil: null, + maxConcurrent: null, + }; + } + const allowSuppressedConnections = options.allowSuppressedConnections === true; const bypassQuotaPolicy = options.bypassQuotaPolicy === true; const forcedConnectionId = diff --git a/src/sse/services/tokenRefresh.ts b/src/sse/services/tokenRefresh.ts index 244eb70558..ad9b77e484 100755 --- a/src/sse/services/tokenRefresh.ts +++ b/src/sse/services/tokenRefresh.ts @@ -141,6 +141,9 @@ export async function updateProviderCredentials(connectionId: string, newCredent if (newCredentials.testStatus) { updates.testStatus = newCredentials.testStatus; } + if (newCredentials.isActive !== undefined) { + updates.isActive = newCredentials.isActive; + } const result = await updateProviderConnection(connectionId, updates); log.info("TOKEN_REFRESH", "Credentials updated in localDb", { diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 092320f075..41e093c8a7 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -1219,6 +1219,24 @@ test("CodexExecutor.refreshCredentials refreshes OAuth tokens and returns null w } }); +test("CodexExecutor.refreshCredentials propagates unrecoverable error object instead of returning null", async () => { + const executor = new CodexExecutor(); + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ error: "invalid_grant", error_description: "Refresh token expired" }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + + try { + const result = await executor.refreshCredentials({ refreshToken: "dead-token" }, null); + assert.ok(result !== null, "should return error object, not null"); + assert.equal((result as any).error, "unrecoverable_refresh_error"); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("CodexExecutor maps usage_limit_reached websocket failures without explicit status to 429", () => { const raw = JSON.stringify({ type: "response.failed", diff --git a/tests/unit/token-refresh-service.test.ts b/tests/unit/token-refresh-service.test.ts index 8dfb5e93fe..339e912056 100644 --- a/tests/unit/token-refresh-service.test.ts +++ b/tests/unit/token-refresh-service.test.ts @@ -1121,3 +1121,82 @@ test("getAccessToken per-connection mutex: mutex cleared after success, next cal } ); }); + +// ─── Unrecoverable error bail-out tests ────────────────────────────────────── + +test("refreshWithRetry bails immediately on unrecoverable error without retrying", async () => { + const provider = `bail-unrecoverable-${Date.now()}`; + const log = createLog(); + let callCount = 0; + + const result = await refreshWithRetry( + async () => { + callCount++; + return { error: "unrecoverable_refresh_error", code: "http_400" }; + }, + 3, + log, + provider + ); + + assert.equal(callCount, 1, "should only call refreshFn once (no retries)"); + assert.deepEqual(result, { error: "unrecoverable_refresh_error", code: "http_400" }); + const warnMessages = log.entries.filter((e) => e.level === "warn").map((e) => e.message); + assert.ok( + warnMessages.some((m) => String(m).includes("Unrecoverable")), + "should log an unrecoverable warning" + ); +}); + +test("refreshWithRetry bails immediately on invalid_grant error without retrying", async () => { + const provider = `bail-invalid-grant-${Date.now()}`; + const log = createLog(); + let callCount = 0; + + const result = await refreshWithRetry( + async () => { + callCount++; + return { error: "invalid_grant", code: "http_400" }; + }, + 3, + log, + provider + ); + + assert.equal(callCount, 1, "should only call refreshFn once (no retries)"); + assert.deepEqual(result, { error: "invalid_grant", code: "http_400" }); +}); + +test("refreshClaudeOAuthToken returns error object for invalid_grant (expired refresh token)", async () => { + const log = createLog(); + + await withMockedFetch( + async () => + new Response(JSON.stringify({ error: "invalid_grant", error_description: "Token expired" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + async () => { + const result = await refreshClaudeOAuthToken("expired-token", log); + assert.ok(result && typeof result === "object", "should return error object, not null"); + assert.equal((result as any).error, "invalid_grant"); + assert.ok(isUnrecoverableRefreshError(result), "should be detected as unrecoverable"); + } + ); +}); + +test("refreshClaudeOAuthToken returns null for transient server errors (not unrecoverable)", async () => { + const log = createLog(); + + await withMockedFetch( + async () => + new Response(JSON.stringify({ error: "server_error" }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + async () => { + const result = await refreshClaudeOAuthToken("some-token", log); + assert.equal(result, null, "transient server errors should return null (retryable)"); + } + ); +});