feat(dashboard): chat-first test slide-over (Option A) (#2660)

Integrated into release/v3.8.3
This commit is contained in:
M.M
2026-05-24 19:46:51 +02:00
committed by GitHub
parent 4495a43908
commit 0f6daf4386
2 changed files with 466 additions and 189 deletions

View File

@@ -1,6 +1,13 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import {
useState,
useEffect,
useRef,
useCallback,
useImperativeHandle,
type RefObject,
} from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/shared/utils/cn";
import { useApiKey } from "../../providers/hooks/useApiKey";
@@ -20,10 +27,23 @@ interface Stats {
latencyMs: number;
}
export interface LlmChatControls {
clear: () => void;
hasMessages: boolean;
streaming: boolean;
}
interface Props {
providerId: string;
initialModel?: string;
embedded?: boolean;
hideToolbar?: boolean;
model?: string;
onModelChange?: (model: string) => void;
selectedKey?: string;
onSelectedKeyChange?: (key: string) => void;
controlsRef?: RefObject<LlmChatControls | null>;
onControlsChange?: (controls: LlmChatControls) => void;
}
function extractDeltaContent(line: string): string {
@@ -60,22 +80,63 @@ function extractUsage(line: string): { prompt_tokens?: number; completion_tokens
}
}
export function LlmChatCard({ providerId, initialModel, embedded = false }: Props) {
export function LlmChatCard({
providerId,
initialModel,
embedded = false,
hideToolbar = false,
model: modelProp,
onModelChange,
selectedKey: selectedKeyProp,
onSelectedKeyChange,
controlsRef,
onControlsChange,
}: Props) {
const t = useTranslations("miniPlayground");
const { apiKey, keys } = useApiKey();
const { models } = useProviderModels(providerId);
const [selectedKey, setSelectedKey] = useState<string>("");
const [model, setModel] = useState<string>(initialModel ?? "");
const [internalSelectedKey, setInternalSelectedKey] = useState<string>("");
const [internalModel, setInternalModel] = useState<string>(initialModel ?? "");
const selectedKey = selectedKeyProp ?? internalSelectedKey;
const setSelectedKey = useCallback(
(k: string) => {
if (onSelectedKeyChange) onSelectedKeyChange(k);
else setInternalSelectedKey(k);
},
[onSelectedKeyChange]
);
const model = modelProp ?? internalModel;
const setModel = useCallback(
(m: string) => {
if (onModelChange) onModelChange(m);
else setInternalModel(m);
},
[onModelChange]
);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState<string>("");
const [streaming, setStreaming] = useState<boolean>(false);
const [stats, setStats] = useState<Stats | null>(null);
const abortRef = useRef<AbortController | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const firstModel = models[0]?.id ?? "";
const effectiveModel = model || firstModel || initialModel || "";
// Auto-prefix model with providerId when no provider/model prefix present, to avoid
// OmniRoute "Ambiguous model" rejection when same alias is registered under multiple providers.
const qualifiedModel = effectiveModel.includes("/")
? effectiveModel
: providerId
? `${providerId}/${effectiveModel}`
: effectiveModel;
// Autofocus textarea in embedded mode
useEffect(() => {
if (embedded) textareaRef.current?.focus();
}, [embedded]);
// Auto-scroll to bottom when messages update
useEffect(() => {
@@ -95,7 +156,7 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
if (!trimmed || streaming) return;
const userMsg: Message = { role: "user", content: trimmed };
const assistantMsg: Message = { role: "assistant", content: "", model: effectiveModel };
const assistantMsg: Message = { role: "assistant", content: "", model: qualifiedModel };
setMessages((prev) => [...prev, userMsg, assistantMsg]);
setInput("");
setStreaming(true);
@@ -117,7 +178,7 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
"x-connection-id": providerId,
},
body: JSON.stringify({
model: effectiveModel,
model: qualifiedModel,
messages: [
// Include history (all except the last assistant placeholder)
...messages,
@@ -217,8 +278,10 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
} finally {
setStreaming(false);
abortRef.current = null;
// Refocus textarea so user can keep typing
requestAnimationFrame(() => textareaRef.current?.focus());
}
}, [input, streaming, selectedKey, apiKey, providerId, effectiveModel, messages]);
}, [input, streaming, selectedKey, apiKey, providerId, qualifiedModel, messages]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -231,11 +294,30 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
abortRef.current?.abort();
};
const handleClear = () => {
if (streaming) handleStop();
const handleClear = useCallback(() => {
if (streaming) abortRef.current?.abort();
setMessages([]);
setStats(null);
};
}, [streaming]);
useImperativeHandle(
controlsRef,
() => ({
clear: handleClear,
hasMessages: messages.length > 0,
streaming,
}),
[handleClear, messages.length, streaming]
);
// Notify parent of control state changes (for external toolbar)
useEffect(() => {
onControlsChange?.({
clear: handleClear,
hasMessages: messages.length > 0,
streaming,
});
}, [onControlsChange, handleClear, messages.length, streaming]);
const modelOptions = models.length > 0 ? models : initialModel ? [{ id: initialModel }] : [];
@@ -246,53 +328,55 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
embedded ? "flex-1 min-h-0" : "rounded-lg border border-border bg-bg-card p-4"
)}
>
{/* Header controls */}
<div className="flex flex-wrap items-center gap-2">
{/* Model select */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<label className="text-xs text-text-muted shrink-0">{t("model")}:</label>
<select
value={model || firstModel}
onChange={(e) => setModel(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
>
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
{modelOptions.map((m) => (
<option key={m.id} value={m.id}>
{m.id}
</option>
))}
</select>
</div>
{/* Key select */}
{keys.length > 0 && (
<div className="flex items-center gap-1.5">
<label className="text-xs text-text-muted shrink-0">{t("selectKey")}:</label>
{/* Header controls (hidden when parent renders its own toolbar) */}
{!hideToolbar && (
<div className="flex flex-wrap items-center gap-2">
{/* Model select */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<label className="text-xs text-text-muted shrink-0">{t("model")}:</label>
<select
value={selectedKey}
onChange={(e) => setSelectedKey(e.target.value)}
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
value={model || firstModel}
onChange={(e) => setModel(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">(default)</option>
{keys.map((k) => (
<option key={k.id} value={k.key}>
{k.name ?? k.id}
{modelOptions.length === 0 && <option value="">{initialModel || "—"}</option>}
{modelOptions.map((m) => (
<option key={m.id} value={m.id}>
{m.id}
</option>
))}
</select>
</div>
)}
{/* Clear button */}
{messages.length > 0 && (
<button
type="button"
onClick={handleClear}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Clear
</button>
)}
</div>
{/* Key select */}
{keys.length > 0 && (
<div className="flex items-center gap-1.5">
<label className="text-xs text-text-muted shrink-0">{t("selectKey")}:</label>
<select
value={selectedKey}
onChange={(e) => setSelectedKey(e.target.value)}
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">(default)</option>
{keys.map((k) => (
<option key={k.id} value={k.key}>
{k.name ?? k.id}
</option>
))}
</select>
</div>
)}
{/* Clear button */}
{messages.length > 0 && (
<button
type="button"
onClick={handleClear}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Clear
</button>
)}
</div>
)}
{/* Messages */}
<div
@@ -385,13 +469,13 @@ export function LlmChatCard({ providerId, initialModel, embedded = false }: Prop
{/* Input row */}
<div className="relative flex items-end gap-2 rounded-lg border border-border bg-bg-subtle px-3 py-2 focus-within:ring-1 focus-within:ring-primary focus-within:border-primary/50 transition-colors">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={`${t("send")}`}
disabled={streaming}
className="flex-1 bg-transparent text-sm py-1.5 text-text-main placeholder:text-text-muted focus:outline-none resize-none disabled:opacity-50 max-h-32"
className="flex-1 bg-transparent text-sm py-1.5 text-text-main placeholder:text-text-muted focus:outline-none resize-none max-h-32"
/>
{streaming ? (
<button

View File

@@ -1,10 +1,15 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useCallback, useEffect, useState, type ReactNode } from "react";
import Image from "next/image";
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
import {
LlmChatCard,
type LlmChatControls,
} from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { useApiKey } from "@/app/(dashboard)/dashboard/providers/hooks/useApiKey";
import { useProviderModels } from "@/app/(dashboard)/dashboard/providers/hooks/useProviderModels";
interface SlideOverProvider {
id?: string;
@@ -26,41 +31,16 @@ interface ProviderTestSlideOverProps {
initialTab?: TabKey;
}
type TabKey = "test" | "models" | "keys" | "logs";
type TabKey = "test" | "logs";
const TABS: { key: TabKey; label: string; icon: string }[] = [
{ key: "test", label: "Test", icon: "play_arrow" },
{ key: "models", label: "Models", icon: "view_list" },
{ key: "keys", label: "Keys", icon: "key" },
{ key: "logs", label: "Logs", icon: "receipt_long" },
];
export default function ProviderTestSlideOver({
isOpen,
onClose,
providerId,
provider,
staticIconPath,
initialTab = "test",
}: ProviderTestSlideOverProps) {
if (!isOpen) return null;
return (
<ProviderTestSlideOverPanel
onClose={onClose}
providerId={providerId}
provider={provider}
staticIconPath={staticIconPath}
initialTab={initialTab}
/>
);
}
interface PanelProps {
onClose: () => void;
providerId: string;
provider: SlideOverProvider;
staticIconPath?: string | null;
initialTab: TabKey;
export default function ProviderTestSlideOver(props: ProviderTestSlideOverProps) {
if (!props.isOpen) return null;
return <ProviderTestSlideOverPanel {...props} />;
}
function ProviderTestSlideOverPanel({
@@ -68,9 +48,17 @@ function ProviderTestSlideOverPanel({
providerId,
provider,
staticIconPath,
initialTab,
}: PanelProps) {
initialTab = "test",
}: ProviderTestSlideOverProps) {
const [tab, setTab] = useState<TabKey>(initialTab);
const [model, setModel] = useState<string>("");
const [selectedKey, setSelectedKey] = useState<string>("");
const [controls, setControls] = useState<LlmChatControls | null>(null);
const onControlsChange = useCallback((c: LlmChatControls) => setControls(c), []);
const { keys } = useApiKey();
const { models } = useProviderModels(providerId);
const firstModel = models[0]?.id ?? "";
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -89,6 +77,7 @@ function ProviderTestSlideOverPanel({
}, []);
const color = provider.color || "#64748b";
const modelOptions = models.length > 0 ? models : [];
return (
<div className="fixed inset-0 z-[60] flex justify-end">
@@ -109,15 +98,33 @@ function ProviderTestSlideOverPanel({
color={color}
onClose={onClose}
/>
{tab === "test" && (
<TestToolbar
model={model || firstModel}
onModelChange={setModel}
modelOptions={modelOptions}
selectedKey={selectedKey}
onSelectedKeyChange={setSelectedKey}
keys={keys}
controls={controls}
/>
)}
<SlideOverTabs tab={tab} onChange={setTab} />
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{tab === "test" && (
<div className="flex-1 min-h-0 flex flex-col pl-4 pr-2 py-3">
<LlmChatCard providerId={providerId} embedded />
<LlmChatCard
providerId={providerId}
embedded
hideToolbar
model={model}
onModelChange={setModel}
selectedKey={selectedKey}
onSelectedKeyChange={setSelectedKey}
onControlsChange={onControlsChange}
/>
</div>
)}
{tab === "models" && <ModelsTab providerId={providerId} />}
{tab === "keys" && <KeysTab provider={provider} providerId={providerId} />}
{tab === "logs" && <LogsTab providerId={providerId} />}
</div>
</div>
@@ -188,6 +195,73 @@ function SlideOverHeader({
);
}
function TestToolbar({
model,
onModelChange,
modelOptions,
selectedKey,
onSelectedKeyChange,
keys,
controls,
}: {
model: string;
onModelChange: (m: string) => void;
modelOptions: { id: string }[];
selectedKey: string;
onSelectedKeyChange: (k: string) => void;
keys: { id: string; key: string; name?: string }[];
controls: LlmChatControls | null;
}) {
const hasMessages = controls?.hasMessages ?? false;
return (
<div className="flex flex-wrap items-center gap-2 px-4 py-2 border-b border-black/5 dark:border-white/5 bg-bg-subtle/30 shrink-0">
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<label className="text-[11px] text-text-muted shrink-0">Model:</label>
<select
value={model}
onChange={(e) => onModelChange(e.target.value)}
className="min-w-0 flex-1 rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
>
{modelOptions.length === 0 && <option value=""></option>}
{modelOptions.map((m) => (
<option key={m.id} value={m.id}>
{m.id}
</option>
))}
</select>
</div>
{keys.length > 0 && (
<div className="flex items-center gap-1.5">
<label className="text-[11px] text-text-muted shrink-0">Key:</label>
<select
value={selectedKey}
onChange={(e) => onSelectedKeyChange(e.target.value)}
className="rounded-md border border-border bg-bg-subtle text-xs px-2 py-1 text-text-main focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="">(default)</option>
{keys.map((k) => (
<option key={k.id} value={k.key}>
{k.name ?? k.id}
</option>
))}
</select>
</div>
)}
{hasMessages && (
<button
type="button"
onClick={() => controls?.clear()}
className="text-[11px] text-text-muted hover:text-text-main transition-colors flex items-center gap-1"
title="Clear conversation"
>
<span className="material-symbols-outlined text-[14px]">delete_sweep</span>
Clear
</button>
)}
</div>
);
}
function SlideOverTabs({ tab, onChange }: { tab: TabKey; onChange: (next: TabKey) => void }) {
return (
<div
@@ -234,134 +308,253 @@ function TabPlaceholder({ icon, title, body }: { icon: string; title: string; bo
);
}
interface ModelEntry {
id: string;
displayId?: string;
owned_by?: string;
interface LogEntry {
id: string | number;
timestamp: string;
model?: string;
requestedModel?: string;
provider?: string;
status?: number;
duration?: number;
tokens?: { in?: number; out?: number };
account?: string;
apiKey?: string;
apiKeyName?: string;
apiKeyId?: string;
}
type ModelsState =
function formatRequester(log: LogEntry): { label: string; title: string } {
const name = log.apiKeyName || log.account;
const keyHint = log.apiKey || log.apiKeyId;
const masked =
typeof keyHint === "string" && keyHint.length > 8
? `${keyHint.slice(0, 4)}${keyHint.slice(-4)}`
: keyHint || "";
if (name && masked) return { label: name, title: `${name} (${masked})` };
if (name) return { label: name, title: name };
if (masked) return { label: masked, title: keyHint || masked };
return { label: "—", title: "unknown requester" };
}
type LogsState =
| { status: "loading" }
| { status: "ready"; models: ModelEntry[] }
| { status: "ready"; logs: LogEntry[] }
| { status: "error"; message: string };
function ModelsTab({ providerId }: { providerId: string }) {
const [state, setState] = useState<ModelsState>({ status: "loading" });
function LogsTab({ providerId }: { providerId: string }) {
const [state, setState] = useState<LogsState>({ status: "loading" });
const [expanded, setExpanded] = useState<string | null>(null);
const [refreshTick, setRefreshTick] = useState(0);
useEffect(() => {
let cancelled = false;
queueMicrotask(() => {
if (cancelled) return;
setState({ status: "loading" });
});
fetch(`/api/v1/providers/${encodeURIComponent(providerId)}/models`)
.then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as { data?: ModelEntry[] };
if (!cancelled) setState({ status: "ready", models: data.data ?? [] });
})
.catch((e: unknown) => {
if (!cancelled) {
setState({ status: "error", message: e instanceof Error ? e.message : "Failed to load" });
}
});
const ctrl = new AbortController();
async function load() {
try {
const url = `/api/usage/call-logs?provider=${encodeURIComponent(providerId)}&limit=20`;
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (cancelled) return;
const logs: LogEntry[] = Array.isArray(data) ? data : (data?.logs ?? []);
setState({ status: "ready", logs });
} catch (err) {
if (cancelled || (err as { name?: string })?.name === "AbortError") return;
setState({ status: "error", message: (err as Error).message || "Failed to load logs" });
}
}
void load();
const interval = setInterval(load, 2000);
return () => {
cancelled = true;
ctrl.abort();
clearInterval(interval);
};
}, [providerId]);
}, [providerId, refreshTick]);
const loading = state.status === "loading";
const error = state.status === "error" ? state.message : null;
const models = state.status === "ready" ? state.models : [];
const handleRefresh = useCallback(() => setRefreshTick((n) => n + 1), []);
if (loading) {
if (state.status === "loading") {
return (
<TabPlaceholder
icon="hourglass_top"
title="Loading models"
body="Fetching available models from provider…"
/>
<div className="flex-1 min-h-0 flex items-center justify-center text-xs text-text-muted gap-2">
<span className="material-symbols-outlined text-[18px] animate-spin">
progress_activity
</span>
Loading logs
</div>
);
}
if (error) {
return <TabPlaceholder icon="error" title="Could not load models" body={error} />;
}
if (models.length === 0) {
if (state.status === "error") {
return (
<TabPlaceholder
icon="view_list"
title="No models"
body="This provider didn't return any models."
/>
<TabPlaceholder icon="error" title="Failed to load logs" body={<p>{state.message}</p>} />
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
<ul className="flex flex-col divide-y divide-border/60">
{models.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="text-sm font-mono text-text-main truncate" title={m.id}>
{m.displayId || m.id}
</p>
{m.owned_by && <p className="text-[11px] text-text-muted">{m.owned_by}</p>}
</div>
<button
type="button"
onClick={() => navigator.clipboard?.writeText(m.id)}
title="Copy model id"
className="p-1.5 rounded-md text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors shrink-0"
if (state.logs.length === 0) {
return (
<TabPlaceholder
icon="receipt_long"
title="No logs yet"
body={
<>
<p>Send a test message from the Test tab logs for this provider will appear here.</p>
<a
href={`/dashboard/logs?connection=${encodeURIComponent(providerId)}`}
className="mt-2 inline-flex items-center gap-1 text-accent hover:underline"
target="_blank"
rel="noopener noreferrer"
>
<span className="material-symbols-outlined text-[16px]">content_copy</span>
</button>
</li>
))}
Open full logs page
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
</a>
</>
}
/>
);
}
return (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-black/5 dark:border-white/5 shrink-0">
<span className="inline-flex items-center gap-2 text-[10px] uppercase tracking-wider text-text-muted font-medium">
<span className="inline-flex items-center gap-1.5">
<span className="relative inline-flex h-2 w-2">
<span className="absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75 animate-ping" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
<span className="text-emerald-500">Live</span>
</span>
<span aria-hidden>·</span>
<span>tailing last {state.logs.length}</span>
</span>
<button
type="button"
onClick={handleRefresh}
className="text-[10px] text-text-muted hover:text-text-main inline-flex items-center gap-1"
title="Refresh now"
>
<span className="material-symbols-outlined text-[14px]">refresh</span>
Refresh
</button>
</div>
<ul className="flex-1 min-h-0 overflow-y-auto divide-y divide-border/40">
{state.logs.map((log) => {
const key = String(log.id);
const isExpanded = expanded === key;
const statusOk = typeof log.status === "number" && log.status >= 200 && log.status < 400;
const statusColor = statusOk
? "text-emerald-600 dark:text-emerald-400"
: typeof log.status === "number"
? "text-red-500"
: "text-text-muted";
const requester = formatRequester(log);
return (
<li key={key}>
<button
type="button"
onClick={() => setExpanded(isExpanded ? null : key)}
className="w-full flex items-center gap-3 px-4 py-2 text-left hover:bg-black/[0.03] dark:hover:bg-white/[0.03] transition-colors"
>
<span className={`text-[11px] font-mono shrink-0 w-10 ${statusColor}`}>
{log.status ?? "—"}
</span>
<span className="text-[11px] text-text-muted shrink-0 w-20 font-mono">
{formatRelativeTs(log.timestamp)}
</span>
<div className="flex-1 min-w-0 flex flex-col">
<span
className="text-xs truncate text-text-main"
title={log.model || log.requestedModel}
>
{log.model || log.requestedModel || "—"}
</span>
<span
className="text-[10px] text-text-muted truncate font-mono"
title={requester.title}
>
{requester.label}
</span>
</div>
<span className="text-[11px] text-text-muted shrink-0 font-mono w-14 text-right">
{formatDurationMs(log.duration)}
</span>
<span
className={`material-symbols-outlined text-text-muted text-[16px] shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
>
chevron_right
</span>
</button>
{isExpanded && <LogDetail log={log} />}
</li>
);
})}
</ul>
<div className="px-4 py-2 border-t border-black/5 dark:border-white/5 text-[10px] text-text-muted text-center shrink-0">
<a
href={`/dashboard/logs?connection=${encodeURIComponent(providerId)}`}
className="inline-flex items-center gap-1 hover:text-text-main hover:underline"
target="_blank"
rel="noopener noreferrer"
>
Open full logs page
<span className="material-symbols-outlined text-[12px]">open_in_new</span>
</a>
</div>
</div>
);
}
function KeysTab({ provider, providerId }: { provider: SlideOverProvider; providerId: string }) {
function LogDetail({ log }: { log: LogEntry }) {
const requester = formatRequester(log);
const tokensIn = log.tokens?.in;
const tokensOut = log.tokens?.out;
const rows: { label: string; value: string; mono?: boolean }[] = [
{ label: "Timestamp", value: new Date(log.timestamp).toLocaleString(), mono: true },
{ label: "Status", value: String(log.status ?? "—") },
{ label: "Duration", value: formatDurationMs(log.duration) },
{ label: "Model", value: log.model || "—", mono: true },
{ label: "Requested model", value: log.requestedModel || "—", mono: true },
{ label: "Provider", value: log.provider || "—", mono: true },
{ label: "Requester", value: requester.title, mono: true },
{
label: "Tokens",
value:
tokensIn != null || tokensOut != null ? `in ${tokensIn ?? 0} · out ${tokensOut ?? 0}` : "—",
},
];
return (
<TabPlaceholder
icon="key"
title="Manage keys"
body={
<div className="flex flex-col gap-2">
<p>
Open the full provider page to add or rotate keys for{" "}
<span className="font-mono">{provider.name}</span>.
</p>
<a
href={`/dashboard/providers/${providerId}`}
className="inline-flex items-center gap-1.5 text-accent hover:underline self-center"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
Go to provider page
</a>
<dl className="px-4 py-3 bg-bg-subtle/40 border-t border-black/5 dark:border-white/5 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 text-[11px]">
{rows.map((row) => (
<div key={row.label} className="contents">
<dt className="text-text-muted uppercase tracking-wider text-[10px] font-medium">
{row.label}
</dt>
<dd className={`text-text-main min-w-0 break-words ${row.mono ? "font-mono" : ""}`}>
{row.value}
</dd>
</div>
}
/>
))}
</dl>
);
}
function LogsTab({ providerId }: { providerId: string }) {
return (
<TabPlaceholder
icon="receipt_long"
title="Recent requests"
body={
<div className="flex flex-col gap-2">
<p>Per-provider log filtering is coming soon. For now, open the global logs view.</p>
<a
href={`/dashboard/logs?connection=${encodeURIComponent(providerId)}`}
className="inline-flex items-center gap-1.5 text-accent hover:underline self-center"
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
Open logs
</a>
</div>
}
/>
);
function formatRelativeTs(ts: string | undefined): string {
if (!ts) return "—";
const date = new Date(ts);
if (isNaN(date.getTime())) return "—";
const diff = (Date.now() - date.getTime()) / 1000;
if (diff < 60) return `${Math.max(0, Math.floor(diff))}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return date.toLocaleDateString();
}
function formatDurationMs(ms: number | undefined): string {
if (ms == null) return "—";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}