first commit
This commit is contained in:
6
ui/app/workspace/mcp-logs/layout.tsx
Normal file
6
ui/app/workspace/mcp-logs/layout.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import MCPLogsPage from "./page";
|
||||
|
||||
export const Route = createFileRoute("/workspace/mcp-logs")({
|
||||
component: MCPLogsPage,
|
||||
});
|
||||
531
ui/app/workspace/mcp-logs/page.tsx
Normal file
531
ui/app/workspace/mcp-logs/page.tsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import { MCPFilterSidebar } from "@/components/filters/mcpFilterSidebar";
|
||||
import FullPageLoader from "@/components/fullPageLoader";
|
||||
import { useColumnConfig } from "@/components/table";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { getErrorMessage, useDeleteMCPLogsMutation, useGetMCPLogsQuery, useGetMCPLogsStatsQuery } from "@/lib/store";
|
||||
import { useLazyGetMCPLogsQuery } from "@/lib/store/apis/mcpLogsApi";
|
||||
import type { MCPToolLogEntry, MCPToolLogFilters, Pagination } from "@/lib/types/logs";
|
||||
import { dateUtils } from "@/lib/types/logs";
|
||||
import { COMPACT_NUMBER_FORMAT } from "@/lib/utils/numbers";
|
||||
import { getRangeForPeriod } from "@/lib/utils/timeRange";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { AlertCircle, CheckCircle, Clock, DollarSign, Hash } from "lucide-react";
|
||||
import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQueryStates } from "nuqs";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createMCPColumns } from "./views/columns";
|
||||
import { MCPEmptyState } from "./views/emptyState";
|
||||
import { McpHeaderView } from "./views/mcpHeaderView";
|
||||
import { MCPLogDetailSheet } from "./views/mcpLogDetailsSheet";
|
||||
import { MCPLogsDataTable } from "./views/mcpLogsTable";
|
||||
|
||||
export default function MCPLogsPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmptyState, setShowEmptyState] = useState(false);
|
||||
const hasCheckedEmptyState = useRef(false);
|
||||
const hasDeleteAccess = useRbac(RbacResource.Logs, RbacOperation.Delete);
|
||||
|
||||
const [deleteLogs] = useDeleteMCPLogsMutation();
|
||||
// Lazy query kept only for handleLogNavigate (fetches adjacent pages on demand)
|
||||
const [triggerGetLogs] = useLazyGetMCPLogsQuery();
|
||||
|
||||
// Track if user has manually modified the time range
|
||||
const userModifiedTimeRange = useRef<boolean>(false);
|
||||
|
||||
// Capture initial defaults on mount to detect shared URLs with custom time ranges
|
||||
const initialDefaults = useRef(dateUtils.getDefaultTimeRange());
|
||||
|
||||
const defaultTimeRange = useMemo(() => dateUtils.getDefaultTimeRange(), []);
|
||||
const getDefaultTimeRange = () => dateUtils.getDefaultTimeRange();
|
||||
|
||||
const { search } = useLocation();
|
||||
const hasExplicitTimeRange = (search as Record<string, unknown>)?.start_time && (search as Record<string, unknown>)?.end_time;
|
||||
|
||||
// URL state management
|
||||
const [urlState, setUrlState] = useQueryStates(
|
||||
{
|
||||
tool_names: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
server_labels: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
status: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
content_search: parseAsString.withDefault(""),
|
||||
start_time: parseAsInteger.withDefault(defaultTimeRange.startTime),
|
||||
end_time: parseAsInteger.withDefault(defaultTimeRange.endTime),
|
||||
limit: parseAsInteger.withDefault(50),
|
||||
offset: parseAsInteger.withDefault(0),
|
||||
sort_by: parseAsString.withDefault("timestamp"),
|
||||
order: parseAsString.withDefault("desc"),
|
||||
polling: parseAsBoolean.withDefault(true).withOptions({ clearOnDefault: false }),
|
||||
period: parseAsString.withDefault(hasExplicitTimeRange ? "" : "1h").withOptions({ clearOnDefault: false }),
|
||||
selected_log: parseAsString.withDefault(""),
|
||||
},
|
||||
{
|
||||
history: "push",
|
||||
shallow: false,
|
||||
},
|
||||
);
|
||||
|
||||
const selectedLogId = urlState.selected_log || null;
|
||||
const polling = urlState.polling;
|
||||
|
||||
// Refresh time range on page focus/visibility
|
||||
useEffect(() => {
|
||||
const refreshDefaultsIfStale = () => {
|
||||
if (!polling) return
|
||||
if (urlState.period) {
|
||||
const { from, to } = getRangeForPeriod(urlState.period);
|
||||
setUrlState(
|
||||
{
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: Math.floor(to.getTime() / 1000),
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userModifiedTimeRange.current) return;
|
||||
|
||||
const startTimeDiff = Math.abs(urlState.start_time - initialDefaults.current.startTime);
|
||||
const endTimeDiff = Math.abs(urlState.end_time - initialDefaults.current.endTime);
|
||||
const tolerance = 5;
|
||||
if (startTimeDiff <= tolerance && endTimeDiff <= tolerance) {
|
||||
const defaults = getDefaultTimeRange();
|
||||
const currentEndDiff = Math.abs(urlState.end_time - defaults.endTime);
|
||||
if (currentEndDiff > 300) {
|
||||
setUrlState(
|
||||
{
|
||||
start_time: defaults.startTime,
|
||||
end_time: defaults.endTime,
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
initialDefaults.current.startTime = defaults.startTime;
|
||||
initialDefaults.current.endTime = defaults.endTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) refreshDefaultsIfStale();
|
||||
};
|
||||
const handleFocus = () => refreshDefaultsIfStale();
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [urlState.period, urlState.start_time, urlState.end_time, setUrlState, polling]);
|
||||
|
||||
// Refresh the time window every 5s while live polling is on and a relative period is active.
|
||||
// Updating start_time/end_time changes RTK args → triggers a refetch without needing pollingInterval.
|
||||
useEffect(() => {
|
||||
if (!polling || !urlState.period) return;
|
||||
|
||||
const id = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
const { from, to } = getRangeForPeriod(urlState.period);
|
||||
setUrlState(
|
||||
{
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: Math.floor(to.getTime() / 1000),
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [polling, urlState.period, setUrlState]);
|
||||
|
||||
// Convert URL state to filters and pagination for API calls
|
||||
const filters: MCPToolLogFilters = useMemo(
|
||||
() => ({
|
||||
tool_names: urlState.tool_names,
|
||||
server_labels: urlState.server_labels,
|
||||
status: urlState.status,
|
||||
virtual_key_ids: urlState.virtual_key_ids,
|
||||
content_search: urlState.content_search,
|
||||
start_time: dateUtils.toISOString(urlState.start_time),
|
||||
end_time: dateUtils.toISOString(urlState.end_time),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
urlState.tool_names,
|
||||
urlState.server_labels,
|
||||
urlState.status,
|
||||
urlState.virtual_key_ids,
|
||||
urlState.content_search,
|
||||
urlState.start_time,
|
||||
urlState.end_time,
|
||||
],
|
||||
);
|
||||
|
||||
const pagination: Pagination = useMemo(
|
||||
() => ({
|
||||
limit: urlState.limit,
|
||||
offset: urlState.offset,
|
||||
sort_by: urlState.sort_by as "timestamp" | "latency",
|
||||
order: urlState.order as "asc" | "desc",
|
||||
}),
|
||||
[urlState.limit, urlState.offset, urlState.sort_by, urlState.order],
|
||||
);
|
||||
|
||||
// Non-lazy RTK Query hooks
|
||||
const {
|
||||
data: logsData,
|
||||
isLoading: logsIsLoading,
|
||||
isFetching: logsIsFetching,
|
||||
error: logsError,
|
||||
refetch: refetchLogs,
|
||||
} = useGetMCPLogsQuery(
|
||||
{ filters, pagination },
|
||||
{
|
||||
// When a relative period is active, the setInterval above updates URL timestamps → RTK
|
||||
// detects arg changes and refetches automatically; no separate pollingInterval needed.
|
||||
pollingInterval: showEmptyState ? 3000 : polling && !urlState.period ? 5000 : 0,
|
||||
refetchOnMountOrArgChange: true,
|
||||
skipPollingIfUnfocused: true,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: statsData,
|
||||
isFetching: statsIsFetching,
|
||||
refetch: refetchStats,
|
||||
} = useGetMCPLogsStatsQuery({ filters }, { refetchOnMountOrArgChange: true });
|
||||
|
||||
const refreshAllData = useCallback(() => {
|
||||
refetchLogs();
|
||||
refetchStats();
|
||||
}, [refetchLogs, refetchStats]);
|
||||
|
||||
// Derive data directly from RTK
|
||||
const logs = logsData?.logs ?? [];
|
||||
const totalItems = logsData?.stats?.total_executions ?? 0;
|
||||
|
||||
const selectedLog = useMemo(() => (selectedLogId ? (logs.find((l) => l.id === selectedLogId) ?? null) : null), [selectedLogId, logs]);
|
||||
|
||||
// Set showEmptyState on first response; clear it as soon as logs appear.
|
||||
useEffect(() => {
|
||||
if (!logsData) return;
|
||||
if (!hasCheckedEmptyState.current) {
|
||||
setShowEmptyState(!logsData.has_logs);
|
||||
hasCheckedEmptyState.current = true;
|
||||
} else if (showEmptyState && logsData.has_logs) {
|
||||
setShowEmptyState(false);
|
||||
}
|
||||
}, [logsData, showEmptyState]);
|
||||
|
||||
// On mount: freshen period timestamps if stale
|
||||
useEffect(() => {
|
||||
if (urlState.period) {
|
||||
const { from, to } = getRangeForPeriod(urlState.period);
|
||||
const freshEnd = Math.floor(to.getTime() / 1000);
|
||||
if (Math.abs(urlState.end_time - freshEnd) > 60) {
|
||||
setUrlState(
|
||||
{
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: freshEnd,
|
||||
period: urlState.period ?? "",
|
||||
},
|
||||
{ history: "replace" },
|
||||
);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Helper to update filters in URL
|
||||
const setFilters = useCallback(
|
||||
(newFilters: MCPToolLogFilters) => {
|
||||
const timeChanged = newFilters.start_time !== undefined || newFilters.end_time !== undefined;
|
||||
if (timeChanged) userModifiedTimeRange.current = true;
|
||||
|
||||
setUrlState({
|
||||
...(timeChanged && { period: "" }),
|
||||
tool_names: newFilters.tool_names || [],
|
||||
server_labels: newFilters.server_labels || [],
|
||||
status: newFilters.status || [],
|
||||
virtual_key_ids: newFilters.virtual_key_ids || [],
|
||||
content_search: newFilters.content_search || "",
|
||||
start_time: newFilters.start_time ? dateUtils.toUnixTimestamp(new Date(newFilters.start_time)) : undefined,
|
||||
end_time: newFilters.end_time ? dateUtils.toUnixTimestamp(new Date(newFilters.end_time)) : undefined,
|
||||
offset: 0,
|
||||
});
|
||||
},
|
||||
[setUrlState],
|
||||
);
|
||||
|
||||
// Helper to update pagination in URL
|
||||
const setPagination = useCallback(
|
||||
(newPagination: Pagination) => {
|
||||
setUrlState({
|
||||
limit: newPagination.limit,
|
||||
offset: newPagination.offset,
|
||||
sort_by: newPagination.sort_by,
|
||||
order: newPagination.order,
|
||||
});
|
||||
},
|
||||
[setUrlState],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (log: MCPToolLogEntry) => {
|
||||
if (!hasDeleteAccess) throw new Error("No delete access");
|
||||
try {
|
||||
await deleteLogs({ ids: [log.id] }).unwrap();
|
||||
if (urlState.selected_log === log.id) {
|
||||
setUrlState({ selected_log: "" });
|
||||
}
|
||||
refreshAllData();
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
setError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
},
|
||||
[deleteLogs, hasDeleteAccess, urlState.selected_log, setUrlState, refreshAllData],
|
||||
);
|
||||
|
||||
const handlePeriodChange = useCallback(
|
||||
(p: string, from: Date, to: Date) => {
|
||||
setUrlState({
|
||||
period: p,
|
||||
start_time: Math.floor(from.getTime() / 1000),
|
||||
end_time: Math.floor(to.getTime() / 1000),
|
||||
offset: 0,
|
||||
});
|
||||
},
|
||||
[setUrlState],
|
||||
);
|
||||
|
||||
const handlePollToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setUrlState({ polling: enabled });
|
||||
if (enabled) refreshAllData();
|
||||
},
|
||||
[setUrlState, refreshAllData],
|
||||
);
|
||||
|
||||
const statCards = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Total Executions",
|
||||
value: <NumberFlow value={statsData?.total_executions ?? 0} format={COMPACT_NUMBER_FORMAT} />,
|
||||
icon: <Hash className="size-4" />,
|
||||
},
|
||||
{
|
||||
title: "Success Rate",
|
||||
value: (
|
||||
<NumberFlow value={statsData?.success_rate ?? 0} format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }} suffix="%" />
|
||||
),
|
||||
icon: <CheckCircle className="size-4" />,
|
||||
},
|
||||
{
|
||||
title: "Avg Latency",
|
||||
value: (
|
||||
<NumberFlow value={statsData?.average_latency ?? 0} format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }} suffix="ms" />
|
||||
),
|
||||
icon: <Clock className="size-4" />,
|
||||
},
|
||||
{
|
||||
title: "Total Cost",
|
||||
value: (
|
||||
<NumberFlow
|
||||
value={statsData?.total_cost ?? 0}
|
||||
format={{
|
||||
...COMPACT_NUMBER_FORMAT,
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
icon: <DollarSign className="size-4" />,
|
||||
},
|
||||
],
|
||||
[statsData],
|
||||
);
|
||||
|
||||
const columns = useMemo(() => createMCPColumns(handleDelete, hasDeleteAccess), [handleDelete, hasDeleteAccess]);
|
||||
|
||||
const columnIds = useMemo(
|
||||
() => columns.map((col) => ("id" in col && col.id ? col.id : "accessorKey" in col ? String(col.accessorKey) : "")).filter(Boolean),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const MCP_COLUMN_LABELS: Record<string, string> = useMemo(
|
||||
() => ({
|
||||
timestamp: "Time",
|
||||
tool_name: "Tool Name",
|
||||
server_label: "Server",
|
||||
latency: "Latency",
|
||||
cost: "Cost",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const {
|
||||
entries: columnEntries,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnPinning,
|
||||
toggleVisibility: toggleColumnVisibility,
|
||||
togglePin: toggleColumnPin,
|
||||
reorder: reorderColumns,
|
||||
reset: resetColumns,
|
||||
} = useColumnConfig({
|
||||
columnIds,
|
||||
paramName: "mcp_cols",
|
||||
fixedColumns: { left: [], right: [] },
|
||||
});
|
||||
|
||||
const selectedLogIndex = useMemo(() => (selectedLogId ? logs.findIndex((l) => l.id === selectedLogId) : -1), [selectedLogId, logs]);
|
||||
|
||||
const handleLogNavigate = useCallback(
|
||||
(direction: "prev" | "next") => {
|
||||
const replaceHistory = { history: "replace" as const };
|
||||
const currentLogId = selectedLogId || "";
|
||||
if (direction === "prev") {
|
||||
if (selectedLogIndex > 0) {
|
||||
setUrlState({ selected_log: logs[selectedLogIndex - 1].id }, replaceHistory);
|
||||
} else if (pagination.offset > 0) {
|
||||
const newOffset = Math.max(0, pagination.offset - pagination.limit);
|
||||
setUrlState({ offset: newOffset, selected_log: "" }, replaceHistory);
|
||||
triggerGetLogs({
|
||||
filters,
|
||||
pagination: { ...pagination, offset: newOffset },
|
||||
}).then((result) => {
|
||||
const pageLogs = result.data?.logs;
|
||||
if (pageLogs?.length) {
|
||||
setUrlState({ selected_log: pageLogs[pageLogs.length - 1].id }, replaceHistory);
|
||||
} else if (result.error) {
|
||||
setUrlState({ offset: pagination.offset, selected_log: currentLogId }, replaceHistory);
|
||||
setError(getErrorMessage(result.error));
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (selectedLogIndex >= 0 && selectedLogIndex < logs.length - 1) {
|
||||
setUrlState({ selected_log: logs[selectedLogIndex + 1].id }, replaceHistory);
|
||||
} else if (pagination.offset + pagination.limit < totalItems) {
|
||||
const newOffset = pagination.offset + pagination.limit;
|
||||
setUrlState({ offset: newOffset, selected_log: "" }, replaceHistory);
|
||||
triggerGetLogs({
|
||||
filters,
|
||||
pagination: { ...pagination, offset: newOffset },
|
||||
}).then((result) => {
|
||||
const pageLogs = result.data?.logs;
|
||||
if (pageLogs?.length) {
|
||||
setUrlState({ selected_log: pageLogs[0].id }, replaceHistory);
|
||||
} else if (result.error) {
|
||||
setUrlState({ offset: pagination.offset, selected_log: currentLogId }, replaceHistory);
|
||||
setError(getErrorMessage(result.error));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
[selectedLogId, selectedLogIndex, logs, pagination, totalItems, filters, setUrlState, triggerGetLogs],
|
||||
);
|
||||
|
||||
const displayError = error ?? (logsError ? getErrorMessage(logsError as Parameters<typeof getErrorMessage>[0]) : null);
|
||||
|
||||
return (
|
||||
<div className="dark:bg-card bg-white">
|
||||
{logsIsLoading ? (
|
||||
<FullPageLoader />
|
||||
) : showEmptyState ? (
|
||||
<MCPEmptyState error={displayError} />
|
||||
) : (
|
||||
<div className="no-padding-parent no-border-parent bg-background flex h-[calc(100vh_-_16px)] w-full gap-3">
|
||||
{/* Sidebar Filters */}
|
||||
<MCPFilterSidebar filters={filters} onFiltersChange={setFilters} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-card flex min-w-0 flex-1 flex-col gap-2 overflow-hidden rounded-l-md">
|
||||
<div className="p-4 pb-0">
|
||||
<McpHeaderView
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
period={urlState.period}
|
||||
onPeriodChange={handlePeriodChange}
|
||||
polling={polling}
|
||||
onPollToggle={handlePollToggle}
|
||||
onRefresh={refreshAllData}
|
||||
loading={logsIsFetching}
|
||||
columnEntries={columnEntries}
|
||||
columnLabels={MCP_COLUMN_LABELS}
|
||||
onToggleColumnVisibility={toggleColumnVisibility}
|
||||
onResetColumns={resetColumns}
|
||||
/>
|
||||
</div>
|
||||
{/* Quick Stats */}
|
||||
<div className="px-4">
|
||||
<div className="grid shrink-0 grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{statCards.map((card) => (
|
||||
<Card key={card.title} className="py-4 shadow-none">
|
||||
<CardContent
|
||||
className={`flex items-center justify-between px-4 transition-opacity duration-200 ${statsIsFetching ? "opacity-50" : "opacity-100"}`}
|
||||
>
|
||||
<div className="w-full min-w-0">
|
||||
<div className="text-muted-foreground text-xs">{card.title}</div>
|
||||
<div className="truncate font-mono text-xl font-medium sm:text-2xl">{card.value}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<Alert variant="destructive" className="shrink-0">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{displayError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MCPLogsDataTable
|
||||
columns={columns}
|
||||
data={logs}
|
||||
totalItems={totalItems}
|
||||
loading={logsIsFetching}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onRowClick={(row, columnId) => {
|
||||
if (columnId === "actions") return;
|
||||
setUrlState({ selected_log: row.id }, { history: "replace" });
|
||||
}}
|
||||
onRefresh={refreshAllData}
|
||||
polling={polling}
|
||||
columnEntries={columnEntries}
|
||||
columnOrder={columnOrder}
|
||||
columnVisibility={columnVisibility}
|
||||
columnPinning={columnPinning}
|
||||
onToggleColumnVisibility={toggleColumnVisibility}
|
||||
onTogglePin={toggleColumnPin}
|
||||
onReorderColumns={reorderColumns}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Log Detail Sheet */}
|
||||
<MCPLogDetailSheet
|
||||
log={selectedLog}
|
||||
open={selectedLogId !== null}
|
||||
onOpenChange={(open) => !open && setUrlState({ selected_log: "" }, { history: "replace" })}
|
||||
handleDelete={handleDelete}
|
||||
onNavigate={handleLogNavigate}
|
||||
hasPrev={selectedLogIndex > 0 || (selectedLogIndex !== -1 && pagination.offset > 0)}
|
||||
hasNext={selectedLogIndex !== -1 && (selectedLogIndex < logs.length - 1 || pagination.offset + pagination.limit < totalItems)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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