mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
refactor: extract CliStatusBadge component and refactor CLI tool cards
- Create shared CliStatusBadge component with support for configured, not_configured, not_installed, other, and unknown statuses - Replace inline status badge markup in ClaudeToolCard and ClineToolCard with the new reusable component - Add colored status dot indicator alongside badge text - Support batch status fallback so badges render even when cards are collapsed - Refactor model/provider selection logic in tool card configuration
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -273,21 +274,10 @@ export default function ClaudeToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{effectiveConfigStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared status badge for CLI tool cards.
|
||||
* Shows the effective config/installation status using batch data,
|
||||
* so badges are visible even when cards are collapsed.
|
||||
*/
|
||||
export default function CliStatusBadge({ effectiveConfigStatus, batchStatus }) {
|
||||
// Determine badge from effectiveConfigStatus or batchStatus
|
||||
const status = effectiveConfigStatus || batchStatus?.configStatus || null;
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
const badges = {
|
||||
configured: {
|
||||
dotClass: "bg-green-500",
|
||||
badgeClass: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
text: "Configured",
|
||||
},
|
||||
not_configured: {
|
||||
dotClass: "bg-yellow-500",
|
||||
badgeClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
text: "Not configured",
|
||||
},
|
||||
not_installed: {
|
||||
dotClass: "bg-zinc-400 dark:bg-zinc-500",
|
||||
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||
text: "Not installed",
|
||||
},
|
||||
other: {
|
||||
dotClass: "bg-blue-500",
|
||||
badgeClass: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||
text: "Custom",
|
||||
},
|
||||
unknown: {
|
||||
dotClass: "bg-zinc-400 dark:bg-zinc-500",
|
||||
badgeClass: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
|
||||
text: "Unknown",
|
||||
},
|
||||
};
|
||||
|
||||
const badge = badges[status] || badges.unknown;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full ${badge.badgeClass}`}
|
||||
>
|
||||
<span className={`size-1.5 rounded-full ${badge.dotClass}`} />
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -206,28 +207,6 @@ export default function ClineToolCard({
|
||||
setShowManualConfigModal(false);
|
||||
};
|
||||
|
||||
const renderStatusBadge = () => {
|
||||
if (!cliReady) return null;
|
||||
const badges = {
|
||||
configured: {
|
||||
class: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
text: "Connected",
|
||||
},
|
||||
not_configured: {
|
||||
class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
text: "Not configured",
|
||||
},
|
||||
other: { class: "bg-blue-500/10 text-blue-600 dark:text-blue-400", text: "Custom config" },
|
||||
};
|
||||
const badge = badges[effectiveConfigStatus];
|
||||
if (!badge) return null;
|
||||
return (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
@@ -254,7 +233,10 @@ export default function ClineToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{renderStatusBadge()}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
export default function CodexToolCard({
|
||||
tool,
|
||||
@@ -333,21 +334,10 @@ wire_api = "responses"
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{effectiveConfigStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function DefaultToolCard({
|
||||
apiKeys,
|
||||
activeProviders = [],
|
||||
cloudEnabled = false,
|
||||
batchStatus,
|
||||
}) {
|
||||
const [copiedField, setCopiedField] = useState(null);
|
||||
const [showModelModal, setShowModelModal] = useState(false);
|
||||
@@ -483,23 +484,48 @@ export default function DefaultToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{runtimeStatus && !runtimeStatus.error && (
|
||||
<span
|
||||
className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${
|
||||
runtimeStatus.reason === "not_required"
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
: runtimeStatus.installed && runtimeStatus.runnable
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400"
|
||||
}`}
|
||||
>
|
||||
{runtimeStatus.reason === "not_required"
|
||||
? "Guide"
|
||||
: runtimeStatus.installed && runtimeStatus.runnable
|
||||
? "Detected"
|
||||
: "Not ready"}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
// Use runtime status if available (after expanding), otherwise use batch status
|
||||
const rs = runtimeStatus;
|
||||
const bs = batchStatus;
|
||||
const isGuide = rs?.reason === "not_required" || tool.configType === "guide";
|
||||
const isDetected = rs ? rs.installed && rs.runnable : bs?.installed && bs?.runnable;
|
||||
const isInstalled = rs ? rs.installed : bs?.installed;
|
||||
|
||||
if (isGuide) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
||||
<span className="size-1.5 rounded-full bg-blue-500" />
|
||||
Guide
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isDetected) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
|
||||
<span className="size-1.5 rounded-full bg-green-500" />
|
||||
Detected
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isInstalled === false && (rs || bs)) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-zinc-500/10 text-zinc-500 dark:text-zinc-400">
|
||||
<span className="size-1.5 rounded-full bg-zinc-400 dark:bg-zinc-500" />
|
||||
Not installed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (isInstalled && !isDetected && (rs || bs)) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 text-[11px] font-medium rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
|
||||
<span className="size-1.5 rounded-full bg-yellow-500" />
|
||||
Not ready
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -266,21 +267,10 @@ export default function DroidToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{effectiveConfigStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -192,27 +193,6 @@ export default function KiloToolCard({
|
||||
setShowManualConfigModal(false);
|
||||
};
|
||||
|
||||
const renderStatusBadge = () => {
|
||||
if (!cliReady) return null;
|
||||
const badges = {
|
||||
configured: {
|
||||
class: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||
text: "Connected",
|
||||
},
|
||||
not_configured: {
|
||||
class: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
|
||||
text: "Not configured",
|
||||
},
|
||||
};
|
||||
const badge = badges[effectiveConfigStatus];
|
||||
if (!badge) return null;
|
||||
return (
|
||||
<span className={`px-1.5 py-0.5 text-[10px] font-medium rounded-full ${badge.class}`}>
|
||||
{badge.text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
@@ -239,7 +219,10 @@ export default function KiloToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{renderStatusBadge()}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
import CliStatusBadge from "./CliStatusBadge";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -270,21 +271,10 @@ export default function OpenClawToolCard({
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{effectiveConfigStatus === "configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">
|
||||
Connected
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "not_configured" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">
|
||||
Not configured
|
||||
</span>
|
||||
)}
|
||||
{effectiveConfigStatus === "other" && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">
|
||||
Other
|
||||
</span>
|
||||
)}
|
||||
<CliStatusBadge
|
||||
effectiveConfigStatus={effectiveConfigStatus}
|
||||
batchStatus={batchStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
|
||||
@@ -176,38 +176,33 @@ export default function APIPageClient({ machineId }) {
|
||||
setModalSuccess(false);
|
||||
setSyncStep("syncing");
|
||||
try {
|
||||
const { ok, data } = await postCloudAction("enable");
|
||||
const { ok, status, data } = await postCloudAction("enable");
|
||||
if (ok) {
|
||||
setSyncStep("verifying");
|
||||
|
||||
// Brief delay so user sees the verifying step
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
|
||||
if (data.verified) {
|
||||
setCloudEnabled(true);
|
||||
setSyncStep("done");
|
||||
setModalSuccess(true);
|
||||
setCloudSyncing(false);
|
||||
dispatchCloudChange();
|
||||
// Sync succeeded — mark as enabled regardless of verify result
|
||||
setCloudEnabled(true);
|
||||
setSyncStep("done");
|
||||
setModalSuccess(true);
|
||||
setCloudSyncing(false);
|
||||
dispatchCloudChange();
|
||||
|
||||
// Show success in modal for a moment, then close
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setShowCloudModal(false);
|
||||
setModalSuccess(false);
|
||||
// Show success in modal for a moment, then close
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setShowCloudModal(false);
|
||||
setModalSuccess(false);
|
||||
|
||||
if (data.verified) {
|
||||
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
|
||||
} else {
|
||||
setCloudEnabled(true);
|
||||
setSyncStep("done");
|
||||
setModalSuccess(true);
|
||||
setCloudSyncing(false);
|
||||
dispatchCloudChange();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
setShowCloudModal(false);
|
||||
setModalSuccess(false);
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: data.verifyError || "Connected but verification pending",
|
||||
message: data.verifyError
|
||||
? `Connected — verification pending: ${data.verifyError}`
|
||||
: "Connected — verification pending",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -218,10 +213,18 @@ export default function APIPageClient({ machineId }) {
|
||||
// Reload settings to ensure fresh state
|
||||
await loadCloudSettings();
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" });
|
||||
// Sync failed — provide a helpful error message
|
||||
let errorMessage = data.error || "Failed to enable cloud";
|
||||
if (status === 502 || status === 408) {
|
||||
errorMessage =
|
||||
"Could not reach cloud worker. Make sure the cloud service is running (npm run dev in /cloud).";
|
||||
}
|
||||
setCloudStatus({ type: "error", message: errorMessage });
|
||||
setShowCloudModal(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setCloudStatus({ type: "error", message: error.message });
|
||||
setCloudStatus({ type: "error", message: error.message || "Connection failed" });
|
||||
setShowCloudModal(false);
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
setSyncStep("");
|
||||
|
||||
@@ -27,7 +27,7 @@ export default function TranslatorPageClient() {
|
||||
Translator Playground
|
||||
</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Debug, test, and visualize API format translations
|
||||
Debug, test, and visualize how OmniRoute translates API requests between providers
|
||||
</p>
|
||||
</div>
|
||||
<SegmentedControl options={MODES} value={mode} onChange={setMode} size="md" />
|
||||
|
||||
@@ -3,11 +3,8 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../hooks/useAvailableModels";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
@@ -16,20 +13,18 @@ const Editor = dynamic(() => import("@monaco-editor/react"), { ssr: false });
|
||||
* Chat Tester Mode:
|
||||
* - Left: Chat interface (send messages as a specific client format)
|
||||
* - Right: Pipeline visualization showing each translation step
|
||||
*
|
||||
* How it works:
|
||||
* 1. You type a message and select a "Client Format" (how the request is structured)
|
||||
* 2. The message is built into a request body matching the client format
|
||||
* 3. OmniRoute detects the format, translates it through the pipeline, and sends to the provider
|
||||
* 4. Each pipeline step is shown on the right: Client → Detect → OpenAI → Provider → Response
|
||||
*/
|
||||
const DEFAULT_MODELS = {
|
||||
openai: "gpt-4o",
|
||||
claude: "claude-sonnet-4-20250514",
|
||||
gemini: "gemini-2.5-flash",
|
||||
"openai-responses": "gpt-4o",
|
||||
};
|
||||
|
||||
export default function ChatTesterMode() {
|
||||
const [provider, setProvider] = useState("openai");
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [clientFormat, setClientFormat] = useState("openai");
|
||||
const [model, setModel] = useState(DEFAULT_MODELS.openai);
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const [message, setMessage] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [chatHistory, setChatHistory] = useState([]);
|
||||
@@ -37,79 +32,11 @@ export default function ChatTesterMode() {
|
||||
const [expandedStep, setExpandedStep] = useState(null);
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// Update default model when client format changes
|
||||
// Pick a smart default model when format changes or models finish loading
|
||||
useEffect(() => {
|
||||
setModel(DEFAULT_MODELS[clientFormat] || "gpt-4o");
|
||||
}, [clientFormat]);
|
||||
|
||||
// Load available models
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/models");
|
||||
const data = await res.json();
|
||||
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
|
||||
setAvailableModels(models);
|
||||
} catch {
|
||||
setAvailableModels([]);
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
// Load providers
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const [connRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/provider-nodes"),
|
||||
]);
|
||||
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
|
||||
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
|
||||
const activeProviders = new Set(
|
||||
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
|
||||
);
|
||||
const options = [...activeProviders]
|
||||
.map((pid) => {
|
||||
const info = AI_PROVIDERS[pid];
|
||||
const node = nodeMap.get(pid);
|
||||
let label = info?.name || node?.name || pid;
|
||||
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "OpenAI Compatible";
|
||||
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "Anthropic Compatible";
|
||||
return { value: pid, label };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const nextOptions =
|
||||
options.length > 0
|
||||
? options
|
||||
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
|
||||
setProviderOptions(nextOptions);
|
||||
if (nextOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
|
||||
value: id,
|
||||
label: info.name,
|
||||
}));
|
||||
setProviderOptions(fallbackOptions);
|
||||
if (fallbackOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
fallbackOptions.some((opt) => opt.value === current)
|
||||
? current
|
||||
: fallbackOptions[0].value
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, []);
|
||||
const picked = pickModelForFormat(clientFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [clientFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -170,6 +97,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 1,
|
||||
name: "Client Request",
|
||||
description: "The request body as your client would send it",
|
||||
format: clientFormat,
|
||||
content: JSON.stringify(clientRequest, null, 2),
|
||||
status: "done",
|
||||
@@ -187,6 +115,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 2,
|
||||
name: "Format Detected",
|
||||
description: "OmniRoute auto-detects the API format from the request structure",
|
||||
format: detectedFormat,
|
||||
content: JSON.stringify(
|
||||
{ detectedFormat, clientFormat, match: detectedFormat === clientFormat },
|
||||
@@ -212,6 +141,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 3,
|
||||
name: "OpenAI Intermediate",
|
||||
description: "All formats are first normalized to OpenAI format (the universal bridge)",
|
||||
format: "openai",
|
||||
content: JSON.stringify(toOpenaiData.result || toOpenaiData, null, 2),
|
||||
status: toOpenaiData.success ? "done" : "error",
|
||||
@@ -234,12 +164,13 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 4,
|
||||
name: "Provider Format",
|
||||
description: `OpenAI format is translated to the provider's native format`,
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(providerTargetData.result || providerTargetData, null, 2),
|
||||
status: providerTargetData.success ? "done" : "error",
|
||||
});
|
||||
|
||||
// Step 5: Send to provider (use the OpenAI intermediate since the proxy handles translation)
|
||||
// Step 5: Send to provider
|
||||
const sendRes = await fetch("/api/translator/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -251,6 +182,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: "Provider Response",
|
||||
description: "The raw response from the provider API",
|
||||
format: targetFmt,
|
||||
content: JSON.stringify(errData, null, 2),
|
||||
status: "error",
|
||||
@@ -274,6 +206,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: 5,
|
||||
name: "Provider Response",
|
||||
description: "The raw SSE stream from the provider API",
|
||||
format: targetFmt,
|
||||
content:
|
||||
fullResponse.slice(0, 5000) + (fullResponse.length > 5000 ? "\n... (truncated)" : ""),
|
||||
@@ -291,6 +224,7 @@ export default function ChatTesterMode() {
|
||||
steps.push({
|
||||
id: steps.length + 1,
|
||||
name: "Error",
|
||||
description: "An unexpected error occurred",
|
||||
format: "error",
|
||||
content: JSON.stringify({ error: err.message }, null, 2),
|
||||
status: "error",
|
||||
@@ -305,229 +239,269 @@ export default function ChatTesterMode() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Chat Interface */}
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Client Format
|
||||
</label>
|
||||
<Select
|
||||
value={clientFormat}
|
||||
onChange={(e) => setClientFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS.filter((o) =>
|
||||
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="model-suggestions"
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<Card className="min-h-[400px] flex flex-col">
|
||||
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
|
||||
{chatHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">chat</span>
|
||||
<p className="text-sm">Send a message to see the translation pipeline</p>
|
||||
</div>
|
||||
)}
|
||||
{chatHistory.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/10 text-text-main border border-primary/20"
|
||||
: "bg-bg-subtle text-text-main border border-border"
|
||||
}`}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
|
||||
{msg.role === "user"
|
||||
? `You (${FORMAT_META[clientFormat]?.label})`
|
||||
: "Assistant"}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Pipeline Debugger</p>
|
||||
<p>
|
||||
Send messages as a specific client format and see how each step of the translation
|
||||
pipeline works. The right panel shows the full flow:{" "}
|
||||
<strong className="text-text-main">
|
||||
Client Request → Format Detection → OpenAI Intermediate → Provider Format → Response
|
||||
</strong>
|
||||
. Click any step to inspect the data at that stage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Pipeline Visualization */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
account_tree
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Click on any step to inspect the data</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!pipeline ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Left: Chat Interface */}
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
account_tree
|
||||
</span>
|
||||
<p className="text-sm">Send a message to see the pipeline</p>
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Client Format
|
||||
</label>
|
||||
<Select
|
||||
value={clientFormat}
|
||||
onChange={(e) => setClientFormat(e.target.value)}
|
||||
options={FORMAT_OPTIONS.filter((o) =>
|
||||
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Provider
|
||||
</label>
|
||||
<Select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
options={providerOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-text-muted mb-1 uppercase tracking-wider">
|
||||
Model
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="model-suggestions"
|
||||
placeholder="Select or type a model name..."
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="model-suggestions">
|
||||
{availableModels.map((m) => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pipeline.map((step, i) => {
|
||||
const meta = FORMAT_META[step.format] || {
|
||||
label: step.format,
|
||||
color: "gray",
|
||||
icon: "code",
|
||||
};
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{/* Connector line */}
|
||||
{i > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={
|
||||
step.status === "error"
|
||||
? "border-red-500/30"
|
||||
: isExpanded
|
||||
? "border-primary/30"
|
||||
: ""
|
||||
}
|
||||
{/* Chat Messages */}
|
||||
<Card className="min-h-[400px] flex flex-col">
|
||||
<div className="p-4 flex-1 overflow-y-auto max-h-[500px] space-y-3">
|
||||
{chatHistory.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-muted py-12">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
chat
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">
|
||||
Send a message to see the translation pipeline
|
||||
</p>
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
Your message will be formatted as a{" "}
|
||||
<strong>{FORMAT_META[clientFormat]?.label}</strong> request, translated through
|
||||
the pipeline, and sent to the selected provider.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{chatHistory.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/10 text-text-main border border-primary/20"
|
||||
: "bg-bg-subtle text-text-main border border-border"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="w-full p-3 flex items-center gap-3 text-left"
|
||||
>
|
||||
{/* Step number */}
|
||||
<div
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
|
||||
step.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: step.status === "done"
|
||||
? `bg-${meta.color}-500/10 text-${meta.color}-500`
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.status === "error" ? "!" : step.id}
|
||||
</div>
|
||||
<p className="text-[10px] font-semibold text-text-muted mb-1 uppercase">
|
||||
{msg.role === "user"
|
||||
? `You (${FORMAT_META[clientFormat]?.label})`
|
||||
: "Assistant"}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Step info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main">{step.name}</p>
|
||||
</div>
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
icon="send"
|
||||
onClick={handleSend}
|
||||
loading={sending}
|
||||
disabled={!message.trim() || sending}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{/* Right: Pipeline Visualization */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<div className="p-4 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">
|
||||
account_tree
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-text-main">Translation Pipeline</h3>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Click on any step to inspect the data at that stage
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Expand icon */}
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{isExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
{!pipeline ? (
|
||||
<Card>
|
||||
<div className="p-8 flex flex-col items-center justify-center text-text-muted">
|
||||
<span className="material-symbols-outlined text-[48px] mb-3 opacity-30">
|
||||
account_tree
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">Pipeline visualization</p>
|
||||
<p className="text-xs text-center max-w-xs">
|
||||
Send a message to see how your request flows through detection → translation →
|
||||
provider call.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{pipeline.map((step, i) => {
|
||||
const meta = FORMAT_META[step.format] || {
|
||||
label: step.format,
|
||||
color: "gray",
|
||||
icon: "code",
|
||||
};
|
||||
const isExpanded = expandedStep === step.id;
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="250px"
|
||||
defaultLanguage="json"
|
||||
value={step.content}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div key={step.id}>
|
||||
{/* Connector line */}
|
||||
{i > 0 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="w-px h-4 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
className={
|
||||
step.status === "error"
|
||||
? "border-red-500/30"
|
||||
: isExpanded
|
||||
? "border-primary/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedStep(isExpanded ? null : step.id)}
|
||||
className="w-full p-3 flex items-center gap-3 text-left"
|
||||
>
|
||||
{/* Step number */}
|
||||
<div
|
||||
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold ${
|
||||
step.status === "error"
|
||||
? "bg-red-500/10 text-red-500"
|
||||
: step.status === "done"
|
||||
? `bg-${meta.color}-500/10 text-${meta.color}-500`
|
||||
: "bg-bg-subtle text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{step.status === "error" ? "!" : step.id}
|
||||
</div>
|
||||
|
||||
{/* Step info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-text-main">{step.name}</p>
|
||||
{step.description && (
|
||||
<p className="text-[10px] text-text-muted truncate">
|
||||
{step.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Format badge */}
|
||||
<Badge variant={step.status === "error" ? "error" : "default"} size="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
|
||||
{/* Expand icon */}
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">
|
||||
{isExpanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<Editor
|
||||
height="250px"
|
||||
defaultLanguage="json"
|
||||
value={step.content}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,6 +49,23 @@ export default function LiveMonitorMode() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Real-Time Translation Activity</p>
|
||||
<p>
|
||||
Shows translation events as API calls flow through OmniRoute. Events come from the
|
||||
in-memory buffer (resets on restart). Use{" "}
|
||||
<strong className="text-text-main">Chat Tester</strong>,{" "}
|
||||
<strong className="text-text-main">Test Bench</strong>, or external API calls to
|
||||
generate events.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard icon="translate" label="Total Translations" value={events.length} color="blue" />
|
||||
@@ -99,11 +116,26 @@ export default function LiveMonitorMode() {
|
||||
monitoring
|
||||
</span>
|
||||
<p className="text-sm font-medium mb-1">No translations yet</p>
|
||||
<p className="text-xs">
|
||||
Translations will appear here as requests flow through the proxy.
|
||||
<p className="text-xs text-center max-w-sm">
|
||||
Translation events appear here as requests flow through OmniRoute. Use any of these
|
||||
methods to generate events:
|
||||
</p>
|
||||
<p className="text-xs mt-2">
|
||||
Make API calls to your OmniRoute endpoints to see live translation data.
|
||||
<div className="flex flex-wrap gap-2 mt-3 text-xs">
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
Chat Tester tab
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
Test Bench tab
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
External API calls
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-md bg-bg-subtle border border-border">
|
||||
IDE/CLI integrations
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] mt-3 text-text-muted/70">
|
||||
Note: Events are stored in-memory and reset when the server restarts.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -65,12 +65,6 @@ export default function PlaygroundMode() {
|
||||
step: "direct",
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
provider:
|
||||
targetFormat === "claude"
|
||||
? "anthropic"
|
||||
: targetFormat === "gemini"
|
||||
? "google"
|
||||
: "openai",
|
||||
body: parsed,
|
||||
}),
|
||||
});
|
||||
@@ -114,6 +108,20 @@ export default function PlaygroundMode() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Format Converter</p>
|
||||
<p>
|
||||
Paste or type a JSON request body. The translator will auto-detect the source format and
|
||||
convert it to the target format. Use this to debug how OmniRoute translates requests
|
||||
between formats (OpenAI ↔ Claude ↔ Gemini ↔ Responses API).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Format Controls Bar */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col sm:flex-row items-center gap-4">
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Select, Badge } from "@/shared/components";
|
||||
import { EXAMPLE_TEMPLATES, FORMAT_META } from "../exampleTemplates";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
import { EXAMPLE_TEMPLATES, FORMAT_META, FORMAT_OPTIONS } from "../exampleTemplates";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import { useAvailableModels } from "../hooks/useAvailableModels";
|
||||
|
||||
/**
|
||||
* Test Bench Mode:
|
||||
* Run translation + send scenarios between providers to validate compatibility.
|
||||
*
|
||||
* How it works:
|
||||
* Predefined scenarios (Simple Chat, Tool Calling, etc.) are loaded from example templates,
|
||||
* translated from the source format to the target provider, and sent to the provider API.
|
||||
* Results show pass/fail, latency, and chunk count, with a compatibility percentage.
|
||||
*/
|
||||
|
||||
const SCENARIOS = [
|
||||
@@ -23,92 +25,18 @@ const SCENARIOS = [
|
||||
{ id: "streaming", name: "Streaming", icon: "stream", templateId: "streaming" },
|
||||
];
|
||||
|
||||
const DEFAULT_MODELS = {
|
||||
openai: "gpt-4o",
|
||||
claude: "claude-sonnet-4-20250514",
|
||||
gemini: "gemini-2.5-flash",
|
||||
};
|
||||
|
||||
export default function TestBenchMode() {
|
||||
const [sourceFormat, setSourceFormat] = useState("claude");
|
||||
const [provider, setProvider] = useState("openai");
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const [model, setModel] = useState(DEFAULT_MODELS.claude);
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
const { model, setModel, availableModels, pickModelForFormat } = useAvailableModels();
|
||||
const [results, setResults] = useState({});
|
||||
const [runningAll, setRunningAll] = useState(false);
|
||||
|
||||
// Update default model when source format changes
|
||||
// Pick a smart default model when source format changes or models finish loading
|
||||
useEffect(() => {
|
||||
setModel(DEFAULT_MODELS[sourceFormat] || "gpt-4o");
|
||||
}, [sourceFormat]);
|
||||
|
||||
// Load available models
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/models");
|
||||
const data = await res.json();
|
||||
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
|
||||
setAvailableModels(models);
|
||||
} catch {
|
||||
setAvailableModels([]);
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const [connRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/provider-nodes"),
|
||||
]);
|
||||
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
|
||||
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
|
||||
const activeProviders = new Set(
|
||||
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
|
||||
);
|
||||
const options = [...activeProviders]
|
||||
.map((pid) => {
|
||||
const info = AI_PROVIDERS[pid];
|
||||
const node = nodeMap.get(pid);
|
||||
let label = info?.name || node?.name || pid;
|
||||
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "OpenAI Compatible";
|
||||
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "Anthropic Compatible";
|
||||
return { value: pid, label };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
const nextOptions =
|
||||
options.length > 0
|
||||
? options
|
||||
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
|
||||
setProviderOptions(nextOptions);
|
||||
if (nextOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
|
||||
value: id,
|
||||
label: info.name,
|
||||
}));
|
||||
setProviderOptions(fallbackOptions);
|
||||
if (fallbackOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
fallbackOptions.some((opt) => opt.value === current)
|
||||
? current
|
||||
: fallbackOptions[0].value
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, []);
|
||||
const picked = pickModelForFormat(sourceFormat);
|
||||
if (picked) setModel(picked);
|
||||
}, [sourceFormat, pickModelForFormat, setModel]);
|
||||
|
||||
const runScenario = async (scenario) => {
|
||||
setResults((prev) => ({ ...prev, [scenario.id]: { status: "running" } }));
|
||||
@@ -213,6 +141,22 @@ export default function TestBenchMode() {
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Info Banner */}
|
||||
<div className="flex items-start gap-3 px-4 py-3 rounded-lg bg-primary/5 border border-primary/10 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined text-primary text-[20px] mt-0.5 shrink-0">
|
||||
info
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-medium text-text-main mb-0.5">Compatibility Tester</p>
|
||||
<p>
|
||||
Run predefined scenarios (Simple Chat, Tool Calling, etc.) to verify translation and
|
||||
provider compatibility. Select a source format and target provider, then run all tests
|
||||
to see a compatibility percentage. Use this to find which features work across
|
||||
providers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<Card>
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
@@ -227,11 +171,9 @@ export default function TestBenchMode() {
|
||||
setSourceFormat(e.target.value);
|
||||
setResults({});
|
||||
}}
|
||||
options={[
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
]}
|
||||
options={FORMAT_OPTIONS.filter((o) =>
|
||||
["openai", "claude", "gemini", "openai-responses"].includes(o.value)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center px-2">
|
||||
@@ -271,7 +213,7 @@ export default function TestBenchMode() {
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
list="testbench-model-suggestions"
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4-20250514"
|
||||
placeholder="Select or type a model name..."
|
||||
className="w-full bg-bg-subtle border border-border rounded-lg px-3 py-2 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary transition-colors"
|
||||
/>
|
||||
<datalist id="testbench-model-suggestions">
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Prefix-based format→model matching, used to pick a smart default
|
||||
* model from the available models list when the user changes format.
|
||||
*/
|
||||
const FORMAT_MODEL_PREFIXES = {
|
||||
openai: ["gpt-", "o1-", "o3-", "o4-"],
|
||||
"openai-responses": ["gpt-", "o1-", "o3-", "o4-"],
|
||||
claude: ["claude-"],
|
||||
gemini: ["gemini-"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch available models and provide smart default selection.
|
||||
*
|
||||
* @returns {{
|
||||
* model: string,
|
||||
* setModel: Function,
|
||||
* availableModels: string[],
|
||||
* loading: boolean,
|
||||
* pickModelForFormat: (format: string) => string
|
||||
* }}
|
||||
*/
|
||||
export function useAvailableModels() {
|
||||
const [model, setModel] = useState("");
|
||||
const [availableModels, setAvailableModels] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/v1/models");
|
||||
const data = await res.json();
|
||||
const models = (data.data || []).map((m) => m.id).sort((a, b) => a.localeCompare(b));
|
||||
setAvailableModels(models);
|
||||
} catch {
|
||||
setAvailableModels([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchModels();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Pick the best model for a given format from the available models.
|
||||
* Returns the first model matching the format prefixes, or the first available model.
|
||||
*/
|
||||
const pickModelForFormat = useCallback(
|
||||
(format) => {
|
||||
if (availableModels.length === 0) return "";
|
||||
const prefixes = FORMAT_MODEL_PREFIXES[format] || [];
|
||||
for (const prefix of prefixes) {
|
||||
const match = availableModels.find((m) => m.startsWith(prefix));
|
||||
if (match) return match;
|
||||
}
|
||||
return availableModels[0];
|
||||
},
|
||||
[availableModels]
|
||||
);
|
||||
|
||||
return { model, setModel, availableModels, loading, pickModelForFormat };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
OPENAI_COMPATIBLE_PREFIX,
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage provider options for the Translator tools.
|
||||
* Fetches active providers from the API and builds a sorted list of options.
|
||||
* Falls back to the static AI_PROVIDERS list if the API is unreachable.
|
||||
*
|
||||
* @param {string} [initialProvider="openai"] - Initial provider value
|
||||
* @returns {{ provider: string, setProvider: Function, providerOptions: Array<{value: string, label: string}>, loading: boolean }}
|
||||
*/
|
||||
export function useProviderOptions(initialProvider = "openai") {
|
||||
const [provider, setProvider] = useState(initialProvider);
|
||||
const [providerOptions, setProviderOptions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const [connRes, nodesRes] = await Promise.all([
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/provider-nodes"),
|
||||
]);
|
||||
const [connData, nodesData] = await Promise.all([connRes.json(), nodesRes.json()]);
|
||||
const nodeMap = new Map((nodesData.nodes || []).map((n) => [n.id, n]));
|
||||
const activeProviders = new Set(
|
||||
(connData.connections || []).filter((c) => c.isActive !== false).map((c) => c.provider)
|
||||
);
|
||||
const options = [...activeProviders]
|
||||
.map((pid) => {
|
||||
const info = AI_PROVIDERS[pid];
|
||||
const node = nodeMap.get(pid);
|
||||
let label = info?.name || node?.name || pid;
|
||||
if (!info && pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "OpenAI Compatible";
|
||||
if (!info && pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
||||
label = node?.name || "Anthropic Compatible";
|
||||
return { value: pid, label };
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const nextOptions =
|
||||
options.length > 0
|
||||
? options
|
||||
: Object.entries(AI_PROVIDERS).map(([id, info]) => ({ value: id, label: info.name }));
|
||||
setProviderOptions(nextOptions);
|
||||
if (nextOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
nextOptions.some((opt) => opt.value === current) ? current : nextOptions[0].value
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
const fallbackOptions = Object.entries(AI_PROVIDERS).map(([id, info]) => ({
|
||||
value: id,
|
||||
label: info.name,
|
||||
}));
|
||||
setProviderOptions(fallbackOptions);
|
||||
if (fallbackOptions.length > 0) {
|
||||
setProvider((current) =>
|
||||
fallbackOptions.some((opt) => opt.value === current)
|
||||
? current
|
||||
: fallbackOptions[0].value
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchProviders();
|
||||
}, []);
|
||||
|
||||
return { provider, setProvider, providerOptions, loading };
|
||||
}
|
||||
@@ -66,15 +66,22 @@ export async function POST(request) {
|
||||
const machineId = await getConsistentMachineId();
|
||||
|
||||
switch (action) {
|
||||
case "enable":
|
||||
await updateSettings({ cloudEnabled: true });
|
||||
// Auto create key if none exists
|
||||
case "enable": {
|
||||
// Auto create key if none exists (before sync, so it's included in sync data)
|
||||
const keys = await getApiKeys();
|
||||
let createdKey = null;
|
||||
if (keys.length === 0) {
|
||||
createdKey = await createApiKey("Default Key", machineId);
|
||||
}
|
||||
return syncAndVerify(machineId, createdKey?.key, keys);
|
||||
// Sync first — only enable if sync succeeds
|
||||
const enableResult = await syncAndVerify(machineId, createdKey?.key, keys);
|
||||
const enableBody = await enableResult.clone().json().catch(() => ({}));
|
||||
// Only persist cloudEnabled if sync succeeded (body.success exists)
|
||||
if (enableBody.success) {
|
||||
await updateSettings({ cloudEnabled: true });
|
||||
}
|
||||
return enableResult;
|
||||
}
|
||||
case "sync": {
|
||||
const syncResult = await syncToCloud(machineId);
|
||||
if (syncResult.error) {
|
||||
@@ -95,16 +102,19 @@ export async function POST(request) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync and verify connection with ping
|
||||
* Sync and verify connection with ping (retry on verify)
|
||||
*/
|
||||
async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
// Step 1: Sync data to cloud
|
||||
const syncResult = await syncToCloud(machineId, createdKey);
|
||||
if (syncResult.error) {
|
||||
return NextResponse.json(syncResult, { status: 502 });
|
||||
return NextResponse.json(
|
||||
{ error: `Cloud sync failed: ${syncResult.error}` },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Verify connection by pinging the cloud
|
||||
// Step 2: Verify connection by pinging the cloud (with retry)
|
||||
const apiKey = createdKey || existingKeys[0]?.key;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({
|
||||
@@ -114,34 +124,48 @@ async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
// Retry verify up to 2 times with a delay (cloud may need a moment after sync)
|
||||
const MAX_VERIFY_ATTEMPTS = 2;
|
||||
const VERIFY_RETRY_DELAY_MS = 1500;
|
||||
let lastVerifyError = null;
|
||||
|
||||
if (pingResponse.ok) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: true,
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: `Ping failed: ${pingResponse.status}`,
|
||||
});
|
||||
for (let attempt = 1; attempt <= MAX_VERIFY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const pingResponse = await fetchWithTimeout(
|
||||
`${CLOUD_URL}/${machineId}/v1/verify`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
5000
|
||||
);
|
||||
|
||||
if (pingResponse.ok) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: true,
|
||||
});
|
||||
}
|
||||
lastVerifyError = `Ping failed: ${pingResponse.status}`;
|
||||
} catch (error) {
|
||||
lastVerifyError = error?.name === "AbortError" ? "Verify timeout" : error.message;
|
||||
}
|
||||
|
||||
// Wait before retry (except on last attempt)
|
||||
if (attempt < MAX_VERIFY_ATTEMPTS) {
|
||||
await new Promise((r) => setTimeout(r, VERIFY_RETRY_DELAY_MS));
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Sync succeeded but verify failed — still return success with warning
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: lastVerifyError || "Verification failed after retries",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import { calculateCost } from "@/lib/usageDb.js";
|
||||
|
||||
/**
|
||||
* Compute date range boundaries
|
||||
* @param {string} range - "7d" | "30d" | "90d" | "ytd" | "all"
|
||||
* @param {string} range - "1d" | "7d" | "30d" | "90d" | "ytd" | "all"
|
||||
* @returns {{ start: Date, end: Date }}
|
||||
*/
|
||||
function getDateRange(range) {
|
||||
@@ -17,6 +17,10 @@ function getDateRange(range) {
|
||||
let start;
|
||||
|
||||
switch (range) {
|
||||
case "1d":
|
||||
start = new Date(end);
|
||||
start.setDate(start.getDate() - 1);
|
||||
break;
|
||||
case "7d":
|
||||
start = new Date(end);
|
||||
start.setDate(start.getDate() - 7);
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function UsageAnalytics() {
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
const ranges = [
|
||||
{ value: "1d", label: "1D" },
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "90d", label: "90D" },
|
||||
|
||||
Reference in New Issue
Block a user