diff --git a/.gitignore b/.gitignore index 3ec3d24321..8fcc4514c3 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,7 @@ docs/* !docs/CONTRIBUTING.md !docs/USER_GUIDE.md !docs/API_REFERENCE.md +!docs/TERMUX_GUIDE.md !docs/TROUBLESHOOTING.md !docs/EXECUTION_CONTEXT_PROVIDER_SYNC.md !docs/TASK_NEBIUS_BACKEND_ENABLEMENT.md diff --git a/docs/TERMUX_GUIDE.md b/docs/TERMUX_GUIDE.md new file mode 100644 index 0000000000..28012906e9 --- /dev/null +++ b/docs/TERMUX_GUIDE.md @@ -0,0 +1,160 @@ +# Termux Headless Setup + +OmniRoute can run as a headless server on Android through Termux. The Electron desktop app is not supported in Termux, but the web dashboard and OpenAI-compatible API work from the local browser or from other devices on the same network. + +## Prerequisites + +Install Termux from F-Droid or GitHub releases, then update packages and install the build tools required by native dependencies such as `better-sqlite3`. + +```bash +pkg update +pkg upgrade +pkg install nodejs-lts python build-essential git +``` + +If native package compilation fails, rerun the `pkg install` command above and then retry the OmniRoute install. + +## Install + +Run the latest published package directly: + +```bash +npx -y omniroute@latest +``` + +You can also install it globally: + +```bash +npm install -g omniroute +omniroute +``` + +## Run + +Start OmniRoute in headless server mode: + +```bash +omniroute +``` + +or: + +```bash +npx omniroute +``` + +The dashboard listens on: + +```text +http://localhost:20128 +``` + +Open that URL in the Android browser. If you run clients inside Termux, use the same host and port as the OpenAI-compatible base URL. + +## Background Execution + +For a simple background process: + +```bash +nohup omniroute > omniroute.log 2>&1 & +``` + +To stop it: + +```bash +pkill -f omniroute +``` + +For automatic startup after device boot, install the Termux:Boot add-on and create a boot script: + +```bash +mkdir -p ~/.termux/boot +cat > ~/.termux/boot/omniroute.sh <<'EOF' +#!/data/data/com.termux/files/usr/bin/sh +cd "$HOME" +nohup omniroute > "$HOME/omniroute.log" 2>&1 & +EOF +chmod +x ~/.termux/boot/omniroute.sh +``` + +Android battery optimization can stop long-running background processes. Disable battery optimization for Termux if the server is expected to stay online. + +## Access From Other Devices + +Find the phone IP address on the WiFi network: + +```bash +ip addr show wlan0 +``` + +Then open the dashboard from another device: + +```text +http://PHONE_IP:20128 +``` + +For example: + +```text +http://192.168.1.50:20128 +``` + +Keep the phone and client on the same trusted network. If you expose OmniRoute outside the phone, enable API keys and dashboard authentication. + +## Data Directory + +By default OmniRoute stores data under the Termux home directory, following the same server-side data path behavior used on Linux. To place the database somewhere explicit: + +```bash +export DATA_DIR="$HOME/.omniroute" +omniroute +``` + +## Limitations + +- Electron does not run in Termux. +- There is no system tray or desktop integration. +- This setup is server-only: use the browser dashboard. +- Native dependencies may need local compilation. +- Low-memory Android devices may need fewer concurrent requests. +- MITM/system certificate features may require Android-level trust-store work outside Termux. + +## Troubleshooting + +### better-sqlite3 Build Errors + +Install the Termux build toolchain: + +```bash +pkg install nodejs-lts python build-essential +``` + +Then rerun: + +```bash +npx -y omniroute@latest +``` + +### Port Already In Use + +Check what is listening on the default port: + +```bash +ss -ltnp | grep 20128 +``` + +Stop the old process: + +```bash +pkill -f omniroute +``` + +### Dashboard Not Reachable From Another Device + +Verify both devices are on the same WiFi network, then test from Termux: + +```bash +curl http://localhost:20128 +``` + +If local access works but LAN access does not, check Android hotspot/WiFi isolation and any firewall or VPN profile on the phone. diff --git a/electron/package.json b/electron/package.json index 2397b91cf9..5d5e60a832 100644 --- a/electron/package.json +++ b/electron/package.json @@ -106,7 +106,8 @@ { "target": "AppImage", "arch": [ - "x64" + "x64", + "arm64" ] }, { diff --git a/eslint.config.mjs b/eslint.config.mjs index a90263b07c..9266b9e696 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,17 @@ const eslintConfig = [ "no-eval": "error", "no-implied-eval": "error", "no-new-func": "error", + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "prop-types", + message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.", + }, + ], + }, + ], }, }, // Relaxed rules for open-sse and tests (incremental adoption) diff --git a/package-lock.json b/package-lock.json index 1f0182ac6e..d4051d1973 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,6 @@ "jsdom": "^29.0.1", "lint-staged": "^16.2.7", "prettier": "^3.8.1", - "prop-types": "^15.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", "typescript-eslint": "^8.56.0", diff --git a/package.json b/package.json index 68bd9bcd1b..dfccd4c578 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,6 @@ "jsdom": "^29.0.1", "lint-staged": "^16.2.7", "prettier": "^3.8.1", - "prop-types": "^15.8.1", "tailwindcss": "^4", "typescript": "^6.0.2", "typescript-eslint": "^8.56.0", diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 6c3a7b18de..2011948e6e 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -3,7 +3,6 @@ import { useTranslations } from "next-intl"; import { useState, useEffect, useMemo, useCallback } from "react"; -import PropTypes from "prop-types"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -30,6 +29,39 @@ type VersionInfo = { news?: NewsAnnouncement | null; }; +type HomePageClientProps = { + machineId?: string; +}; + +type ProviderSummaryItem = { + id: string; + provider: { + id: string; + name: string; + color?: string; + textIcon?: string; + alias?: string; + }; + total: number; + connected: number; + errors: number; + modelCount: number; + authType: "free" | "oauth" | "apikey" | string; +}; + +type ProviderMetricSummary = { + totalRequests?: number; + totalSuccesses?: number; + successRate?: number; + avgLatencyMs?: number; +}; + +type ProviderModelSummary = { + fullModel: string; + alias?: string; + model?: string; +}; + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); function mergeUpdateStep(steps: UpdateStep[], nextStep: UpdateStep) { @@ -43,7 +75,7 @@ function mergeUpdateStep(steps: UpdateStep[], nextStep: UpdateStep) { return next; } -export default function HomePageClient({ machineId }) { +export default function HomePageClient({ machineId }: HomePageClientProps) { const t = useTranslations("home"); const tc = useTranslations("common"); const ts = useTranslations("sidebar"); @@ -774,11 +806,15 @@ export default function HomePageClient({ machineId }) { ); } -HomePageClient.propTypes = { - machineId: PropTypes.string, -}; - -function ProviderOverviewCard({ item, metrics, onClick }) { +function ProviderOverviewCard({ + item, + metrics, + onClick, +}: { + item: ProviderSummaryItem; + metrics?: ProviderMetricSummary; + onClick: () => void; +}) { const t = useTranslations("home"); const tc = useTranslations("common"); @@ -839,32 +875,15 @@ function ProviderOverviewCard({ item, metrics, onClick }) { ); } -ProviderOverviewCard.propTypes = { - item: PropTypes.shape({ - id: PropTypes.string.isRequired, - provider: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - textIcon: PropTypes.string, - alias: PropTypes.string, - }).isRequired, - total: PropTypes.number.isRequired, - connected: PropTypes.number.isRequired, - errors: PropTypes.number.isRequired, - modelCount: PropTypes.number.isRequired, - authType: PropTypes.string.isRequired, - }).isRequired, - metrics: PropTypes.shape({ - totalRequests: PropTypes.number, - totalSuccesses: PropTypes.number, - successRate: PropTypes.number, - avgLatencyMs: PropTypes.number, - }), - onClick: PropTypes.func.isRequired, -}; - -function ProviderModelsModal({ provider, models, onClose }) { +function ProviderModelsModal({ + provider, + models, + onClose, +}: { + provider: ProviderSummaryItem; + models: ProviderModelSummary[]; + onClose: () => void; +}) { const [copiedModel, setCopiedModel] = useState(null); const notify = useNotificationStore(); const router = useRouter(); @@ -966,9 +985,3 @@ function ProviderModelsModal({ provider, models, onClose }) { ); } - -ProviderModelsModal.propTypes = { - provider: PropTypes.object.isRequired, - models: PropTypes.array.isRequired, - onClose: PropTypes.func.isRequired, -}; diff --git a/src/app/(dashboard)/dashboard/agents/page.tsx b/src/app/(dashboard)/dashboard/agents/page.tsx index 74dc2ff90d..b030b297c7 100644 --- a/src/app/(dashboard)/dashboard/agents/page.tsx +++ b/src/app/(dashboard)/dashboard/agents/page.tsx @@ -5,13 +5,6 @@ import Link from "next/link"; import { Card, Button, Input } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { useTranslations } from "next-intl"; -import { AI_PROVIDERS } from "@/shared/constants/providers"; -import { CLI_TOOLS } from "@/shared/constants/cliTools"; -import { - CLI_COMPAT_PROVIDER_IDS, - CLI_COMPAT_TOGGLE_IDS, - normalizeCliCompatProviderId, -} from "@/shared/constants/cliCompatProviders"; interface AgentInfo { id: string; @@ -66,7 +59,6 @@ export default function AgentsPage() { const [refreshing, setRefreshing] = useState(false); const [showAddForm, setShowAddForm] = useState(false); const [addLoading, setAddLoading] = useState(false); - const [settings, setSettings] = useState>({}); const [newAgent, setNewAgent] = useState({ name: "", binary: "", @@ -74,7 +66,6 @@ export default function AgentsPage() { spawnArgs: "", }); const t = useTranslations("agents"); - const ts = useTranslations("settings"); const fetchAgents = useCallback(async () => { try { @@ -91,34 +82,8 @@ export default function AgentsPage() { useEffect(() => { fetchAgents(); - // Also fetch settings for CLI fingerprint - fetch("/api/settings") - .then((r) => r.json()) - .then((d) => setSettings(d)) - .catch(() => {}); }, [fetchAgents]); - const updateSetting = async (key: string, value: any) => { - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ [key]: value }), - }); - if (res.ok) setSettings((prev) => ({ ...prev, [key]: value })); - } catch (err) { - console.error("Failed to update setting:", err); - } - }; - - const normalizedCliCompatProviders = Array.from( - new Set( - (settings.cliCompatProviders || []) - .map((providerId: string) => normalizeCliCompatProviderId(providerId)) - .filter((providerId: string) => CLI_COMPAT_PROVIDER_IDS.includes(providerId)) - ) - ); - const handleRefresh = async () => { setRefreshing(true); try { @@ -215,20 +180,88 @@ export default function AgentsPage() { {t("cliToolsRedirectCta")} -
- +
+ {t("flowOmniRoute")} - + + arrow_forward + + {t("flowSpawn")} - + + arrow_forward + + {t("flowLocalBinary")} - + + arrow_forward + + {t("flowExecute")}
+
+
+
+
+ + devices + +
+

{t("flowDiagramClient")}

+

{t("flowDiagramClientDesc")}

+
+
+ + arrow_forward + +
+
+
+ hub +
+

{t("flowDiagramOmniRoute")}

+

+ {t("flowDiagramOmniRouteDesc")} +

+
+
+ + arrow_forward + +
+
+
+ + launch + +
+

+ {t("flowDiagramSpawn")} +

+

{t("flowDiagramSpawnDesc")}

+
+
+ + arrow_forward + +
+
+
+ + terminal + +
+

+ {t("flowDiagramCli")} +

+

{t("flowDiagramCliDesc")}

+
+
+
{t("cliToolsRedirectTitle")}{" "} {t("cliToolsRedirectDesc")}{" "} @@ -240,6 +273,67 @@ export default function AgentsPage() {
+ +
+
+
+ +
+

{t("comparisonTitle")}

+
+ +
+
+
+ + arrow_forward + +

+ {t("comparisonCliToolsLabel")} +

+
+

+ {t("comparisonCliToolsTitle")} +

+

{t("comparisonCliToolsDesc")}

+
+ IDE + arrow_forward + OmniRoute + arrow_forward + Provider API +
+
+ +
+
+ + arrow_back + +

+ {t("comparisonAgentsLabel")} +

+
+

+ {t("comparisonAgentsTitle")} +

+

{t("comparisonAgentsDesc")}

+
+ Client + arrow_forward + OmniRoute + arrow_forward + CLI Binary +
+
+
+ +

{t("comparisonSummary")}

+
+
+ {/* Summary Cards */} {summary && (
@@ -305,69 +399,14 @@ export default function AgentsPage() {

{t("setupGuideCommandMissingDesc")}

- - - {/* CLI Fingerprint Matching */} - -
-
- -
-

{ts("cliFingerprint")}

-
-
-

{ts("cliFingerprintDesc")}

-
- {CLI_COMPAT_TOGGLE_IDS.map((toggleId) => { - const providerId = normalizeCliCompatProviderId(toggleId); - const providerMeta = Object.values(AI_PROVIDERS).find((p: any) => p.id === providerId) as any; - const toolMeta = CLI_TOOLS[toggleId as keyof typeof CLI_TOOLS] as any; - const isEnabled = normalizedCliCompatProviders.includes(providerId); - const displayName = toolMeta?.name || providerMeta?.name || toggleId; - const icon = providerMeta?.icon || "terminal"; - const color = providerMeta?.color || "#888"; - return ( - - ); - })} -
- {normalizedCliCompatProviders.length > 0 && ( -

- verified - {ts("cliFingerprintEnabled", { - count: normalizedCliCompatProviders.length, - })} -

- )} +
+ fingerprint +

+ {t("fingerprintSettingsHint")}{" "} + + {t("openSettings")} + +

@@ -419,9 +458,14 @@ export default function AgentsPage() {
- - {agent.protocol} - +
+ + {agent.protocol} + + {agent.installed && ( +

{t("agentUseCaseHint")}

+ )} +
{agent.isCustom && ( + +
+ +
+ + +
+ + + + +
+ +
+
+
+ + {error && ( +
+ {error} +
+ )} + + + {loading ? ( +
{t("loading")}
+ ) : visibleEntries.length === 0 ? ( +
+ policy +

{t("noEvents")}

+
+ ) : ( +
+ + + + + + + + + + + + + + + {visibleEntries.map((entry) => { + const entrySeverity = getSeverity(entry); + return ( + + + + + + + + + + + ); + })} + +
{t("timestamp")}{t("eventType")}{t("severity")}{t("sourceIp")}{t("userOrKey")}{t("action")}{t("result")}{t("details")}
+ {formatLocalDate(entry.timestamp)} + + + {entry.action} + + + + {t(entrySeverity)} + + + {entry.ip_address || entry.ip || t("notAvailable")} + {entry.actor || t("system")} + {entry.target || entry.resourceType || t("notAvailable")} + + {entry.status || t("notAvailable")} + + +
+
+ )} +
+ +
+ + +
+ + {selectedEntry && ( +
+ +
+
+              {formatJson(selectedEntry)}
+            
+ + + )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/audit/page.tsx b/src/app/(dashboard)/dashboard/audit/page.tsx index 4cc14f489f..0755c9b957 100644 --- a/src/app/(dashboard)/dashboard/audit/page.tsx +++ b/src/app/(dashboard)/dashboard/audit/page.tsx @@ -1,5 +1,246 @@ -import { redirect } from "next/navigation"; +"use client"; -export default function ConfigAuditPage() { - redirect("/dashboard/logs?tab=audit-logs"); +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" ? : } +
+ ); } diff --git a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx index 496d38c67f..5ebcc0def1 100644 --- a/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx +++ b/src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; import { Card, EmptyState, SegmentedControl, CardSkeleton } from "@/shared/components"; import { @@ -14,6 +14,8 @@ import { XAxis, YAxis, CartesianGrid, + BarChart, + Bar, } from "recharts"; type CostRange = "7d" | "30d" | "90d" | "all"; @@ -24,6 +26,13 @@ interface UsageAnalyticsSummary { uniqueModels: number; uniqueAccounts: number; uniqueApiKeys: number; + totalTokens: number; + promptTokens: number; + completionTokens: number; + fallbackCount: number; + fallbackRatePct: number; + requestedModelCoveragePct: number; + streak: number; } interface UsageAnalyticsProviderRow { @@ -45,11 +54,34 @@ interface UsageAnalyticsTrendRow { cost: number; } +interface UsageAnalyticsApiKeyRow { + apiKey: string; + apiKeyId: string | null; + apiKeyName: string; + requests: number; + promptTokens: number; + completionTokens: number; + totalTokens: number; + cost: number; +} + +interface UsageAnalyticsAccountRow { + account: string; + totalTokens: number; + requests: number; + cost: number; +} + interface UsageAnalyticsPayload { summary: UsageAnalyticsSummary; byProvider: UsageAnalyticsProviderRow[]; byModel: UsageAnalyticsModelRow[]; + byApiKey: UsageAnalyticsApiKeyRow[]; + byAccount: UsageAnalyticsAccountRow[]; dailyTrend: UsageAnalyticsTrendRow[]; + weeklyPattern: Array<{ day: string; avgTokens: number; totalTokens: number }>; + activityMap: Record; + presetSummaries?: Record; } const RANGE_OPTIONS: Array<{ value: CostRange; labelKey: string }> = [ @@ -79,6 +111,104 @@ function createCurrencyFormatter(locale: string) { }); } +function csvCell(value: string | number): string { + const text = String(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function generateCSV(analytics: UsageAnalyticsPayload, locale: string): string { + const currencyFormatter = createCurrencyFormatter(locale); + const lines: string[] = []; + + lines.push("# OmniRoute Cost Report"); + lines.push(`# Generated: ${new Date().toISOString()}`); + lines.push(""); + lines.push("## Summary"); + lines.push("Metric,Value"); + lines.push(`Total Cost,${csvCell(currencyFormatter.format(analytics.summary.totalCost))}`); + lines.push(`Total Requests,${analytics.summary.totalRequests}`); + lines.push(`Unique Models,${analytics.summary.uniqueModels}`); + lines.push(`Unique Accounts,${analytics.summary.uniqueAccounts}`); + lines.push(`Total Tokens,${analytics.summary.totalTokens}`); + lines.push(""); + + lines.push("## Daily Cost Trend"); + lines.push("Date,Cost (USD)"); + for (const row of analytics.dailyTrend) { + lines.push(`${csvCell(row.date)},${row.cost.toFixed(6)}`); + } + lines.push(""); + + lines.push("## Cost by Provider"); + lines.push("Provider,Requests,Total Tokens,Cost (USD)"); + for (const row of analytics.byProvider) { + lines.push( + [row.provider, row.requests, row.totalTokens, row.cost.toFixed(6)].map(csvCell).join(",") + ); + } + lines.push(""); + + lines.push("## Cost by Model"); + lines.push("Model,Requests,Total Tokens,Cost (USD)"); + for (const row of analytics.byModel) { + lines.push( + [row.model, row.requests, row.totalTokens, row.cost.toFixed(6)].map(csvCell).join(",") + ); + } + lines.push(""); + + lines.push("## Cost by API Key"); + lines.push("API Key,Requests,Total Tokens,Cost (USD)"); + for (const row of analytics.byApiKey || []) { + lines.push( + [row.apiKeyName || row.apiKey, row.requests, row.totalTokens, row.cost.toFixed(6)] + .map(csvCell) + .join(",") + ); + } + lines.push(""); + + lines.push("## Cost by Account"); + lines.push("Account,Requests,Total Tokens,Cost (USD)"); + for (const row of analytics.byAccount || []) { + lines.push( + [row.account, row.requests, row.totalTokens, row.cost.toFixed(6)].map(csvCell).join(",") + ); + } + + return lines.join("\n"); +} + +function generateJSON(analytics: UsageAnalyticsPayload): string { + return JSON.stringify( + { + generatedAt: new Date().toISOString(), + summary: analytics.summary, + dailyTrend: analytics.dailyTrend, + weeklyPattern: analytics.weeklyPattern, + activityMap: analytics.activityMap, + byProvider: analytics.byProvider, + byModel: analytics.byModel, + byApiKey: analytics.byApiKey || [], + byAccount: analytics.byAccount || [], + }, + null, + 2 + ); +} + +function downloadFile(content: string, filename: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + export default function CostOverviewTab() { const t = useTranslations("costs"); const locale = useLocale(); @@ -94,63 +224,36 @@ export default function CostOverviewTab() { const [summaryLoading, setSummaryLoading] = useState(true); const [error, setError] = useState(null); - const fetchAnalytics = useCallback( - async (requestedRange: string) => { - const response = await fetch(`/api/usage/analytics?range=${requestedRange}`); - if (!response.ok) { - throw new Error(t("overviewLoadFailed")); - } - return (await response.json()) as UsageAnalyticsPayload; - }, - [t] - ); - useEffect(() => { let active = true; async function loadRange() { try { setLoading(true); - const payload = await fetchAnalytics(range); + setSummaryLoading(true); + const response = await fetch( + `/api/usage/analytics?range=${encodeURIComponent(range)}&presets=1d,7d,30d` + ); + if (!response.ok) { + throw new Error(t("overviewLoadFailed")); + } + const payload = (await response.json()) as UsageAnalyticsPayload; if (!active) return; setAnalytics(payload); + if (payload.presetSummaries) { + setPresetCosts({ + "1d": payload.presetSummaries["1d"]?.totalCost || 0, + "7d": payload.presetSummaries["7d"]?.totalCost || 0, + "30d": payload.presetSummaries["30d"]?.totalCost || 0, + }); + } setError(null); - } catch (loadError: any) { + } catch (loadError) { if (!active) return; - setError(loadError?.message || t("overviewLoadFailed")); + setError(loadError instanceof Error ? loadError.message : t("overviewLoadFailed")); } finally { if (active) { setLoading(false); - } - } - } - - void loadRange(); - - return () => { - active = false; - }; - }, [fetchAnalytics, range, t]); - - useEffect(() => { - let active = true; - - async function loadPresets() { - try { - setSummaryLoading(true); - const [day, week, month] = await Promise.all([ - fetchAnalytics("1d"), - fetchAnalytics("7d"), - fetchAnalytics("30d"), - ]); - if (!active) return; - setPresetCosts({ - "1d": day.summary?.totalCost || 0, - "7d": week.summary?.totalCost || 0, - "30d": month.summary?.totalCost || 0, - }); - } finally { - if (active) { setSummaryLoading(false); } } @@ -161,7 +264,7 @@ export default function CostOverviewTab() { return () => { active = false; }; - }, [fetchAnalytics]); + }, [range, t]); const selectedRangeLabel = t( RANGE_OPTIONS.find((option) => option.value === range)?.labelKey || "range30d" @@ -172,6 +275,13 @@ export default function CostOverviewTab() { uniqueModels: 0, uniqueAccounts: 0, uniqueApiKeys: 0, + totalTokens: 0, + promptTokens: 0, + completionTokens: 0, + fallbackCount: 0, + fallbackRatePct: 0, + requestedModelCoveragePct: 0, + streak: 0, }; const providersByCost = [...(analytics?.byProvider || [])] .filter((provider) => provider.cost > 0) @@ -179,8 +289,37 @@ export default function CostOverviewTab() { const modelsByCost = [...(analytics?.byModel || [])] .filter((model) => model.cost > 0) .sort((left, right) => right.cost - left.cost); + const apiKeysByCost = [...(analytics?.byApiKey || [])] + .filter((apiKey) => apiKey.cost > 0) + .sort((left, right) => right.cost - left.cost); + const accountsByCost = [...(analytics?.byAccount || [])] + .filter((account) => account.cost > 0) + .sort((left, right) => right.cost - left.cost); const avgCostPerRequest = summary.totalRequests > 0 ? summary.totalCost / summary.totalRequests : 0; + const dailyTrend = analytics?.dailyTrend || []; + const recentDays = dailyTrend.slice(-7); + const avgDailyCost = + recentDays.length > 0 + ? recentDays.reduce((sum, day) => sum + (day.cost || 0), 0) / recentDays.length + : 0; + const today = new Date(); + const daysRemainingInMonth = + new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - today.getDate(); + const projectedMonthEnd = + (presetCosts["30d"] || summary.totalCost) + avgDailyCost * daysRemainingInMonth; + const trendLength = dailyTrend.length; + const halfLength = Math.floor(trendLength / 2); + const firstHalf = dailyTrend.slice(0, halfLength); + const secondHalf = dailyTrend.slice(halfLength); + const firstHalfCost = firstHalf.reduce((sum, day) => sum + (day.cost || 0), 0); + const secondHalfCost = secondHalf.reduce((sum, day) => sum + (day.cost || 0), 0); + const costChangePct = + firstHalfCost > 0 + ? ((secondHalfCost - firstHalfCost) / firstHalfCost) * 100 + : secondHalfCost > 0 + ? 100 + : 0; if (loading && !analytics) { return ; @@ -202,14 +341,57 @@ export default function CostOverviewTab() {

{t("overviewTitle")}

{t("overviewDescription")}

- ({ - value: option.value, - label: t(option.labelKey), - }))} - value={range} - onChange={(value) => setRange(value as CostRange)} - /> +
+ {summary.streak > 0 && ( +
+ + local_fire_department + + {summary.streak} + {t("dayStreak")} +
+ )} + {analytics && summary.totalCost > 0 && ( +
+ + +
+ )} + ({ + value: option.value, + label: t(option.labelKey), + }))} + value={range} + onChange={(value) => setRange(value as CostRange)} + /> +
@@ -261,6 +443,184 @@ export default function CostOverviewTab() { + +

+ {t("tokenUsage")} +

+
+ + + + 0 + ? `${(summary.promptTokens / summary.completionTokens).toFixed(1)}:1` + : "-" + } + /> +
+
+ + {summary.totalRequests > 0 && ( + +

+ {t("routingEfficiency")} +

+
+
+

+ {t("fallbackCount")} +

+

+ {new Intl.NumberFormat(locale).format(summary.fallbackCount || 0)} +

+

+ {t("outOfRequests", { + total: new Intl.NumberFormat(locale).format(summary.totalRequests), + })} +

+
+
+

+ {t("fallbackRate")} +

+
+

10 + ? "text-red-400" + : (summary.fallbackRatePct || 0) > 5 + ? "text-amber-400" + : "text-emerald-400" + }`} + > + {(summary.fallbackRatePct || 0).toFixed(1)}% +

+ 10 + ? "#f87171" + : (summary.fallbackRatePct || 0) > 5 + ? "#fbbf24" + : "#34d399", + }} + > + {(summary.fallbackRatePct || 0) > 5 ? "warning" : "check_circle"} + +
+
+
+

+ {t("modelCoverage")} +

+

+ {(summary.requestedModelCoveragePct || 0).toFixed(1)}% +

+

{t("modelCoverageDesc")}

+
+
+
+ )} + + {summary.totalCost > 0 && ( +
+ +
+ trending_up +

+ {t("monthlyForecast")} +

+
+
+

+ {currencyFormatter.format(projectedMonthEnd)} +

+

+ {t("forecastBasis", { days: recentDays.length })} +

+
+
+ {t("avgDailyCost")}: + {currencyFormatter.format(avgDailyCost)} + / + {t("daysRemaining", { days: daysRemainingInMonth })} +
+
+ + +
+ + compare_arrows + +

+ {t("periodComparison")} +

+
+
+

0 + ? "text-red-400" + : costChangePct < 0 + ? "text-emerald-400" + : "text-text-main" + }`} + > + {costChangePct > 0 ? "+" : ""} + {costChangePct.toFixed(1)}% +

+ 0 + ? "text-red-400" + : costChangePct < 0 + ? "text-emerald-400" + : "text-text-muted" + }`} + > + {costChangePct > 0 + ? "arrow_upward" + : costChangePct < 0 + ? "arrow_downward" + : "remove"} + +
+
+
+

{t("previousPeriod")}

+

+ {currencyFormatter.format(firstHalfCost)} +

+
+
+

{t("currentPeriod")}

+

+ {currencyFormatter.format(secondHalfCost)} +

+
+
+
+
+ )} + {summary.totalCost <= 0 ? ( @@ -292,10 +654,70 @@ export default function CostOverviewTab() { title={t("topModels")} nameKey="model" valueKey="cost" + secondaryKey="totalTokens" + secondaryLabel={t("tokens")} rows={modelsByCost} locale={locale} /> + + {(apiKeysByCost.length > 0 || accountsByCost.length > 0) && ( +
+ {apiKeysByCost.length > 0 && ( + + )} + {accountsByCost.length > 0 && ( + + )} +
+ )} + + {summary.totalRequests > 0 && ( +
+ + +
+ )} )} @@ -463,17 +885,154 @@ function CostTrendCard({ ); } +function WeeklyPatternCard({ + title, + rows, + locale, +}: { + title: string; + rows: Array<{ day: string; avgTokens: number; totalTokens: number }>; + locale: string; +}) { + const chartData = rows.map((row) => ({ + day: row.day, + tokens: row.avgTokens || 0, + })); + + return ( + +

+ {title} +

+
+ + + + + new Intl.NumberFormat(locale, { notation: "compact" }).format(Number(value || 0)) + } + width={40} + /> + + `${new Intl.NumberFormat(locale).format(value || 0)} tokens` + } + contentStyle={{ + background: "var(--surface)", + border: "1px solid rgba(255,255,255,0.1)", + borderRadius: "12px", + }} + /> + + + +
+
+ ); +} + +function ActivityHeatmap({ + title, + activityMap, + lessLabel, + moreLabel, + locale, +}: { + title: string; + activityMap: Record; + lessLabel: string; + moreLabel: string; + locale: string; +}) { + const days: Array<{ date: string; value: number }> = []; + const today = new Date(); + for (let index = 364; index >= 0; index--) { + const date = new Date(today); + date.setDate(date.getDate() - index); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String( + date.getDate() + ).padStart(2, "0")}`; + days.push({ date: key, value: activityMap[key] || 0 }); + } + + const maxValue = Math.max(...days.map((day) => day.value), 1); + const getIntensity = (value: number): string => { + if (value === 0) return "bg-surface/30"; + const ratio = value / maxValue; + if (ratio < 0.25) return "bg-emerald-900/50"; + if (ratio < 0.5) return "bg-emerald-700/60"; + if (ratio < 0.75) return "bg-emerald-500/70"; + return "bg-emerald-400"; + }; + + const weeks: Array> = []; + for (let index = 0; index < days.length; index += 7) { + weeks.push(days.slice(index, index + 7)); + } + + return ( + +

+ {title} +

+
+
+ {weeks.map((week) => ( +
+ {week.map((day) => ( +
0 + ? `${new Intl.NumberFormat(locale).format(day.value)} tokens` + : "No activity" + }`} + /> + ))} +
+ ))} +
+
+
+ {lessLabel} +
+
+
+
+
+
+
+ {moreLabel} +
+ + ); +} + function TopListCard({ title, rows, nameKey, valueKey, + secondaryKey, + secondaryLabel, locale, }: { title: string; rows: Array>; nameKey: string; valueKey: string; + secondaryKey?: string; + secondaryLabel?: string; locale: string; }) { const currencyFormatter = createCurrencyFormatter(locale); @@ -490,12 +1049,101 @@ function TopListCard({ className="flex items-center justify-between gap-3 rounded-lg border border-border/20 bg-surface/20 px-4 py-3" > {String(row[nameKey])} - - {currencyFormatter.format(Number(row[valueKey] || 0))} - +
+ {secondaryKey ? ( + + {new Intl.NumberFormat(locale, { notation: "compact" }).format( + Number(row[secondaryKey] || 0) + )}{" "} + {secondaryLabel} + + ) : null} + + {currencyFormatter.format(Number(row[valueKey] || 0))} + +
))}
); } + +interface ColumnDef { + key: string; + label: string; + align: "left" | "right"; + format?: "number" | "compact" | "currency"; +} + +function CostBreakdownTable({ + title, + rows, + columns, + locale, +}: { + title: string; + rows: Array>; + columns: ColumnDef[]; + locale: string; +}) { + const currencyFormatter = createCurrencyFormatter(locale); + + function formatValue(value: unknown, format?: ColumnDef["format"]): string { + const num = Number(value || 0); + switch (format) { + case "currency": + return currencyFormatter.format(num); + case "compact": + return new Intl.NumberFormat(locale, { notation: "compact" }).format(num); + case "number": + return new Intl.NumberFormat(locale).format(num); + default: + return String(value ?? "-"); + } + } + + return ( + +

+ {title} +

+
+ + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+ {formatValue(row[column.key], column.format)} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 3d12cb818f..15b855ad8b 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, useMemo, useCallback } from "react"; -import PropTypes from "prop-types"; import Link from "next/link"; import { Card, Button, Input, Modal, CardSkeleton, SegmentedControl } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -88,6 +87,29 @@ type TunnelNotice = { message: string; }; +type APIPageClientProps = { + machineId: string; +}; + +type EndpointProviderSummary = { + id: string; + provider: { + name: string; + alias?: string; + }; +}; + +type EndpointModelSummary = { + id: string; + owned_by?: string; + parent?: string; + type?: string; + custom?: boolean; + root?: string; +}; + +type CopyHandler = (text: string, key?: string) => void | Promise; + type EndpointTunnelVisibility = { showCloudflaredTunnel: boolean; showTailscaleFunnel: boolean; @@ -104,7 +126,7 @@ function runEndpointBackgroundTask(taskName: string, task: () => Promise void; +}) { const t = useTranslations("endpoint"); const tc = useTranslations("common"); // Get provider alias for matching models @@ -2378,14 +2408,6 @@ function ProviderModelsModal({ provider, models, copy, copied, onClose }) { ); } -ProviderModelsModal.propTypes = { - provider: PropTypes.object.isRequired, - models: PropTypes.array.isRequired, - copy: PropTypes.func.isRequired, - copied: PropTypes.string, - onClose: PropTypes.func.isRequired, -}; - // -- Sub-component: Endpoint Section ------------------------------------------ function EndpointSection({ @@ -2402,6 +2424,20 @@ function EndpointSection({ copied, baseUrl, modelsLoading = false, +}: { + icon: string; + iconColor: string; + iconBg: string; + title: string; + path: string; + description: string; + models: EndpointModelSummary[]; + expanded: boolean; + onToggle: () => void; + copy: CopyHandler; + copied?: string | null; + baseUrl: string; + modelsLoading?: boolean; }) { const t = useTranslations("endpoint"); const grouped = useMemo(() => { @@ -2510,19 +2546,3 @@ function EndpointSection({
); } - -EndpointSection.propTypes = { - icon: PropTypes.string.isRequired, - iconColor: PropTypes.string.isRequired, - iconBg: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - models: PropTypes.array.isRequired, - expanded: PropTypes.bool.isRequired, - onToggle: PropTypes.func.isRequired, - copy: PropTypes.func.isRequired, - copied: PropTypes.string, - baseUrl: PropTypes.string.isRequired, - modelsLoading: PropTypes.bool, -}; diff --git a/src/app/(dashboard)/dashboard/health/TelemetryCard.tsx b/src/app/(dashboard)/dashboard/health/TelemetryCard.tsx new file mode 100644 index 0000000000..bafd447d83 --- /dev/null +++ b/src/app/(dashboard)/dashboard/health/TelemetryCard.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; + +type TelemetryPayload = { + count?: number; + totalRequests?: number; + avg?: number; + avgLatencyMs?: number; + p50?: number; + p95?: number; + p99?: number; + uptime?: number; + errorRate?: number; + activeConnections?: number; + memoryUsage?: { + rss?: number; + heapUsed?: number; + heapTotal?: number; + }; + sessions?: { + activeCount?: number; + }; + quotaMonitor?: { + errors?: number; + }; +}; + +type HealthPayload = { + system?: { + uptime?: number; + memoryUsage?: { + rss?: number; + heapUsed?: number; + heapTotal?: number; + }; + }; + activeConnections?: number; +}; + +type TelemetrySample = { + timestamp: number; + latencyMs: number; + throughput: number; + memoryBytes: number; +}; + +const REFRESH_MS = 30_000; +const MAX_SAMPLES = 24; + +function formatDuration(seconds = 0) { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +function formatBytes(bytes = 0) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; +} + +function formatMs(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return "—"; + return `${Math.round(value)}ms`; +} + +function Sparkline({ + samples, + field, +}: { + samples: TelemetrySample[]; + field: keyof TelemetrySample; +}) { + const values = samples + .map((sample) => Number(sample[field])) + .filter((value) => Number.isFinite(value)); + + if (values.length < 2) { + return
; + } + + const min = Math.min(...values); + const max = Math.max(...values); + const range = Math.max(1, max - min); + const points = values + .map((value, index) => { + const x = (index / Math.max(1, values.length - 1)) * 100; + const y = 36 - ((value - min) / range) * 32; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); + + return ( + + ); +} + +function getIndicatorTone(value: number, warning: number, critical: number, inverse = false) { + const healthy = inverse ? value >= warning : value <= warning; + const criticalHit = inverse ? value < critical : value >= critical; + if (criticalHit) return "bg-red-500/10 text-red-500"; + if (!healthy) return "bg-amber-500/10 text-amber-500"; + return "bg-emerald-500/10 text-emerald-500"; +} + +export default function TelemetryCard() { + const t = useTranslations("telemetry"); + const [telemetry, setTelemetry] = useState(null); + const [health, setHealth] = useState(null); + const [samples, setSamples] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastUpdated, setLastUpdated] = useState(null); + + const loadTelemetry = useCallback(async () => { + try { + const [telemetryResult, healthResult] = await Promise.allSettled([ + fetch("/api/telemetry/summary").then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }), + fetch("/api/monitoring/health").then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; + }), + ]); + + if (telemetryResult.status === "rejected" && healthResult.status === "rejected") { + throw telemetryResult.reason; + } + + const nextTelemetry = telemetryResult.status === "fulfilled" ? telemetryResult.value : null; + const nextHealth = healthResult.status === "fulfilled" ? healthResult.value : null; + if (nextTelemetry) setTelemetry(nextTelemetry); + if (nextHealth) setHealth(nextHealth); + setError(null); + setLastUpdated(new Date()); + + const memoryBytes = + nextTelemetry?.memoryUsage?.rss || nextHealth?.system?.memoryUsage?.rss || 0; + const latencyMs = + nextTelemetry?.avgLatencyMs ?? nextTelemetry?.avg ?? nextTelemetry?.p50 ?? 0; + const throughput = nextTelemetry?.totalRequests ?? nextTelemetry?.count ?? 0; + + setSamples((prev) => [ + ...prev.slice(Math.max(0, prev.length - MAX_SAMPLES + 1)), + { + timestamp: Date.now(), + latencyMs, + throughput, + memoryBytes, + }, + ]); + } catch (err) { + setError(err instanceof Error ? err.message : t("loadFailed")); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadTelemetry(); + const interval = setInterval(() => void loadTelemetry(), REFRESH_MS); + return () => clearInterval(interval); + }, [loadTelemetry]); + + const values = useMemo(() => { + const totalRequests = telemetry?.totalRequests ?? telemetry?.count ?? 0; + const avgLatency = telemetry?.avgLatencyMs ?? telemetry?.avg ?? telemetry?.p50; + const p95Latency = telemetry?.p95 ?? avgLatency ?? 0; + const quotaErrors = telemetry?.quotaMonitor?.errors ?? 0; + const errorRate = + typeof telemetry?.errorRate === "number" + ? telemetry.errorRate + : totalRequests > 0 + ? (quotaErrors / Math.max(totalRequests, 1)) * 100 + : 0; + + return { + uptime: telemetry?.uptime ?? health?.system?.uptime ?? 0, + totalRequests, + avgLatency, + p95Latency, + errorRate, + activeConnections: + telemetry?.activeConnections ?? + telemetry?.sessions?.activeCount ?? + health?.activeConnections ?? + 0, + memoryUsage: telemetry?.memoryUsage ?? health?.system?.memoryUsage ?? {}, + }; + }, [health, telemetry]); + + const metricCards = [ + { + label: t("uptime"), + value: formatDuration(values.uptime), + icon: "timer", + tone: "bg-blue-500/10 text-blue-500", + }, + { + label: t("totalRequests"), + value: values.totalRequests.toLocaleString(), + icon: "receipt_long", + tone: "bg-primary/10 text-primary", + }, + { + label: t("avgLatency"), + value: formatMs(values.avgLatency), + icon: "speed", + tone: getIndicatorTone(values.p95Latency, 2_000, 10_000), + }, + { + label: t("errorRate"), + value: `${values.errorRate.toFixed(2)}%`, + icon: "error", + tone: getIndicatorTone(values.errorRate, 1, 5), + }, + { + label: t("activeConnections"), + value: values.activeConnections.toLocaleString(), + icon: "hub", + tone: "bg-cyan-500/10 text-cyan-500", + }, + { + label: t("memoryUsage"), + value: formatBytes(values.memoryUsage.rss ?? values.memoryUsage.heapUsed ?? 0), + icon: "memory", + tone: "bg-violet-500/10 text-violet-500", + }, + ]; + + return ( + +
+
+

+ monitoring + {t("title")} +

+

{t("description")}

+ {lastUpdated && ( +

+ {t("updatedAt", { time: lastUpdated.toLocaleTimeString() })} +

+ )} +
+ +
+ + {error && ( +
+ {t("partialData", { error })} +
+ )} + +
+ {metricCards.map((metric) => ( +
+
+
+

+ {metric.label} +

+

{metric.value}

+
+ + {metric.icon} + +
+
+ ))} +
+ +
+
+
+ {t("latencyTrend")} + {formatMs(values.p95Latency)} p95 +
+ +
+
+
+ {t("throughputTrend")} + {values.totalRequests.toLocaleString()} +
+ +
+
+
+ {t("memoryTrend")} + {formatBytes(values.memoryUsage.heapUsed ?? 0)} +
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index d4af48798e..da02692bc1 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -16,6 +16,7 @@ import { useState, useEffect, useCallback } from "react"; import { Card } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; import { useTranslations } from "next-intl"; +import TelemetryCard from "./TelemetryCard"; function formatUptime(seconds) { const d = Math.floor(seconds / 86400); @@ -59,7 +60,6 @@ export default function HealthPage() { const [dbHealthError, setDbHealthError] = useState(null); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); - const [telemetry, setTelemetry] = useState(null); const [cache, setCache] = useState(null); const [signatureCache, setSignatureCache] = useState(null); const [degradation, setDegradation] = useState(null); @@ -91,20 +91,18 @@ export default function HealthPage() { } }, []); - // Fetch telemetry, cache, and signature cache stats + // Fetch cache, signature cache, and degradation stats. const fetchExtras = useCallback(async () => { const results = await Promise.allSettled([ - fetch("/api/telemetry/summary").then((r) => r.json()), fetch("/api/cache/stats").then((r) => r.json()), fetch("/api/rate-limits").then((r) => r.json()), fetch("/api/health/degradation").then((r) => r.json()), ]); - if (results[0].status === "fulfilled") setTelemetry(results[0].value); - if (results[1].status === "fulfilled") setCache(results[1].value); - if (results[2].status === "fulfilled" && results[2].value.cacheStats) { - setSignatureCache(results[2].value.cacheStats); + if (results[0].status === "fulfilled") setCache(results[0].value); + if (results[1].status === "fulfilled" && results[1].value.cacheStats) { + setSignatureCache(results[1].value.cacheStats); } - if (results[3].status === "fulfilled") setDegradation(results[3].value); + if (results[2].status === "fulfilled") setDegradation(results[2].value); }, []); useEffect(() => { @@ -247,6 +245,8 @@ export default function HealthPage() {
+ +
@@ -617,38 +617,8 @@ export default function HealthPage() { )} - {/* Telemetry Cards — Latency & Prompt Cache */} -
- {/* Latency Card */} - -

- speed - {t("latency")} -

- {telemetry ? ( -
-
- {t("latencyP50")} - {fmtMs(telemetry.p50)} -
-
- {t("latencyP95")} - {fmtMs(telemetry.p95)} -
-
- {t("latencyP99")} - {fmtMs(telemetry.p99)} -
-
- {t("totalRequests")} - {telemetry.totalRequests ?? 0} -
-
- ) : ( -

{t("noDataYet")}

- )} -
- + {/* Cache Cards */} +
{/* Prompt Cache Card */}

diff --git a/src/app/(dashboard)/dashboard/logs/page.tsx b/src/app/(dashboard)/dashboard/logs/page.tsx index 8c7c64eeeb..48e677fabc 100644 --- a/src/app/(dashboard)/dashboard/logs/page.tsx +++ b/src/app/(dashboard)/dashboard/logs/page.tsx @@ -55,7 +55,7 @@ export default function LogsPage() { try { const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs"; const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`); - if (!res.ok) throw new Error("Export failed"); + if (!res.ok) throw new Error(t("exportFailed")); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -66,7 +66,7 @@ export default function LogsPage() { document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { - console.error("Export failed:", err); + console.error(t("exportFailed"), err); } finally { setExporting(false); } @@ -111,7 +111,7 @@ export default function LogsPage() { strokeLinejoin="round" /> - {exporting ? "Exporting..." : "Export"} + {exporting ? t("exporting") : t("export")} {showExport && ( @@ -121,7 +121,7 @@ export default function LogsPage() { shadow-xl overflow-hidden animate-in fade-in" >
- Time Range + {t("timeRange")}
{TIME_RANGES.map((range) => ( ))} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 5cefea5c4d..10868deb0f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -3,7 +3,6 @@ import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { useNotificationStore } from "@/store/notificationStore"; -import PropTypes from "prop-types"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; @@ -3591,23 +3590,6 @@ function ModelRow({ ); } -ModelRow.propTypes = { - model: PropTypes.shape({ - id: PropTypes.string.isRequired, - }).isRequired, - fullModel: PropTypes.string.isRequired, - provider: PropTypes.string.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - t: PropTypes.func, - showDeveloperToggle: PropTypes.bool, - effectiveModelNormalize: PropTypes.func.isRequired, - effectiveModelPreserveDeveloper: PropTypes.func.isRequired, - getUpstreamHeadersRecord: PropTypes.func.isRequired, - saveModelCompatFlags: PropTypes.func.isRequired, - compatDisabled: PropTypes.bool, -}; - function ModelVisibilityToolbar({ t, filterValue, @@ -3842,27 +3824,6 @@ function PassthroughModelsSection({ ); } -PassthroughModelsSection.propTypes = { - providerAlias: PropTypes.string.isRequired, - modelAliases: PropTypes.object.isRequired, - customModels: PropTypes.array, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - effectiveModelNormalize: PropTypes.func.isRequired, - effectiveModelPreserveDeveloper: PropTypes.func.isRequired, - getUpstreamHeadersRecord: PropTypes.func.isRequired, - saveModelCompatFlags: PropTypes.func.isRequired, - compatSavingModelId: PropTypes.string, - isModelHidden: PropTypes.func.isRequired, - onToggleHidden: PropTypes.func.isRequired, - onBulkToggleHidden: PropTypes.func.isRequired, - bulkTogglePending: PropTypes.bool, - togglingModelId: PropTypes.string, -}; - function PassthroughModelRow({ modelId, fullModel, @@ -3984,25 +3945,6 @@ function PassthroughModelRow({ ); } -PassthroughModelRow.propTypes = { - modelId: PropTypes.string.isRequired, - fullModel: PropTypes.string.isRequired, - source: PropTypes.string, - isHidden: PropTypes.bool, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, - t: PropTypes.func, - showDeveloperToggle: PropTypes.bool, - effectiveModelNormalize: PropTypes.func.isRequired, - effectiveModelPreserveDeveloper: PropTypes.func.isRequired, - getUpstreamHeadersRecord: PropTypes.func.isRequired, - saveModelCompatFlags: PropTypes.func.isRequired, - compatDisabled: PropTypes.bool, - onToggleHidden: PropTypes.func, - togglingHidden: PropTypes.bool, -}; - // ============ Custom Models Section (for ALL providers) ============ function CustomModelsSection({ @@ -4516,14 +4458,6 @@ function CustomModelsSection({ ); } -CustomModelsSection.propTypes = { - providerId: PropTypes.string.isRequired, - providerAlias: PropTypes.string.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onModelsChanged: PropTypes.func, -}; - function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, @@ -4811,42 +4745,6 @@ function CompatibleModelsSection({ ); } -CompatibleModelsSection.propTypes = { - providerStorageAlias: PropTypes.string.isRequired, - providerDisplayAlias: PropTypes.string.isRequired, - modelAliases: PropTypes.object.isRequired, - customModels: PropTypes.array, - fallbackModels: PropTypes.array, - description: PropTypes.string.isRequired, - inputLabel: PropTypes.string.isRequired, - inputPlaceholder: PropTypes.string.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, - connections: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string, - isActive: PropTypes.bool, - }) - ).isRequired, - isAnthropic: PropTypes.bool, - onImportWithProgress: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - effectiveModelNormalize: PropTypes.func.isRequired, - effectiveModelPreserveDeveloper: PropTypes.func.isRequired, - getUpstreamHeadersRecord: PropTypes.func.isRequired, - saveModelCompatFlags: PropTypes.func.isRequired, - compatSavingModelId: PropTypes.string, - onModelsChanged: PropTypes.func, - allowImport: PropTypes.bool.isRequired, - isModelHidden: PropTypes.func.isRequired, - onToggleHidden: PropTypes.func.isRequired, - onBulkToggleHidden: PropTypes.func.isRequired, - bulkTogglePending: PropTypes.bool, - togglingModelId: PropTypes.string, -}; - function CooldownTimer({ until }: CooldownTimerProps) { const [remaining, setRemaining] = useState(""); @@ -4879,10 +4777,6 @@ function CooldownTimer({ until }: CooldownTimerProps) { return ⏱ {remaining}; } -CooldownTimer.propTypes = { - until: PropTypes.string.isRequired, -}; - const ERROR_TYPE_LABELS = { runtime_error: { labelKey: "errorTypeRuntime", variant: "warning" }, upstream_auth_error: { labelKey: "errorTypeUpstreamAuth", variant: "error" }, @@ -5469,50 +5363,6 @@ function ConnectionRow({ ); } -ConnectionRow.propTypes = { - connection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - email: PropTypes.string, - displayName: PropTypes.string, - rateLimitedUntil: PropTypes.string, - rateLimitProtection: PropTypes.bool, - testStatus: PropTypes.string, - isActive: PropTypes.bool, - priority: PropTypes.number, - lastError: PropTypes.string, - lastErrorType: PropTypes.string, - lastErrorSource: PropTypes.string, - errorCode: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - globalPriority: PropTypes.number, - providerSpecificData: PropTypes.object, - }).isRequired, - isOAuth: PropTypes.bool.isRequired, - isClaude: PropTypes.bool, - isCodex: PropTypes.bool, - isFirst: PropTypes.bool.isRequired, - isLast: PropTypes.bool.isRequired, - onMoveUp: PropTypes.func.isRequired, - onMoveDown: PropTypes.func.isRequired, - onToggleActive: PropTypes.func.isRequired, - onToggleRateLimit: PropTypes.func.isRequired, - onToggleClaudeExtraUsage: PropTypes.func, - onToggleCodex5h: PropTypes.func, - onToggleCodexWeekly: PropTypes.func, - isCcCompatible: PropTypes.bool, - cliproxyapiEnabled: PropTypes.bool, - onToggleCliproxyapiMode: PropTypes.func, - onRetest: PropTypes.func.isRequired, - isRetesting: PropTypes.bool, - onEdit: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, - onReauth: PropTypes.func, - onApplyCodexAuthLocal: PropTypes.func, - isApplyingCodexAuthLocal: PropTypes.bool, - onExportCodexAuthFile: PropTypes.func, - isExportingCodexAuthFile: PropTypes.bool, -}; - const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ "azure-openai", "bailian-coding-plan", @@ -6104,17 +5954,6 @@ function AddApiKeyModal({ ); } -AddApiKeyModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - provider: PropTypes.string, - providerName: PropTypes.string, - isCompatible: PropTypes.bool, - isAnthropic: PropTypes.bool, - isCcCompatible: PropTypes.bool, - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) { const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl; try { @@ -6860,20 +6699,6 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec ); } -EditConnectionModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - connection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - email: PropTypes.string, - priority: PropTypes.number, - authType: PropTypes.string, - provider: PropTypes.string, - }), - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - function EditCompatibleNodeModal({ isOpen, node, @@ -7134,20 +6959,3 @@ function EditCompatibleNodeModal({ ); } - -EditCompatibleNodeModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - node: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - prefix: PropTypes.string, - apiType: PropTypes.string, - baseUrl: PropTypes.string, - chatPath: PropTypes.string, - modelsPath: PropTypes.string, - }), - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - isAnthropic: PropTypes.bool, - isCcCompatible: PropTypes.bool, -}; diff --git a/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx new file mode 100644 index 0000000000..8b0b10a8cd --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx @@ -0,0 +1,342 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { Badge, Button, Input, Modal, Select } from "@/shared/components"; + +type CompatibleMode = "openai" | "anthropic" | "cc"; +type CompatibleProviderNode = { id: string } & Record; + +interface AddCompatibleProviderModalProps { + isOpen: boolean; + mode: CompatibleMode; + title?: string; + onClose: () => void; + onCreated: (node: CompatibleProviderNode) => void; +} + +interface CompatibleFormState { + name: string; + prefix: string; + apiType: string; + baseUrl: string; + chatPath: string; + modelsPath: string; +} + +const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; + +const MODE_DEFAULTS: Record< + CompatibleMode, + { + baseUrl: string; + type: "openai-compatible" | "anthropic-compatible"; + compatMode?: "cc"; + chatPath: string; + hasApiType: boolean; + hasModelsPath: boolean; + hasWarning: boolean; + } +> = { + openai: { + baseUrl: "https://api.openai.com/v1", + type: "openai-compatible", + chatPath: "", + hasApiType: true, + hasModelsPath: true, + hasWarning: false, + }, + anthropic: { + baseUrl: "https://api.anthropic.com/v1", + type: "anthropic-compatible", + chatPath: "", + hasApiType: false, + hasModelsPath: true, + hasWarning: false, + }, + cc: { + baseUrl: "", + type: "anthropic-compatible", + compatMode: "cc", + chatPath: CC_DEFAULT_CHAT_PATH, + hasApiType: false, + hasModelsPath: false, + hasWarning: true, + }, +}; + +function createInitialForm(mode: CompatibleMode): CompatibleFormState { + const defaults = MODE_DEFAULTS[mode]; + return { + name: "", + prefix: "", + apiType: "chat", + baseUrl: defaults.baseUrl, + chatPath: defaults.chatPath, + modelsPath: "", + }; +} + +export default function AddCompatibleProviderModal({ + isOpen, + mode, + title, + onClose, + onCreated, +}: AddCompatibleProviderModalProps) { + const t = useTranslations("providers"); + const defaults = MODE_DEFAULTS[mode]; + const [formData, setFormData] = useState(() => createInitialForm(mode)); + const [submitting, setSubmitting] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); + const [showAdvanced, setShowAdvanced] = useState(false); + + const apiTypeOptions = useMemo( + () => [ + { value: "chat", label: t("chatCompletions") }, + { value: "responses", label: t("responsesApi") }, + { value: "embeddings", label: t("embeddings") }, + { value: "audio-transcriptions", label: t("audioTranscriptions") }, + { value: "audio-speech", label: t("audioSpeech") }, + { value: "images-generations", label: t("imagesGenerations") }, + ], + [t] + ); + + useEffect(() => { + if (!isOpen) return; + setFormData(createInitialForm(mode)); + setValidationResult(null); + setCheckKey(""); + setShowAdvanced(false); + }, [isOpen, mode]); + + const modalTitle = + title || + (mode === "openai" + ? t("addOpenAICompatible") + : mode === "anthropic" + ? t("addAnthropicCompatible") + : t("addCcCompatible")); + + const namePlaceholder = + mode === "cc" + ? t("ccCompatibleNamePlaceholder") + : t("compatibleProdPlaceholder", { + type: mode === "openai" ? t("openai") : t("anthropic"), + }); + const nameHint = mode === "cc" ? t("ccCompatibleNameHint") : t("nameHint"); + const prefixPlaceholder = + mode === "openai" + ? t("openaiPrefixPlaceholder") + : mode === "cc" + ? t("ccCompatiblePrefixPlaceholder") + : t("anthropicPrefixPlaceholder"); + const prefixHint = mode === "cc" ? t("ccCompatiblePrefixHint") : t("prefixHint"); + const baseUrlPlaceholder = + mode === "openai" + ? t("openaiBaseUrlPlaceholder") + : mode === "cc" + ? t("ccCompatibleBaseUrlPlaceholder") + : t("anthropicBaseUrlPlaceholder"); + const baseUrlHint = + mode === "cc" + ? t("ccCompatibleBaseUrlHint") + : t("compatibleBaseUrlHint", { + type: mode === "openai" ? t("openai") : t("anthropic"), + }); + const chatPathPlaceholder = + mode === "openai" ? "/v1/chat/completions" : mode === "cc" ? CC_DEFAULT_CHAT_PATH : "/messages"; + const chatPathHint = mode === "cc" ? t("ccCompatibleChatPathHint") : t("chatPathHint"); + const advancedId = `advanced-settings-${mode}`; + const hasRequiredFields = Boolean( + formData.name.trim() && formData.prefix.trim() && formData.baseUrl.trim() + ); + const canValidate = Boolean(checkKey.trim() && formData.baseUrl.trim()); + + const resetAfterCreate = () => { + setFormData(createInitialForm(mode)); + setCheckKey(""); + setValidationResult(null); + setShowAdvanced(false); + }; + + const handleSubmit = async () => { + if (!hasRequiredFields) return; + setSubmitting(true); + try { + const body: Record = { + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + type: defaults.type, + chatPath: formData.chatPath || (mode === "cc" ? CC_DEFAULT_CHAT_PATH : ""), + }; + if (defaults.hasApiType) body.apiType = formData.apiType; + if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || ""; + if (defaults.compatMode) body.compatMode = defaults.compatMode; + + const res = await fetch("/api/provider-nodes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = (await res.json()) as { node: CompatibleProviderNode }; + if (res.ok) { + onCreated(data.node); + resetAfterCreate(); + } + } catch (error) { + console.log(`Error creating ${mode} compatible node:`, error); + } finally { + setSubmitting(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const body: Record = { + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: defaults.type, + }; + if (defaults.hasModelsPath) body.modelsPath = formData.modelsPath || ""; + if (defaults.compatMode) { + body.compatMode = defaults.compatMode; + body.chatPath = formData.chatPath || CC_DEFAULT_CHAT_PATH; + } + + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + return ( + +
+ {defaults.hasWarning && ( +
+
+ + warning + +

{t("ccCompatibleValidationHint")}

+
+
+ )} + + setFormData({ ...formData, name: e.target.value })} + placeholder={namePlaceholder} + hint={nameHint} + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder={prefixPlaceholder} + hint={prefixHint} + /> + {defaults.hasApiType && ( + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder={baseUrlPlaceholder} + hint={baseUrlHint} + /> + + + {showAdvanced && ( +
+ setFormData({ ...formData, chatPath: e.target.value })} + placeholder={chatPathPlaceholder} + hint={chatPathHint} + /> + {defaults.hasModelsPath && ( + setFormData({ ...formData, modelsPath: e.target.value })} + placeholder={t("modelsPathPlaceholder")} + hint={t("modelsPathHint")} + /> + )} +
+ )} + +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? t("valid") : t("invalid")} + + )} + +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx new file mode 100644 index 0000000000..b977a3a363 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -0,0 +1,251 @@ +"use client"; + +import type { MouseEvent, ReactNode } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +import { Badge, Card, Toggle } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { + isAnthropicCompatibleProvider, + isClaudeCodeCompatibleProvider, + isOpenAICompatibleProvider, +} from "@/shared/constants/providers"; + +interface ProviderStats { + total?: number; + connected?: number; + error?: number; + errorCode?: string | null; + errorTime?: string | null; + allDisabled?: boolean; + expiryStatus?: "expired" | "expiring_soon" | string | null; +} + +interface ProviderCardProps { + providerId: string; + provider: { + id?: string; + name: string; + color?: string; + apiType?: string; + deprecated?: boolean; + deprecationReason?: string; + hasFree?: boolean; + freeNote?: string; + }; + stats: ProviderStats; + authType?: string; + onToggle: (active: boolean) => void; +} + +const DOT_COLORS: Record = { + free: "bg-green-500", + oauth: "bg-blue-500", + apikey: "bg-amber-500", + compatible: "bg-orange-500", + "web-cookie": "bg-purple-500", + search: "bg-teal-500", + audio: "bg-rose-500", + local: "bg-emerald-500", + "upstream-proxy": "bg-indigo-500", +}; + +function getStatusDisplay( + connected: number, + error: number, + errorCode: string | null | undefined, + t: ReturnType +) { + const parts: ReactNode[] = []; + if (connected > 0) { + parts.push( + + {t("connected", { count: connected })} + + ); + } + if (error > 0) { + const errText = errorCode + ? t("errorCount", { count: error, code: errorCode }) + : t("errorCountNoCode", { count: error }); + parts.push( + + {errText} + + ); + } + if (parts.length === 0) { + return {t("noConnections")}; + } + return parts; +} + +export default function ProviderCard({ + providerId, + provider, + stats, + authType = "apikey", + onToggle, +}: ProviderCardProps) { + const t = useTranslations("providers"); + const tc = useTranslations("common"); + const connected = Number(stats.connected || 0); + const error = Number(stats.error || 0); + const allDisabled = Boolean(stats.allDisabled); + const isCompatible = isOpenAICompatibleProvider(providerId); + const isCcCompatible = isClaudeCodeCompatibleProvider(providerId); + const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId) && !isCcCompatible; + + const dotLabels: Record = { + free: tc("free"), + oauth: t("oauthLabel"), + apikey: t("apiKeyLabel"), + compatible: t("compatibleLabel"), + "web-cookie": t("webCookieProviders"), + search: t("searchProvidersHeading"), + audio: t("audioProvidersHeading"), + local: t("localProviders"), + "upstream-proxy": t("upstreamProxyProviders"), + }; + + const staticIconPath = (() => { + if (isCompatible) { + return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; + } + if (isAnthropicCompatible || isCcCompatible) return "/providers/anthropic-m.png"; + return null; + })(); + + const handleToggle = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onToggle(allDisabled); + }; + + return ( + + +
+
+
+ {staticIconPath ? ( + {provider.name} + ) : ( + + )} +
+
+

+ + {provider.name} + + {provider.deprecated && ( + + + block + {t("deprecated")} + + + )} + +

+
+ {allDisabled ? ( + + + pause_circle + {t("disabled")} + + + ) : ( + <> + {getStatusDisplay(connected, error, stats.errorCode, t)} + {(authType === "free" || provider.hasFree === true) && ( + + + redeem + {t("freeTier")} + + + )} + {stats.expiryStatus === "expired" && ( + + {t("expiredBadge")} + + )} + {stats.expiryStatus === "expiring_soon" && ( + + {t("expiringSoonBadge")} + + )} + {isCompatible && ( + + {provider.apiType === "responses" ? t("responses") : t("chat")} + + )} + {isCcCompatible && ( + + CC + + )} + {isAnthropicCompatible && ( + + {t("messages")} + + )} + {stats.errorTime && ( + * {stats.errorTime} + )} + + )} +
+
+
+
+ {Number(stats.total || 0) > 0 && ( +
+ {}} + title={allDisabled ? t("enableProvider") : t("disableProvider")} + /> +
+ )} + + chevron_right + +
+
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 4249b8ced2..69ff5d7a2f 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,27 +1,17 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import Image from "next/image"; -import ProviderIcon from "@/shared/components/ProviderIcon"; -import PropTypes from "prop-types"; -import { - Card, - CardSkeleton, - Badge, - Button, - Input, - Modal, - Select, - Toggle, -} from "@/shared/components"; +import { CardSkeleton, Badge, Button, Input, Toggle } from "@/shared/components"; import { FREE_PROVIDERS, OAUTH_PROVIDERS, - isAnthropicCompatibleProvider, + AGGREGATOR_PROVIDER_IDS, + EMBEDDING_RERANK_PROVIDER_IDS, + ENTERPRISE_CLOUD_PROVIDER_IDS, + IMAGE_ONLY_PROVIDER_IDS, + VIDEO_PROVIDER_IDS, isClaudeCodeCompatibleProvider, - isOpenAICompatibleProvider, } from "@/shared/constants/providers"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; @@ -35,29 +25,10 @@ import { } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; import { readConfiguredOnlyPreference, writeConfiguredOnlyPreference } from "./providerPageStorage"; +import AddCompatibleProviderModal from "./components/AddCompatibleProviderModal"; +import ProviderCard from "./components/ProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; -const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; -const IMAGE_ONLY_PROVIDER_IDS = new Set([ - "nanobanana", - "fal-ai", - "stability-ai", - "black-forest-labs", - "recraft", - "topaz", -]); -const AGGREGATOR_PROVIDER_IDS = new Set([ - "openrouter", - "synthetic", - "kilo-gateway", - "aimlapi", - "novita", - "piapi", - "getgoapi", - "laozhang", - "vercel-ai-gateway", -]); - function countConfigured(entries: ProviderEntry[]) { return { configured: entries.filter((entry) => Number(entry.stats?.total || 0) > 0).length, @@ -65,31 +36,25 @@ function countConfigured(entries: ProviderEntry[]) { }; } -// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard -function getStatusDisplay(connected, error, errorCode, t) { - const parts = []; - if (connected > 0) { - parts.push( - - {t("connected", { count: connected })} - - ); - } - if (error > 0) { - const errText = errorCode - ? t("errorCount", { count: error, code: errorCode }) - : t("errorCountNoCode", { count: error }); - parts.push( - - {errText} - - ); - } - if (parts.length === 0) { - return {t("noConnections")}; - } - return parts; -} +type ProviderBatchTestResult = { + connectionId?: string; + connectionName?: string; + provider?: string; + valid?: boolean; + latencyMs?: number; + diagnosis?: { type?: string }; +}; + +type ProviderBatchTestResults = { + mode?: string; + results?: ProviderBatchTestResult[]; + summary?: { + total?: number; + passed?: number; + failed?: number; + }; + error?: string | { message?: string }; +}; function getConnectionErrorTag(connection) { if (!connection) return null; @@ -437,7 +402,10 @@ export default function ProvidersPage() { apiKeyProviderEntriesAll.filter( (entry) => !IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId) && - !AGGREGATOR_PROVIDER_IDS.has(entry.providerId) + !AGGREGATOR_PROVIDER_IDS.has(entry.providerId) && + !ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId) && + !VIDEO_PROVIDER_IDS.has(entry.providerId) && + !EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId) ), showConfiguredOnly, searchQuery @@ -452,6 +420,21 @@ export default function ProvidersPage() { showConfiguredOnly, searchQuery ); + const enterpriseProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)), + showConfiguredOnly, + searchQuery + ); + const videoProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId)), + showConfiguredOnly, + searchQuery + ); + const embeddingRerankProviderEntries = filterConfiguredProviderEntries( + apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)), + showConfiguredOnly, + searchQuery + ); const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats); const webCookieProviderEntries = filterConfiguredProviderEntries( @@ -703,7 +686,7 @@ export default function ProvidersPage() {
{llmProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {aggregatorProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+

+ )} + + {enterpriseProviderEntries.length > 0 && ( +
+

+ {t("enterpriseCloud")} +

+
+ {enterpriseProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + {imageProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {videoProviderEntries.length > 0 && ( +
+

+ {t("videoProviders")} +

+
+ {videoProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + handleToggleProvider(providerId, toggleAuthType, active)} + /> + ) + )} +
+
+ )} + + {embeddingRerankProviderEntries.length > 0 && ( +
+

+ {t("embeddingRerankProviders")} +

+
+ {embeddingRerankProviderEntries.map( + ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( + {webCookieProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {searchProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {audioProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - +
{localProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - +
{upstreamProxyEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - {compatibleProviderEntries.map( ({ providerId, provider, stats, displayAuthType, toggleAuthType }) => ( - )}
- setShowAddCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); @@ -1031,8 +1111,9 @@ export default function ProvidersPage() { router.push(`/dashboard/providers/${node.id}`); }} /> - setShowAddAnthropicCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); @@ -1041,9 +1122,10 @@ export default function ProvidersPage() { }} /> {ccCompatibleProviderEnabled && ( - setShowAddCcCompatibleModal(false)} onCreated={(node) => { setProviderNodes((prev) => [...prev, node]); @@ -1083,865 +1165,9 @@ export default function ProvidersPage() { ); } -function ProviderCard({ providerId, provider, stats, authType, onToggle }) { - const t = useTranslations("providers"); - const tc = useTranslations("common"); - const { connected, error, errorCode, errorTime, allDisabled } = stats; - - // (#529) Icon state replaced by ProviderIcon component (Lobehub + PNG + generic fallback) - - const dotColors = { - free: "bg-green-500", - oauth: "bg-blue-500", - apikey: "bg-amber-500", - compatible: "bg-orange-500", - }; - const dotLabels = { - free: tc("free"), - oauth: t("oauthLabel"), - apikey: t("apiKeyLabel"), - compatible: t("compatibleLabel"), - }; - - return ( - - -
-
-
- {/* (#529) ProviderIcon: Lobehub icons → PNG fallback → generic icon */} - -
-
-

- {provider.name} - -

-
- {allDisabled ? ( - - - pause_circle - {t("disabled")} - - - ) : ( - <> - {getStatusDisplay(connected, error, errorCode, t)} - {stats.expiryStatus === "expired" && ( - - Expired - - )} - {stats.expiryStatus === "expiring_soon" && ( - - Expiring Soon - - )} - {errorTime && • {errorTime}} - - )} -
-
-
-
- {stats.total > 0 && ( -
{ - e.preventDefault(); - e.stopPropagation(); - onToggle(!allDisabled ? false : true); - }} - className="" - > - {}} - title={allDisabled ? t("enableProvider") : t("disableProvider")} - /> -
- )} - - chevron_right - -
-
-
- - ); -} - -ProviderCard.propTypes = { - providerId: PropTypes.string.isRequired, - provider: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - textIcon: PropTypes.string, - }).isRequired, - stats: PropTypes.shape({ - connected: PropTypes.number, - error: PropTypes.number, - errorCode: PropTypes.string, - errorTime: PropTypes.string, - }).isRequired, - authType: PropTypes.string, -}; - -// API Key providers - use image with textIcon fallback (same as OAuth providers) -function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) { - const t = useTranslations("providers"); - const tc = useTranslations("common"); - const { connected, error, errorCode, errorTime, allDisabled } = stats; - const isCompatible = isOpenAICompatibleProvider(providerId); - const isCcCompatible = isClaudeCodeCompatibleProvider(providerId); - const isAnthropicCompatible = - isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId); - - const dotColors = { - free: "bg-green-500", - oauth: "bg-blue-500", - apikey: "bg-amber-500", - compatible: "bg-orange-500", - }; - const dotLabels = { - free: tc("free"), - oauth: t("oauthLabel"), - apikey: t("apiKeyLabel"), - compatible: t("compatibleLabel"), - }; - - // (#529) Icon state replaced by ProviderIcon component - // For compatible/anthropic providers, continue using static PNGs via the icon path - const staticIconPath = (() => { - if (isCompatible) { - return provider.apiType === "responses" ? "/providers/oai-r.png" : "/providers/oai-cc.png"; - } - if (isAnthropicCompatible || isCcCompatible) return "/providers/anthropic-m.png"; - return null; // ProviderIcon will handle it - })(); - - return ( - - -
-
-
- {/* (#529) ProviderIcon with static override for compatible providers */} - {staticIconPath ? ( - {provider.name} - ) : ( - - )} -
-
-

- {provider.name} - -

-
- {allDisabled ? ( - - - pause_circle - {t("disabled")} - - - ) : ( - <> - {getStatusDisplay(connected, error, errorCode, t)} - {stats.expiryStatus === "expired" && ( - - Expired - - )} - {stats.expiryStatus === "expiring_soon" && ( - - Expiring Soon - - )} - {isCompatible && ( - - {provider.apiType === "responses" ? t("responses") : t("chat")} - - )} - {isCcCompatible && ( - - CC - - )} - {isAnthropicCompatible && ( - - {t("messages")} - - )} - {errorTime && • {errorTime}} - - )} -
-
-
-
- {stats.total > 0 && ( -
{ - e.preventDefault(); - e.stopPropagation(); - onToggle(!allDisabled ? false : true); - }} - className="" - > - {}} - title={allDisabled ? t("enableProvider") : t("disableProvider")} - /> -
- )} - - chevron_right - -
-
-
- - ); -} - -ApiKeyProviderCard.propTypes = { - providerId: PropTypes.string.isRequired, - provider: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - textIcon: PropTypes.string, - apiType: PropTypes.string, - }).isRequired, - stats: PropTypes.shape({ - connected: PropTypes.number, - error: PropTypes.number, - errorCode: PropTypes.string, - errorTime: PropTypes.string, - }).isRequired, - authType: PropTypes.string, -}; - -function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) { - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - chatPath: "", - modelsPath: "", - }); - const [submitting, setSubmitting] = useState(false); - const [checkKey, setCheckKey] = useState(""); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); - const [showAdvanced, setShowAdvanced] = useState(false); - - const apiTypeOptions = [ - { value: "chat", label: t("chatCompletions") }, - { value: "responses", label: t("responsesApi") }, - { value: "embeddings", label: t("embeddings") }, - { value: "audio-transcriptions", label: t("audioTranscriptions") }, - { value: "audio-speech", label: t("audioSpeech") }, - { value: "images-generations", label: t("imagesGenerations") }, - ]; - - useEffect(() => { - const defaultBaseUrl = "https://api.openai.com/v1"; - setFormData((prev) => ({ - ...prev, - baseUrl: defaultBaseUrl, - })); - }, [formData.apiType]); - - const handleSubmit = async () => { - if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - apiType: formData.apiType, - baseUrl: formData.baseUrl, - type: "openai-compatible", - chatPath: formData.chatPath || "", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - chatPath: "", - modelsPath: "", - }); - setCheckKey(""); - setValidationResult(null); - setShowAdvanced(false); - } - } catch (error) { - console.log("Error creating OpenAI Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "openai-compatible", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder={t("compatibleProdPlaceholder", { type: t("openai") })} - hint={t("nameHint")} - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={t("openaiPrefixPlaceholder")} - hint={t("prefixHint")} - /> - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={t("openaiBaseUrlPlaceholder")} - hint={t("compatibleBaseUrlHint", { type: t("openai") })} - /> - - {showAdvanced && ( -
- setFormData({ ...formData, chatPath: e.target.value })} - placeholder={t("chatPathPlaceholder")} - hint={t("chatPathHint")} - /> - setFormData({ ...formData, modelsPath: e.target.value })} - placeholder={t("modelsPathPlaceholder")} - hint={t("modelsPathHint")} - /> -
- )} -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - - )} -
- - -
-
-
- ); -} - -AddOpenAICompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - -function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - name: "", - prefix: "", - baseUrl: "https://api.anthropic.com/v1", - chatPath: "", - modelsPath: "", - }); - const [submitting, setSubmitting] = useState(false); - const [checkKey, setCheckKey] = useState(""); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); - const [showAdvanced, setShowAdvanced] = useState(false); - - useEffect(() => { - // Reset validation when modal opens - if (isOpen) { - setValidationResult(null); - setCheckKey(""); - } - }, [isOpen]); - - const handleSubmit = async () => { - if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - baseUrl: formData.baseUrl, - type: "anthropic-compatible", - chatPath: formData.chatPath || "", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - baseUrl: "https://api.anthropic.com/v1", - chatPath: "", - modelsPath: "", - }); - setCheckKey(""); - setValidationResult(null); - setShowAdvanced(false); - } - } catch (error) { - console.log("Error creating Anthropic Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "anthropic-compatible", - modelsPath: formData.modelsPath || "", - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder={t("compatibleProdPlaceholder", { type: t("anthropic") })} - hint={t("nameHint")} - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={t("anthropicPrefixPlaceholder")} - hint={t("prefixHint")} - /> - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={t("anthropicBaseUrlPlaceholder")} - hint={t("compatibleBaseUrlHint", { type: t("anthropic") })} - /> - - {showAdvanced && ( -
- setFormData({ ...formData, chatPath: e.target.value })} - placeholder="/messages" - hint={t("chatPathHint")} - /> - setFormData({ ...formData, modelsPath: e.target.value })} - placeholder={t("modelsPathPlaceholder")} - hint={t("modelsPathHint")} - /> -
- )} -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - - )} -
- - -
-
-
- ); -} - -AddAnthropicCompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - -function AddCcCompatibleModal({ isOpen, addLabel, onClose, onCreated }) { - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - name: "", - prefix: "", - baseUrl: "", - chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }); - const [submitting, setSubmitting] = useState(false); - const [checkKey, setCheckKey] = useState(""); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState<"success" | "failed" | null>(null); - const [showAdvanced, setShowAdvanced] = useState(false); - const hasRequiredFields = Boolean( - formData.name.trim() && formData.prefix.trim() && formData.baseUrl.trim() - ); - const canValidate = Boolean(checkKey.trim() && formData.baseUrl.trim()); - - useEffect(() => { - if (isOpen) { - setValidationResult(null); - setCheckKey(""); - } - }, [isOpen]); - - const handleSubmit = async () => { - if (!hasRequiredFields) return; - setSubmitting(true); - try { - const res = await fetch("/api/provider-nodes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: formData.name, - prefix: formData.prefix, - baseUrl: formData.baseUrl, - type: "anthropic-compatible", - compatMode: "cc", - chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }), - }); - const data = await res.json(); - if (res.ok) { - onCreated(data.node); - setFormData({ - name: "", - prefix: "", - baseUrl: "", - chatPath: CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }); - setCheckKey(""); - setValidationResult(null); - setShowAdvanced(false); - } - } catch (error) { - console.log("Error creating CC Compatible node:", error); - } finally { - setSubmitting(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: "anthropic-compatible", - compatMode: "cc", - chatPath: formData.chatPath || CC_COMPATIBLE_DEFAULT_CHAT_PATH, - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - return ( - -
-
-
- - warning - -

{t("ccCompatibleValidationHint")}

-
-
- setFormData({ ...formData, name: e.target.value })} - placeholder={t("ccCompatibleNamePlaceholder")} - hint={t("ccCompatibleNameHint")} - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={t("ccCompatiblePrefixPlaceholder")} - hint={t("ccCompatiblePrefixHint")} - /> - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={t("ccCompatibleBaseUrlPlaceholder")} - hint={t("ccCompatibleBaseUrlHint")} - /> - - {showAdvanced && ( -
- setFormData({ ...formData, chatPath: e.target.value })} - placeholder={CC_COMPATIBLE_DEFAULT_CHAT_PATH} - hint={t("ccCompatibleChatPathHint")} - /> -
- )} -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - - )} -
- - -
-
-
- ); -} - -AddCcCompatibleModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - addLabel: PropTypes.string.isRequired, - onClose: PropTypes.func.isRequired, - onCreated: PropTypes.func.isRequired, -}; - // ─── Provider Test Results View (mirrors combo TestResultsView) ────────────── -function ProviderTestResultsView({ results }) { +function ProviderTestResultsView({ results }: { results: ProviderBatchTestResults }) { const t = useTranslations("providers"); const tc = useTranslations("common"); const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); @@ -2040,16 +1266,3 @@ function ProviderTestResultsView({ results }) {
); } - -ProviderTestResultsView.propTypes = { - results: PropTypes.shape({ - mode: PropTypes.string, - results: PropTypes.array, - summary: PropTypes.shape({ - total: PropTypes.number, - passed: PropTypes.number, - failed: PropTypes.number, - }), - error: PropTypes.string, - }).isRequired, -}; diff --git a/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx new file mode 100644 index 0000000000..f9b8682a32 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/MitmProxyTab.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card } from "@/shared/components"; + +type MitmTargetRoute = { + id: string; + name: string; + targetHost: string; + targetPort: number; + localPort: number; + endpoints: string[]; + enabled: boolean; +}; + +type MitmStatus = { + running: boolean; + pid: number | null; + dnsConfigured: boolean; + certExists: boolean; + hasCachedPassword: boolean; + port: number; + targets: MitmTargetRoute[]; + stats: { + startedAt: string | null; + totalRequests: number; + interceptedRequests: number; + activeConnections: number; + lastRequestAt: string | null; + lastInterceptAt: string | null; + }; +}; + +function emptyStatus(): MitmStatus { + return { + running: false, + pid: null, + dnsConfigured: false, + certExists: false, + hasCachedPassword: false, + port: 443, + targets: [], + stats: { + startedAt: null, + totalRequests: 0, + interceptedRequests: 0, + activeConnections: 0, + lastRequestAt: null, + lastInterceptAt: null, + }, + }; +} + +function formatDate(value: string | null) { + if (!value) return "-"; + try { + return new Date(value).toLocaleString(); + } catch { + return value; + } +} + +export default function MitmProxyTab() { + const t = useTranslations("mitm"); + const [status, setStatus] = useState(emptyStatus); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [port, setPort] = useState("443"); + const [apiKey, setApiKey] = useState(""); + const [sudoPassword, setSudoPassword] = useState(""); + const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>( + null + ); + + const loadStatus = useCallback(async () => { + setLoading(true); + try { + const response = await fetch("/api/settings/mitm"); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || t("loadFailed")); + setStatus(data); + setPort(String(data.port || 443)); + setFeedback(null); + } catch (error) { + setFeedback({ + type: "error", + message: error instanceof Error ? error.message : t("loadFailed"), + }); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void loadStatus(); + }, [loadStatus]); + + const updateMitm = async (payload: Record, successMessage: string) => { + setSaving(true); + setFeedback(null); + try { + const response = await fetch("/api/settings/mitm", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || t("saveFailed")); + setStatus(data); + setPort(String(data.port || 443)); + setFeedback({ type: "success", message: successMessage }); + } catch (error) { + setFeedback({ + type: "error", + message: error instanceof Error ? error.message : t("saveFailed"), + }); + } finally { + setSaving(false); + } + }; + + const savePort = () => { + const parsedPort = Number.parseInt(port, 10); + if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) { + setFeedback({ type: "error", message: t("invalidPort") }); + return; + } + void updateMitm({ port: parsedPort }, t("settingsSaved")); + }; + + const toggleMitm = () => { + void updateMitm( + { + enabled: !status.running, + port: Number.parseInt(port, 10) || 443, + apiKey: apiKey.trim() || undefined, + sudoPassword: sudoPassword || undefined, + }, + status.running ? t("stoppedSuccess") : t("startedSuccess") + ); + }; + + const regenerateCertificate = async () => { + if (!confirm(t("regenerateConfirm"))) return; + + setSaving(true); + setFeedback(null); + try { + const response = await fetch("/api/settings/mitm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "regenerate-cert" }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || t("regenerateFailed")); + setStatus(data); + setFeedback({ type: "success", message: t("regenerateSuccess") }); + } catch (error) { + setFeedback({ + type: "error", + message: error instanceof Error ? error.message : t("regenerateFailed"), + }); + } finally { + setSaving(false); + } + }; + + const statusTone = status.running + ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-600" + : "border-border bg-sidebar text-text-muted"; + + return ( + +
+
+

+ lan + {t("title")} +

+

{t("description")}

+
+
+ + + {status.running ? "play_circle" : "pause_circle"} + + {status.running ? t("running") : t("stopped")} + + +
+
+ + {feedback && ( +
+ {feedback.message} +
+ )} + +
+
+
+
+
+

{t("enable")}

+

{t("enableDesc")}

+
+ +
+ +
+ + + +
+ +
+ +
+
+
+

{t("certificate")}

+

+ {status.certExists ? t("certificateReady") : t("certificateMissing")} +

+
+ + {status.certExists ? t("available") : t("missing")} + +
+
+ + download + {t("downloadCert")} + + +
+
+
+ +
+ {[ + { + label: t("interceptedRequests"), + value: status.stats.interceptedRequests.toLocaleString(), + icon: "swap_horiz", + }, + { + label: t("activeConnections"), + value: status.stats.activeConnections.toLocaleString(), + icon: "hub", + }, + { + label: t("dnsConfigured"), + value: status.dnsConfigured ? t("yes") : t("no"), + icon: "dns", + }, + { + label: t("pid"), + value: status.pid ? String(status.pid) : "-", + icon: "tag", + }, + ].map((item) => ( +
+
+
+

+ {item.label} +

+

{item.value}

+
+ + {item.icon} + +
+
+ ))} +
+

+ {t("lastIntercept")} +

+

+ {formatDate(status.stats.lastInterceptAt)} +

+
+
+
+ +
+
+

{t("targetRoutes")}

+
+ {status.targets.length === 0 ? ( +
{t("noTargets")}
+ ) : ( +
+ + + + + + + + + + + + {status.targets.map((target) => ( + + + + + + + + ))} + +
{t("target")}{t("host")}{t("localPort")}{t("endpoints")}{t("status")}
{target.name} + {target.targetHost}:{target.targetPort} + + {target.localPort} + +
+ {target.endpoints.map((endpoint) => ( + + {endpoint} + + ))} +
+
+ + {target.enabled ? t("enabled") : t("configured")} + +
+
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index cbec0d9576..e7511ddb51 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -22,6 +22,7 @@ import ResilienceTab from "./components/ResilienceTab"; import CliproxyapiSettingsTab from "./components/CliproxyapiSettingsTab"; import PayloadRulesTab from "./components/PayloadRulesTab"; import VisionBridgeSettingsTab from "./components/VisionBridgeSettingsTab"; +import MitmProxyTab from "./components/MitmProxyTab"; import ModelRoutingSection from "@/shared/components/ModelRoutingSection"; const tabs = [ @@ -31,6 +32,7 @@ const tabs = [ { id: "security", labelKey: "security", icon: "shield" }, { id: "routing", labelKey: "routing", icon: "route" }, { id: "resilience", labelKey: "resilience", icon: "electrical_services" }, + { id: "mitm", labelKey: "mitmProxy", icon: "lan" }, { id: "advanced", labelKey: "advanced", icon: "tune" }, ]; @@ -116,6 +118,8 @@ export default function SettingsPage() { {activeTab === "resilience" && } + {activeTab === "mitm" && } + {activeTab === "advanced" && (
diff --git a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx index 1d359d4fce..3d57ac02ad 100644 --- a/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx +++ b/src/app/(dashboard)/dashboard/translator/TranslatorPageClient.tsx @@ -3,7 +3,7 @@ import { useTranslations } from "next-intl"; import { useCallback, useState } from "react"; -import { SegmentedControl } from "@/shared/components"; +import { Badge, Card, SegmentedControl } from "@/shared/components"; import PlaygroundMode from "./components/PlaygroundMode"; import ChatTesterMode from "./components/ChatTesterMode"; import TestBenchMode from "./components/TestBenchMode"; @@ -12,6 +12,7 @@ import StreamTransformerMode from "./components/StreamTransformerMode"; export default function TranslatorPageClient() { const t = useTranslations("translator"); + const [showFeatures, setShowFeatures] = useState(false); const translateOrFallback = useCallback( (key: string, fallback: string) => { try { @@ -94,6 +95,79 @@ export default function TranslatorPageClient() {
+ + + + {showFeatures && ( +
+ + + + + + + + +
+ )} +
+ {/* Mode Content */} {mode === "playground" && } {mode === "chat-tester" && } @@ -103,3 +177,60 @@ export default function TranslatorPageClient() {
); } + +function FeatureChip({ + icon, + title, + description, + color, +}: { + icon: string; + title: string; + description: string; + color: "purple" | "blue" | "amber" | "emerald" | "cyan" | "orange" | "pink" | "indigo"; +}) { + const colorMap = { + purple: { + shell: "border-purple-500/20 bg-purple-500/5", + icon: "text-purple-500", + }, + blue: { + shell: "border-blue-500/20 bg-blue-500/5", + icon: "text-blue-500", + }, + amber: { + shell: "border-amber-500/20 bg-amber-500/5", + icon: "text-amber-500", + }, + emerald: { + shell: "border-emerald-500/20 bg-emerald-500/5", + icon: "text-emerald-500", + }, + cyan: { + shell: "border-cyan-500/20 bg-cyan-500/5", + icon: "text-cyan-500", + }, + orange: { + shell: "border-orange-500/20 bg-orange-500/5", + icon: "text-orange-500", + }, + pink: { + shell: "border-pink-500/20 bg-pink-500/5", + icon: "text-pink-500", + }, + indigo: { + shell: "border-indigo-500/20 bg-indigo-500/5", + icon: "text-indigo-500", + }, + }[color]; + + return ( +
+
+ {icon} +

{title}

+
+

{description}

+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx index 07aaed5b04..3d1e29a0a3 100644 --- a/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx +++ b/src/app/(dashboard)/dashboard/translator/components/ChatTesterMode.tsx @@ -79,6 +79,17 @@ export default function ChatTesterMode() { parts: [{ text: m.content }], })), }; + } else if (clientFormat === "antigravity") { + clientRequest = { + request: { + contents: allMessages.map((m) => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })), + }, + model, + userAgent: "antigravity", + }; } else if (clientFormat === "openai-responses") { clientRequest = { model, @@ -89,6 +100,12 @@ export default function ChatTesterMode() { })), stream: true, }; + } else if (clientFormat === "cursor" || clientFormat === "kiro") { + clientRequest = { + model, + messages: allMessages, + stream: true, + }; } else { clientRequest = { model, @@ -278,9 +295,7 @@ export default function ChatTesterMode() { { + const file = event.currentTarget.files?.[0]; + if (file) { + void handleImportSuite(file); + } + event.currentTarget.value = ""; + }} + /> + + + +
+ {runProgress && ( +
+
+ + {t("runAllProgress", { + current: runProgress.current, + total: runProgress.total, + name: runProgress.suiteName || t("runAllSuites"), + })} + + {runAllPercent}% +
+
+
+
+ {runProgress.failedSuites > 0 && ( +

+ {t("runAllFailedSuites", { count: runProgress.failedSuites })} +

+ )} +
+ )} +
+ + {suite.source === "custom" && ( <>
- ({ - key: column.key, - label: t(column.labelKey), - }))} - data={run.results.map((result, index) => ({ - ...result, - id: result.caseId || index, - }))} - renderCell={(row, column) => { - if (column.key === "status") { - return row.passed ? ( - {t("passedIconLabel")} - ) : ( -
- {t("failedIconLabel")} - {row.error ? ( - - {t("errorBadge")} + {run.results.length > 0 ? ( +
+ {run.results.map((result, index) => { + const resultKey = `${run.id}:${result.caseId || index}`; + const isResultExpanded = expandedResults.has(resultKey); + const actualOutput = getResultActualValue( + result, + run.outputs?.[result.caseId] + ); + const expectedOutput = getResultExpectedValue(result); + + return ( +
+ + + {isResultExpanded && ( +
+
+

+ {t("expectedOutputLabel")} +

+
+                                              {expectedOutput}
+                                            
+
+
+

+ {t("actualOutputLabel")} +

+
+                                              {actualOutput}
+                                            
+ {result.error ? ( +

+ {result.error} +

+ ) : null} +
+
+ )}
); - } - - if (column.key === "durationMs") { - return ( - - {row.durationMs != null ? `${row.durationMs}ms` : "—"} - - ); - } - - if (column.key === "details") { - return ( - - {getResultDetails(row as EvalResult, t)} - - ); - } - - return ( - - {String(row[column.key] || "—")} - - ); - }} - maxHeight="360px" - emptyMessage={t("noResultsYet")} - /> + })} +
+ ) : ( +
+ {t("noResultsYet")} +
+ )} ))}
@@ -1481,6 +1942,35 @@ function SuiteBuilderModal({ }); } + function duplicateCase(caseId: string) { + const source = draft.cases.find((entry) => entry.id === caseId); + if (!source) return; + + const sourceIndex = draft.cases.findIndex((entry) => entry.id === caseId); + const duplicate = { + ...source, + id: createDraftId(), + name: source.name ? `${source.name} ${t("suiteBuilderCloneSuffix")}`.trim() : "", + }; + const nextCases = [...draft.cases]; + nextCases.splice(sourceIndex + 1, 0, duplicate); + onChange({ + ...draft, + cases: nextCases, + }); + } + + function getExpectedPlaceholder(strategy: BuilderStrategy) { + if (strategy === "exact") return t("suiteBuilderCaseExpectedPlaceholderExact"); + if (strategy === "regex") return t("suiteBuilderCaseExpectedPlaceholderRegex"); + return t("suiteBuilderCaseExpectedPlaceholderContains"); + } + + function getExpectedHint(strategy: BuilderStrategy) { + if (strategy === "regex") return t("suiteBuilderCaseExpectedHintRegex"); + return undefined; + } + return (
- {draft.cases.map((draftCase, index) => ( - -
-
-

- {t("suiteBuilderCaseCardTitle", { index: index + 1 })} -

-

- {t("suiteBuilderCaseCardHint", { index: index + 1 })} -

+ {draft.cases.map((draftCase, index) => { + const selectedStrategy = editableStrategies.find( + (strategy) => strategy.name === draftCase.strategy + ); + + return ( + +
+
+

+ {t("suiteBuilderCaseCardTitle", { index: index + 1 })} +

+

+ {t("suiteBuilderCaseCardHint", { index: index + 1 })} +

+
+
+ + +
- -
-
- updateCase(draftCase.id, { name: event.target.value })} - placeholder={t("suiteBuilderCaseNamePlaceholder")} - /> - updateCase(draftCase.id, { model: event.target.value })} - placeholder={t("suiteBuilderCaseModelPlaceholder")} - /> - updateCase(draftCase.id, { tags: event.target.value })} - placeholder={t("suiteBuilderCaseTagsPlaceholder")} - hint={t("suiteBuilderCaseTagsHint")} - /> -