first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Status, StatusBarColors, Statuses } from "@/lib/constants/logs";
import type { MCPToolLogEntry } from "@/lib/types/logs";
import { ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, Trash2 } from "lucide-react";
import { format, isValid } from "date-fns";
// Helper function to validate status and return a safe Status value
const getValidatedStatus = (status: string): Status => {
// Check if status is a valid Status by checking against Statuses array
if (Statuses.includes(status as Status)) {
return status as Status;
}
// Fallback to "processing" for unknown statuses
return "processing";
};
export const createMCPColumns = (
handleDelete: (log: MCPToolLogEntry) => Promise<void>,
hasDeleteAccess: boolean,
): ColumnDef<MCPToolLogEntry>[] => [
{
accessorKey: "status",
header: "",
size: 8,
maxSize: 8,
cell: ({ row }) => {
const status = getValidatedStatus(row.original.status);
return <div className={`h-full min-h-[24px] w-1 rounded-sm ${StatusBarColors[status]}`} />;
},
},
{
accessorKey: "timestamp",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Time
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
size: 230,
cell: ({ row }) => {
const timestamp = row.original.timestamp;
const date = new Date(timestamp);
return <div className="truncate text-xs">{isValid(date) ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "Invalid date"}</div>;
},
},
{
accessorKey: "tool_name",
header: "Tool Name",
size: 300,
cell: ({ row }) => {
const toolName = row.getValue("tool_name") as string;
return <span className="block max-w-full truncate font-mono text-sm">{toolName}</span>;
},
},
{
accessorKey: "server_label",
header: "Server",
size: 150,
cell: ({ row }) => {
const serverLabel = row.getValue("server_label") as string;
return serverLabel ? (
<Badge variant="secondary" className="font-mono">
{serverLabel}
</Badge>
) : (
<span className="text-muted-foreground">-</span>
);
},
},
{
accessorKey: "latency",
header: ({ column }) => (
<Button variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
Latency
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
size: 120,
cell: ({ row }) => {
const latency = row.original.latency;
return (
<div className="pl-4 font-mono text-sm">{latency === undefined || latency === null ? "N/A" : `${latency.toLocaleString()}ms`}</div>
);
},
},
{
accessorKey: "cost",
header: "Cost",
size: 120,
cell: ({ row }) => {
const cost = row.original.cost;
const isValidNumber = typeof cost === "number" && Number.isFinite(cost);
return <div className="font-mono text-sm">{isValidNumber ? `${cost.toFixed(4)}` : "N/A"}</div>;
},
},
{
id: "actions",
size: 72,
cell: ({ row }) => {
const log = row.original;
return (
<Button
variant="outline"
size="icon"
className="text-destructive hover:bg-destructive/10 hover:text-destructive border-destructive/30"
onClick={() => void handleDelete(log)}
disabled={!hasDeleteAccess}
aria-label="Delete log"
>
<Trash2 />
</Button>
);
},
},
];

View File

@@ -0,0 +1,321 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { CodeEditor } from "@/components/ui/codeEditor";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getExampleBaseUrl } from "@/lib/utils/port";
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard";
import { AlertTriangle, Copy } from "lucide-react";
import { useMemo, useState } from "react";
type Language = "python" | "typescript";
type Examples = {
manual: {
[L in Language]: string;
};
agentMode: {
[L in Language]: string;
};
};
// Common editor options to reduce duplication
const EditorOptions = {
scrollBeyondLastLine: false,
minimap: { enabled: false },
lineNumbers: "off",
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
glyphMargin: false,
} as const;
interface CodeBlockProps {
code: string;
language: string;
onLanguageChange?: (language: string) => void;
showLanguageSelect?: boolean;
readonly?: boolean;
}
function CodeBlock({ code, language, onLanguageChange, showLanguageSelect = false, readonly = true }: CodeBlockProps) {
const { copy: copyToClipboard } = useCopyToClipboard();
return (
<div className="relative">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2">
{showLanguageSelect && onLanguageChange && (
<Select value={language} onValueChange={onLanguageChange}>
<SelectTrigger className="h-8 w-fit text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem className="text-xs" value="python">
Python
</SelectItem>
<SelectItem className="text-xs" value="typescript">
TypeScript
</SelectItem>
</SelectContent>
</Select>
)}
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(code)} aria-label="Copy to clipboard">
<Copy className="size-4" />
</Button>
</div>
<CodeEditor className="w-full" code={code} lang={language} readonly={readonly} height={300} fontSize={14} options={EditorOptions} />
</div>
);
}
interface MCPEmptyStateProps {
error?: string | null;
statusIndicator?: React.ReactNode;
}
export function MCPEmptyState({ error, statusIndicator }: MCPEmptyStateProps) {
const [language, setLanguage] = useState<Language>("python");
// Generate examples dynamically using the port utility
const examples: Examples = useMemo(() => {
const baseUrl = getExampleBaseUrl();
return {
manual: {
python: `import openai
import requests
# Step 1: Initialize OpenAI client with Bifrost
client = openai.OpenAI(
base_url="${baseUrl}/openai",
api_key="dummy-api-key" # Handled by Bifrost
)
# Step 2: Send chat request
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "List files in current directory"}]
)
# Step 3: Check for tool calls
message = response.choices[0].message
if message.tool_calls:
for tool_call in message.tool_calls:
# Step 4: Execute tool via Bifrost
tool_result = requests.post(
"${baseUrl}/v1/mcp/tool/execute",
json={
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments
}
}
).json()
# Step 5: Continue conversation with results
final_response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "user", "content": "List files in current directory"},
message,
tool_result
]
)
print(final_response.choices[0].message.content)`,
typescript: `import OpenAI from "openai";
// Step 1: Initialize OpenAI client with Bifrost
const openai = new OpenAI({
baseURL: "${baseUrl}/openai",
apiKey: "dummy-api-key", // Handled by Bifrost
});
// Step 2: Send chat request
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "List files in current directory" }],
});
const message = response.choices[0].message;
// Step 3: Check for tool calls
if (message.tool_calls) {
for (const toolCall of message.tool_calls) {
// Step 4: Execute tool via Bifrost
const toolResult = await fetch("${baseUrl}/v1/mcp/tool/execute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: toolCall.id,
type: "function",
function: {
name: toolCall.function.name,
arguments: toolCall.function.arguments,
},
}),
}).then(res => res.json());
// Step 5: Continue conversation with results
const finalResponse = await openai.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "user", content: "List files in current directory" },
message,
toolResult,
],
});
console.log(finalResponse.choices[0].message.content);
}
}`,
},
agentMode: {
python: `import openai
# Agent Mode enables autonomous tool execution
# Configure auto-executable tools in MCP Gateway settings
client = openai.OpenAI(
base_url="${baseUrl}/openai",
api_key="dummy-api-key"
)
# With agent mode enabled, Bifrost automatically:
# 1. Receives tool calls from LLM
# 2. Executes auto-approved tools (e.g., read_file, list_directory)
# 3. Feeds results back to LLM
# 4. Returns final response after all iterations
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": "List all Python files and summarize their purpose"
}]
)
# The response includes results from all auto-executed tools
# Non-auto-executable tools (e.g., write_file) are returned for manual approval
print(response.choices[0].message.content)
# If there are pending non-auto-executable tools:
if response.choices[0].message.tool_calls:
print("Pending tools requiring approval:",
[tc.function.name for tc in response.choices[0].message.tool_calls])`,
typescript: `import OpenAI from "openai";
// Agent Mode enables autonomous tool execution
// Configure auto-executable tools in MCP Gateway settings
const openai = new OpenAI({
baseURL: "${baseUrl}/openai",
apiKey: "dummy-api-key",
});
// With agent mode enabled, Bifrost automatically:
// 1. Receives tool calls from LLM
// 2. Executes auto-approved tools (e.g., read_file, list_directory)
// 3. Feeds results back to LLM
// 4. Returns final response after all iterations
const response = await openai.chat.completions.create({
model: "gpt-4o",
messages: [{
role: "user",
content: "List all Python files and summarize their purpose"
}],
});
// The response includes results from all auto-executed tools
// Non-auto-executable tools (e.g., write_file) are returned for manual approval
console.log(response.choices[0].message.content);
// If there are pending non-auto-executable tools:
if (response.choices[0].message.tool_calls) {
console.log("Pending tools requiring approval:",
response.choices[0].message.tool_calls.map(tc => tc.function.name)
);
}`,
},
};
}, []);
const isUnexpectedError = error && error.includes("An unexpected error occurred");
return (
<div className="dark:bg-card flex w-full flex-col items-center justify-center space-y-8 bg-white">
{error && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{isUnexpectedError ? "Looks like you haven't configured the log store in your config file." : error}
</AlertDescription>
</Alert>
)}
<div className="w-full space-y-6">
<div className="flex flex-row items-center gap-2">
<div>
<h3 className="text-lg font-semibold">Get Started with MCP Tool Execution</h3>
<p className="text-muted-foreground text-sm">Execute your first MCP tool call to see logs appear</p>
</div>
<div className="ml-auto">{statusIndicator}</div>
</div>
<Tabs defaultValue="manual" className="w-full rounded-lg border">
<TabsList className="grid h-10 w-full grid-cols-2 rounded-t-lg rounded-b-none">
<TabsTrigger value="manual">Manual Tool Execution</TabsTrigger>
<TabsTrigger value="agent">Agent Mode (Auto-Execute)</TabsTrigger>
</TabsList>
<TabsContent value="manual" className="px-4">
<div className="text-muted-foreground mb-3 text-sm">
<p>Full control over tool approval. You explicitly execute each tool call via the API.</p>
</div>
<CodeBlock
code={examples.manual[language]}
language={language}
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
showLanguageSelect
/>
</TabsContent>
<TabsContent value="agent" className="px-4">
<div className="text-muted-foreground mb-3 text-sm">
<p>Autonomous execution for pre-approved tools. Configure auto-executable tools in MCP Gateway settings.</p>
</div>
<CodeBlock
code={examples.agentMode[language]}
language={language}
onLanguageChange={(newLang) => setLanguage(newLang as Language)}
showLanguageSelect
/>
</TabsContent>
</Tabs>
<div className="bg-muted/50 rounded-lg border p-4">
<h4 className="mb-2 text-sm font-semibold">Prerequisites</h4>
<ul className="text-muted-foreground space-y-1 text-sm">
<li className="flex items-start gap-2">
<span className="text-primary">1.</span>
<span>Configure MCP servers in the MCP Gateway (e.g., filesystem, web_search)</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary">2.</span>
<span>
Set <code className="bg-muted rounded px-1">tools_to_execute</code> to whitelist available tools
</span>
</li>
<li className="flex items-start gap-2">
<span className="text-primary">3.</span>
<span>
For Agent Mode: Configure <code className="bg-muted rounded px-1">tools_to_auto_execute</code> for autonomous execution
</span>
</li>
</ul>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { ColumnConfigDropdown, type ColumnConfigEntry } from "@/components/table";
import { Button } from "@/components/ui/button";
import { DateTimePickerWithRange } from "@/components/ui/datePickerWithRange";
import { Input } from "@/components/ui/input";
import type { MCPToolLogFilters } from "@/lib/types/logs";
import { getRangeForPeriod, TIME_PERIODS } from "@/lib/utils/timeRange";
import { Radio, RefreshCw, Search } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
interface McpHeaderViewProps {
filters: MCPToolLogFilters;
onFiltersChange: (filters: MCPToolLogFilters) => void;
period: string;
onPeriodChange: (period: string, from: Date, to: Date) => void;
polling: boolean;
onPollToggle: (enabled: boolean) => void;
onRefresh: () => void;
loading?: boolean;
/** Column config for the ColumnConfigDropdown */
columnEntries: ColumnConfigEntry[];
columnLabels: Record<string, string>;
onToggleColumnVisibility: (id: string) => void;
onResetColumns: () => void;
}
export function McpHeaderView({
filters,
onFiltersChange,
period,
onPeriodChange,
polling,
onPollToggle,
onRefresh,
loading = false,
columnEntries,
columnLabels,
onToggleColumnVisibility,
onResetColumns,
}: McpHeaderViewProps) {
const [localSearch, setLocalSearch] = useState(filters.content_search || "");
const [startTime, setStartTime] = useState<Date | undefined>(filters.start_time ? new Date(filters.start_time) : undefined);
const [endTime, setEndTime] = useState<Date | undefined>(filters.end_time ? new Date(filters.end_time) : undefined);
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const filtersRef = useRef<MCPToolLogFilters>(filters);
useEffect(() => {
filtersRef.current = filters;
}, [filters]);
useEffect(() => {
setLocalSearch(filters.content_search || "");
}, [filters.content_search]);
useEffect(() => {
setStartTime(filters.start_time ? new Date(filters.start_time) : undefined);
setEndTime(filters.end_time ? new Date(filters.end_time) : undefined);
}, [filters.start_time, filters.end_time]);
useEffect(() => {
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
};
}, []);
const handleSearchChange = useCallback(
(value: string) => {
setLocalSearch(value);
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = setTimeout(() => {
onFiltersChange({ ...filtersRef.current, content_search: value });
}, 500);
},
[onFiltersChange],
);
return (
<div className="flex grow items-center justify-between space-x-2">
<Button
variant="outline"
size="sm"
className="h-7.5 disabled:opacity-100"
onClick={onRefresh}
disabled={loading}
data-testid="mcp-logs-header-refresh-btn"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button
variant={polling ? "default" : "outline"}
size="sm"
className="h-7.5"
onClick={() => onPollToggle(!polling)}
data-testid="mcp-logs-header-live-btn"
>
{polling ? <Radio className="h-4 w-4 animate-pulse" /> : <Radio className="h-4 w-4" />}
Live
</Button>
<div className="border-input flex h-7.5 flex-1 items-center gap-2 rounded-sm border">
<Search className="mr-0.5 ml-2 size-4" />
<Input
type="text"
className="!h-7 rounded-tl-none rounded-tr-sm rounded-br-sm rounded-bl-none border-none bg-slate-50 shadow-none outline-none focus-visible:ring-0 dark:bg-zinc-900"
placeholder="Search MCP logs"
value={localSearch}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</div>
<DateTimePickerWithRange
dateTime={{ from: startTime, to: endTime }}
predefinedPeriod={period || undefined}
onDateTimeUpdate={(p) => {
setStartTime(p.from);
setEndTime(p.to);
onFiltersChange({
...filters,
start_time: p.from?.toISOString(),
end_time: p.to?.toISOString(),
});
}}
preDefinedPeriods={TIME_PERIODS}
onPredefinedPeriodChange={(periodValue) => {
if (!periodValue) return;
const { from, to } = getRangeForPeriod(periodValue);
setStartTime(from);
setEndTime(to);
onPeriodChange(periodValue, from, to);
}}
/>
<ColumnConfigDropdown
entries={columnEntries}
labels={columnLabels}
onToggleVisibility={onToggleColumnVisibility}
onReset={onResetColumns}
/>
</div>
);
}

