From c86c43f78cf7eaf40d472b7111dd52552afa9c4a Mon Sep 17 00:00:00 2001 From: Andrew Munsell Date: Wed, 29 Apr 2026 16:11:24 -0700 Subject: [PATCH 01/26] feat(api-keys): add rename support in permissions modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an editable key name field at the top of the permissions modal, allowing users to rename API keys alongside existing permission settings. The backend already supported name updates via PATCH /api/keys/:id — this wires the UI to send the name field and refreshes the key list on success. Changes: - Add keyName state and text input to PermissionsModal - Update handleUpdatePermissions to validate and send name in PATCH body - Add integration test for rename via PATCH (valid, empty, too-long names) - Update E2E mock to handle PATCH requests --- CHANGELOG.md | 4 + .../api-manager/ApiManagerPageClient.tsx | 40 ++++- tests/e2e/api-keys-flow.spec.ts | 168 ++++++++++++++++++ tests/integration/api-keys.test.ts | 41 +++++ 4 files changed, 248 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d829a062b058..35f9169e0027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### ✨ New Features + +- **feat(api-keys):** add rename support in the permissions modal — editable key name field with validation + --- ## [3.7.4] — 2026-04-28 diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index a71b38a25893..fff2b31ec3a3 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -6,7 +6,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useTranslations } from "next-intl"; // Constants for validation -const MAX_KEY_NAME_LENGTH = 100; +const MAX_KEY_NAME_LENGTH = 200; const MAX_SELECTED_MODELS = 500; // Debounce hook for search optimization @@ -232,14 +232,13 @@ export default function ApiManagerPageClient() { const clearError = useCallback(() => setError(null), []); const handleCreateKey = async () => { - // Validate and sanitize input - const sanitizedName = sanitizeInput(newKeyName); - const validation = validateKeyName(sanitizedName, t); - + // Validate raw input first, then sanitize + const validation = validateKeyName(newKeyName, t); if (!validation.valid) { setError(validation.error || t("invalidKeyName")); return; } + const sanitizedName = sanitizeInput(newKeyName); setIsSubmitting(true); clearError(); @@ -322,6 +321,7 @@ export default function ApiManagerPageClient() { }; const handleUpdatePermissions = async ( + name: string, allowedModels: string[], noLog: boolean, allowedConnections: string[], @@ -332,6 +332,14 @@ export default function ApiManagerPageClient() { ) => { if (!editingKey || !editingKey.id) return; + // Validate raw input first, then sanitize + const nameValidation = validateKeyName(name, t); + if (!nameValidation.valid) { + setError(nameValidation.error || t("invalidKeyName")); + return; + } + const sanitizedName = sanitizeInput(name); + // Validate models array if (!Array.isArray(allowedModels)) { setError(t("invalidModelsSelection")); @@ -366,6 +374,7 @@ export default function ApiManagerPageClient() { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + name: sanitizedName, allowedModels: validModels, allowedConnections: validConnections, noLog, @@ -764,6 +773,7 @@ export default function ApiManagerPageClient() { value={newKeyName} onChange={(e) => setNewKeyName(e.target.value)} placeholder={t("keyNamePlaceholder")} + maxLength={MAX_KEY_NAME_LENGTH} autoFocus />

{t("keyNameDesc")}

@@ -862,6 +872,7 @@ const PermissionsModal = memo(function PermissionsModal({ searchModel: string; onSearchChange: (v: string) => void; onSave: ( + name: string, models: string[], noLog: boolean, connections: string[], @@ -879,6 +890,7 @@ const PermissionsModal = memo(function PermissionsModal({ const initialConnections = Array.isArray(apiKey?.allowedConnections) ? apiKey.allowedConnections : []; + const [keyName, setKeyName] = useState(apiKey?.name ?? ""); const [selectedModels, setSelectedModels] = useState(initialModels); const [allowAll, setAllowAll] = useState(initialModels.length === 0); const [noLogEnabled, setNoLogEnabled] = useState(apiKey?.noLog === true); @@ -993,6 +1005,7 @@ const PermissionsModal = memo(function PermissionsModal({ } : null; onSave( + keyName, allowAll ? [] : selectedModels, noLogEnabled, allowAllConnections ? [] : selectedConnections, @@ -1003,6 +1016,7 @@ const PermissionsModal = memo(function PermissionsModal({ ); }, [ onSave, + keyName, allowAll, selectedModels, noLogEnabled, @@ -1028,6 +1042,22 @@ const PermissionsModal = memo(function PermissionsModal({ onClose={onClose} >
+ {/* Key Name */} +
+
+

{t("keyName")}

+

{t("keyNameDesc")}

+
+
+ setKeyName(e.target.value)} + placeholder={t("keyNamePlaceholder")} + maxLength={MAX_KEY_NAME_LENGTH} + /> +
+
+ {/* Access Mode Toggle */}
-
- +
+ {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 4cc14f489f52..0755c9b95709 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 496d38c67f64..5ebcc0def1eb 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 3d12cb818f54..15b855ad8b2d 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 000000000000..bafd447d83a9 --- /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 d4af48798ec9..da02692bc11b 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 8c7c64eeebf0..48e677fabca8 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 5cefea5c4de6..10868deb0f77 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 000000000000..8b0b10a8cd2a --- /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 000000000000..b977a3a363f7 --- /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 4249b8ced228..69ff5d7a2ff5 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 000000000000..f9b8682a32fb --- /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 cbec0d957603..e7511ddb514f 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 1d359d4fce68..3d57ac02ad9e 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 07aaed5b043f..3d1e29a0a3d4 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")} - - ) : null} -
- ); - } - - if (column.key === "durationMs") { - return ( - - {row.durationMs != null ? `${row.durationMs}ms` : "—"} - + {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); - if (column.key === "details") { return ( - - {getResultDetails(row as EvalResult, t)} - +
+ + + {isResultExpanded && ( +
+
+

+ {t("expectedOutputLabel")} +

+
+                                              {expectedOutput}
+                                            
+
+
+

+ {t("actualOutputLabel")} +

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

+ {result.error} +

+ ) : null} +
+
+ )} +
); - } - - 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")} - /> -