first commit
This commit is contained in:
117
ui/app/workspace/mcp-logs/views/columns.tsx
Normal file
117
ui/app/workspace/mcp-logs/views/columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
321
ui/app/workspace/mcp-logs/views/emptyState.tsx
Normal file
321
ui/app/workspace/mcp-logs/views/emptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
ui/app/workspace/mcp-logs/views/mcpHeaderView.tsx
Normal file
135
ui/app/workspace/mcp-logs/views/mcpHeaderView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
287
ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
Normal file
287
ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
253
ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx
Normal file
253
ui/app/workspace/mcp-logs/views/mcpLogsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user