View File

@@ -0,0 +1,287 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alertDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CodeEditor } from "@/components/ui/codeEditor";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdownMenu";
import { DottedSeparator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { downloadAsJson } from "@/lib/utils/browser-download";
import { Status, StatusColors, Statuses } from "@/lib/constants/logs";
import type { MCPToolLogEntry } from "@/lib/types/logs";
import { ChevronDown, ChevronUp, Download, MoreVertical, Trash2 } from "lucide-react";
import { addMilliseconds, format, isValid } from "date-fns";
import { useState, type ReactNode } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { toast } from "sonner";
interface MCPLogDetailSheetProps {
log: MCPToolLogEntry | null;
open: boolean;
onOpenChange: (open: boolean) => void;
handleDelete: (log: MCPToolLogEntry) => Promise<void>;
onNavigate?: (direction: "prev" | "next") => void;
hasPrev?: boolean;
hasNext?: boolean;
}
const LogEntryDetailsView = ({ label, value, className }: { label: string; value: React.ReactNode; className?: string }) => (
<div className={className}>
<div className="text-muted-foreground text-xs">{label}</div>
<div className="text-sm font-medium">{value}</div>
</div>
);
const BlockHeader = ({ title, icon }: { title: string; icon?: ReactNode }) => {
return (
<div className="flex items-center gap-2">
{icon}
<div className="text-sm font-medium">{title}</div>
</div>
);
};
// Helper function to validate status and return a safe Status value
const getValidatedStatus = (status: string): Status => {
// Check if status is a valid Status by checking against Statuses array
if (Statuses.includes(status as Status)) {
return status as Status;
}
// Fallback to "processing" for unknown statuses
return "processing";
};
export function MCPLogDetailSheet({
log,
open,
onOpenChange,
handleDelete,
onNavigate,
hasPrev = false,
hasNext = false,
}: MCPLogDetailSheetProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
// Keyboard navigation: arrow up/down to navigate between logs
useHotkeys("up", () => onNavigate?.("prev"), { enabled: open && hasPrev, preventDefault: true });
useHotkeys("down", () => onNavigate?.("next"), { enabled: open && hasNext, preventDefault: true });
if (!log) return null;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex w-full flex-col gap-4 overflow-x-hidden p-8 sm:max-w-[60%]">
<SheetHeader className="flex flex-row items-center px-0">
<div className="flex w-full items-center justify-between">
<SheetTitle className="flex w-fit items-center gap-2 font-medium">
{log.id && <p className="text-md max-w-full truncate">Request ID: {log.id}</p>}
<Badge variant="outline" className={`${StatusColors[getValidatedStatus(log.status)]} uppercase`}>
{log.status}
</Badge>
</SheetTitle>
</div>
<div className="flex items-center">
<Button
variant="ghost"
className="size-8"
disabled={!hasPrev}
onClick={() => onNavigate?.("prev")}
aria-label="Previous log"
data-testid="mcp-log-nav-prev"
type="button"
>
<ChevronUp className="size-4" />
</Button>
<Button
variant="ghost"
className="size-8"
disabled={!hasNext}
onClick={() => onNavigate?.("next")}
aria-label="Next log"
data-testid="mcp-log-nav-next"
type="button"
>
<ChevronDown className="size-4" />
</Button>
</div>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="size-8" type="button">
<MoreVertical className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="export-log-json"
onClick={() => downloadAsJson(log, `mcp-log-${log.id ?? "export"}.json`)}
>
<Download className="h-4 w-4" />
Export as JSON
</DropdownMenuItem>
<DropdownMenuSeparator />
<AlertDialogTrigger asChild>
<DropdownMenuItem variant="destructive">
<Trash2 className="h-4 w-4" />
Delete log
</DropdownMenuItem>
</AlertDialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete this log?</AlertDialogTitle>
<AlertDialogDescription>This action cannot be undone. This will permanently delete the log entry.</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async (e) => {
e.preventDefault();
try {
await handleDelete(log);
setDeleteDialogOpen(false);
onOpenChange(false);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to delete log";
toast.error(errorMessage);
// Keep dialog open on error so user can see the error and retry
}
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SheetHeader>
<div className="space-y-4 rounded-sm border px-6 py-4">
<div className="space-y-4">
<BlockHeader title="Timings" />
<div className="grid w-full grid-cols-3 items-center justify-between gap-4">
<LogEntryDetailsView
className="w-full"
label="Start Timestamp"
value={isValid(new Date(log.timestamp)) ? format(new Date(log.timestamp), "yyyy-MM-dd hh:mm:ss aa") : "Invalid date"}
/>
<LogEntryDetailsView
className="w-full"
label="End Timestamp"
value={
isValid(new Date(log.timestamp))
? format(addMilliseconds(new Date(log.timestamp), log.latency || 0), "yyyy-MM-dd hh:mm:ss aa")
: "Invalid date"
}
/>
<LogEntryDetailsView className="w-full" label="Latency" value={log.latency ? `${log.latency.toFixed(2)}ms` : "NA"} />
</div>
</div>
<DottedSeparator />
<div className="space-y-4">
<BlockHeader title="Request Details" />
<div className="grid w-full grid-cols-3 items-start justify-between gap-4">
<LogEntryDetailsView
className="col-span-2 w-full"
label="Tool Name"
value={<span className="font-mono text-sm">{log.tool_name}</span>}
/>
<LogEntryDetailsView
className="w-full"
label="Server"
value={
log.server_label ? (
<Badge variant="secondary" className="font-mono">
{log.server_label}
</Badge>
) : (
"-"
)
}
/>
{log.virtual_key && <LogEntryDetailsView className="w-full" label="Virtual Key" value={log.virtual_key.name} />}
{log.llm_request_id && (
<LogEntryDetailsView
className="col-span-3 w-full"
label="LLM Request ID"
value={<span className="font-mono text-xs">{log.llm_request_id}</span>}
/>
)}
</div>
</div>
</div>
{/* Arguments */}
{log.arguments && (
<div className="w-full rounded-sm border">
<div className="border-b px-6 py-2 text-sm font-medium">Arguments</div>
<CodeEditor
className="z-0 w-full"
shouldAdjustInitialHeight={true}
maxHeight={250}
wrap={true}
code={typeof log.arguments === "string" ? log.arguments : JSON.stringify(log.arguments as Record<string, unknown>, null, 2)}
lang="json"
readonly={true}
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
/>
</div>
)}
{/* Result */}
{log.result && log.status !== "processing" && (
<div className="w-full rounded-sm border">
<div className="border-b px-6 py-2 text-sm font-medium">Result</div>
<CodeEditor
className="z-0 w-full"
shouldAdjustInitialHeight={true}
maxHeight={350}
wrap={true}
code={typeof log.result === "string" ? log.result : JSON.stringify(log.result, null, 2)}
lang="json"
readonly={true}
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
/>
</div>
)}
{/* Metadata */}
{log.metadata && Object.keys(log.metadata).length > 0 && (
<div className="space-y-4 rounded-sm border px-6 py-4">
<BlockHeader title="Metadata" />
<div className="grid w-full grid-cols-3 items-start justify-between gap-4">
{Object.entries(log.metadata).map(([key, value]) => (
<LogEntryDetailsView key={key} className="w-full" label={key} value={String(value)} />
))}
</div>
</div>
)}
{/* Error Details */}
{log.error_details && (
<div className="border-destructive/50 w-full rounded-sm border">
<div className="border-destructive/50 text-destructive border-b px-6 py-2 text-sm font-medium">Error Details</div>
<CodeEditor
className="z-0 w-full"
shouldAdjustInitialHeight={true}
maxHeight={250}
wrap={true}
code={JSON.stringify(log.error_details, null, 2)}
lang="json"
readonly={true}
options={{ scrollBeyondLastLine: false, collapsibleBlocks: true, lineNumbers: "off", alwaysConsumeMouseWheel: false }}
/>
</div>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,253 @@
import {
buildPinStyle,
type ColumnConfigEntry,
DraggableColumnHeader,
PIN_SHADOW_LEFT,
PIN_SHADOW_RIGHT,
useHeaderCellRefs,
usePinOffsets,
} from "@/components/table";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import type { MCPToolLogEntry, Pagination } from "@/lib/types/logs";
import { cn } from "@/lib/utils";
import type { ColumnOrderState, ColumnPinningState, VisibilityState } from "@tanstack/react-table";
import { ColumnDef, flexRender, getCoreRowModel, SortingState, useReactTable } from "@tanstack/react-table";
import { ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
interface DataTableProps {
columns: ColumnDef<MCPToolLogEntry>[];
data: MCPToolLogEntry[];
totalItems: number;
loading?: boolean;
pagination: Pagination;
onPaginationChange: (pagination: Pagination) => void;
onRowClick?: (log: MCPToolLogEntry, columnId: string) => void;
onRefresh?: () => void;
polling?: boolean;
/** Column config — computed by the parent via useColumnConfig */
columnEntries: ColumnConfigEntry[];
columnOrder: ColumnOrderState;
columnVisibility: VisibilityState;
columnPinning: ColumnPinningState;
onToggleColumnVisibility: (id: string) => void;
onTogglePin: (id: string, side: "left" | "right") => void;
onReorderColumns: (entries: ColumnConfigEntry[]) => void;
}
export function MCPLogsDataTable({
columns,
data,
totalItems,
loading = false,
pagination,
onPaginationChange,
onRowClick,
onRefresh,
polling = false,
columnEntries,
columnOrder,
columnVisibility,
columnPinning,
onToggleColumnVisibility,
onTogglePin,
onReorderColumns,
}: DataTableProps) {
const [sorting, setSorting] = useState<SortingState>([{ id: pagination.sort_by, desc: pagination.order === "desc" }]);
const fixedColumnIds = useMemo(() => new Set<string>([]), []);
// Measure actual header cell widths for pixel-perfect pin offsets
const { headerCellRefs, setHeaderCellRef } = useHeaderCellRefs();
const pinOffsets = usePinOffsets(headerCellRefs, columnPinning);
// Shadow on the edge of pinned groups
const lastLeftPinId = columnPinning.left?.at(-1);
const firstRightPinId = columnPinning.right?.at(0);
// Handle native drag-and-drop reorder
const handleColumnDrop = useCallback(
(draggedId: string, targetId: string) => {
const newEntries = [...columnEntries];
const draggedIdx = newEntries.findIndex((e) => e.id === draggedId);
const targetIdx = newEntries.findIndex((e) => e.id === targetId);
if (draggedIdx === -1 || targetIdx === -1) return;
const [moved] = newEntries.splice(draggedIdx, 1);
newEntries.splice(targetIdx, 0, moved);
onReorderColumns(newEntries);
},
[columnEntries, onReorderColumns],
);
const handleSortingChange = (updaterOrValue: SortingState | ((old: SortingState) => SortingState)) => {
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
setSorting(newSorting);
if (newSorting.length > 0) {
const { id, desc } = newSorting[0];
onPaginationChange({
...pagination,
sort_by: id as "timestamp" | "latency",
order: desc ? "desc" : "asc",
});
}
};
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: Math.ceil(totalItems / pagination.limit),
state: {
sorting,
columnOrder,
columnVisibility,
columnPinning,
},
onSortingChange: handleSortingChange,
});
const currentPage = Math.floor(pagination.offset / pagination.limit) + 1;
const totalPages = Math.ceil(totalItems / pagination.limit);
const startItem = pagination.offset + 1;
const endItem = Math.min(pagination.offset + pagination.limit, totalItems);
// Display values that handle the case when totalItems is 0
const startItemDisplay = totalItems === 0 ? 0 : startItem;
const endItemDisplay = totalItems === 0 ? 0 : endItem;
const goToPage = (page: number) => {
const newOffset = (page - 1) * pagination.limit;
onPaginationChange({
...pagination,
offset: newOffset,
});
};
return (
<div className="flex grow flex-col gap-2 overflow-y-auto px-4 pb-2">
<div className="flex h-full grow flex-col gap-2">
<div className="grow overflow-y-auto rounded-sm border">
<Table containerClassName="h-full">
<thead className={cn("sticky top-0 z-10 bg-[#f9f9f9] dark:bg-[#27272a] px-2 [&_tr]:border-b")}>
{table.getHeaderGroups().map((headerGroup) => (
<tr
key={headerGroup.id}
className="hover:bg-muted/50 dark:hover:bg-muted/75 data-[state=selected]:bg-muted border-b transition-colors"
>
{headerGroup.headers.map((header) => (
<DraggableColumnHeader
key={header.id}
header={header}
isConfigurable={!fixedColumnIds.has(header.column.id)}
pinStyle={buildPinStyle(header.column, pinOffsets)}
pinnedHeaderClassName="bg-[#f9f9f9] dark:bg-[#27272a]"
className={cn(
header.column.id === lastLeftPinId && PIN_SHADOW_LEFT,
header.column.id === firstRightPinId && PIN_SHADOW_RIGHT,
)}
onHide={onToggleColumnVisibility}
onPin={onTogglePin}
onDrop={handleColumnDrop}
cellRef={setHeaderCellRef(header.column.id)}
/>
))}
</tr>
))}
</thead>
<TableBody>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="h-12 text-center">
<div className="text-muted-foreground flex items-center justify-center gap-2 text-sm">
{polling ? (
<>
<RefreshCw className="h-4 w-4 animate-spin" />
Waiting for new MCP logs...
</>
) : (
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={loading} data-testid="mcp-logs-table-refresh-btn">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
)}
</div>
</TableCell>
</TableRow>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-muted/50 group/table-row h-12 cursor-pointer">
{row.getVisibleCells().map((cell) => {
const pinned = cell.column.getIsPinned();
const size = cell.column.getSize();
return (
<TableCell
onClick={() => onRowClick?.(row.original, cell.column.id)}
key={cell.id}
style={{ width: size, minWidth: size, maxWidth: size, ...buildPinStyle(cell.column, pinOffsets) }}
className={cn(
"overflow-hidden",
pinned && "bg-card",
cell.column.id === lastLeftPinId && PIN_SHADOW_LEFT,
cell.column.id === firstRightPinId && PIN_SHADOW_RIGHT,
"group-hover/table-row:bg-[#f7f7f7] dark:group-hover/table-row:bg-[#232327]",
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results found. Try adjusting your filters and/or time range.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination Footer */}
<div className="flex items-center justify-between text-xs" data-testid="pagination">
<div className="text-muted-foreground flex items-center gap-2">
{startItemDisplay.toLocaleString()}-{endItemDisplay.toLocaleString()} of {totalItems.toLocaleString()} entries
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
data-testid="prev-page"
aria-label="Previous page"
>
<ChevronLeft className="size-3" />
</Button>
<div className="flex items-center gap-1">
<span>Page</span>
<span>{currentPage}</span>
<span>of {totalPages}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => goToPage(currentPage + 1)}
disabled={totalPages === 0 || currentPage >= totalPages}
data-testid="next-page"
aria-label="Next page"
>
<ChevronRight className="size-3" />
</Button>
</div>
</div>
</div>
</div>
);
}