Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 75 additions & 2 deletions ui/app/workspace/mcp-sessions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
import FullPageLoader from "@/components/fullPageLoader";
import { useDebouncedValue } from "@/hooks/useDebounce";
import { getErrorMessage, useGetMCPSessionsQuery } from "@/lib/store";
import { AuthMode, MCPSessionKind, MCPSessionStatus } from "@/lib/types/mcpSessions";
import { parseAsArrayOf, parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useEffect } from "react";
import SessionsTable from "./views/sessionsTable";

// Page size larger than the governance default (25) since session rows are
// denser than VK rows and the page is the only screen of MCP-auth content.
const PAGE_SIZE = 50;

export default function MCPSessionsPage() {
const { data, isLoading, isError, error } = useGetMCPSessionsQuery();
const [urlState, setUrlState] = useQueryStates(
{
q: parseAsString.withDefault(""),
kind: parseAsArrayOf(parseAsString).withDefault([]),
status: parseAsArrayOf(parseAsString).withDefault([]),
auth_mode: parseAsArrayOf(parseAsString).withDefault([]),
mcp_client_id: parseAsArrayOf(parseAsString).withDefault([]),
offset: parseAsInteger.withDefault(0),
},
{ history: "push" },
);

const debouncedSearch = useDebouncedValue(urlState.q, 300);

const { data, isLoading, isFetching, isError, error } = useGetMCPSessionsQuery({
q: debouncedSearch || undefined,
kind: urlState.kind.length ? (urlState.kind as MCPSessionKind[]) : undefined,
status: urlState.status.length ? (urlState.status as MCPSessionStatus[]) : undefined,
auth_mode: urlState.auth_mode.length ? (urlState.auth_mode as AuthMode[]) : undefined,
mcp_client_id: urlState.mcp_client_id.length ? urlState.mcp_client_id : undefined,
limit: PAGE_SIZE,
offset: urlState.offset,
});

const totalCount = data?.total_count ?? 0;

// Snap offset back if the total shrinks past the current page (e.g. a
// revoke removed the last row on the last page). Same logic as VKs.
useEffect(() => {
if (!data || urlState.offset < totalCount) return;
setUrlState({
offset: totalCount === 0 ? 0 : Math.floor((totalCount - 1) / PAGE_SIZE) * PAGE_SIZE,
});
}, [totalCount, urlState.offset, data, setUrlState]);

if (isLoading) {
return <FullPageLoader />;
Expand All @@ -19,9 +60,41 @@ export default function MCPSessionsPage() {
);
}

const hasActiveFilters =
!!urlState.q ||
urlState.kind.length > 0 ||
Comment thread
greptile-apps[bot] marked this conversation as resolved.
urlState.status.length > 0 ||
urlState.auth_mode.length > 0 ||
urlState.mcp_client_id.length > 0;

const handleSearchChange = (value: string) => setUrlState({ q: value || null, offset: 0 });
const handleKindChange = (value: string[]) => setUrlState({ kind: value.length ? value : null, offset: 0 });
const handleStatusChange = (value: string[]) => setUrlState({ status: value.length ? value : null, offset: 0 });
const handleAuthModeChange = (value: string[]) => setUrlState({ auth_mode: value.length ? value : null, offset: 0 });
const handleOffsetChange = (offset: number) => setUrlState({ offset });
const handleClearFilters = () =>
setUrlState({ q: null, kind: null, status: null, auth_mode: null, mcp_client_id: null, offset: 0 });

return (
<div className="mx-auto w-full max-w-7xl">
<SessionsTable sessions={data?.sessions ?? []} />
<SessionsTable
sessions={data?.sessions ?? []}
totalCount={totalCount}
isFetching={isFetching}
search={urlState.q}
onSearchChange={handleSearchChange}
kindFilter={urlState.kind}
onKindFilterChange={handleKindChange}
statusFilter={urlState.status}
onStatusFilterChange={handleStatusChange}
authModeFilter={urlState.auth_mode}
onAuthModeFilterChange={handleAuthModeChange}
hasActiveFilters={hasActiveFilters}
onClearFilters={handleClearFilters}
offset={urlState.offset}
limit={PAGE_SIZE}
onOffsetChange={handleOffsetChange}
/>
</div>
);
}
113 changes: 113 additions & 0 deletions ui/app/workspace/mcp-sessions/views/sessionsFilterBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Inline filter row above the sessions table: search input + three multi-
// selects (kind, status, auth_mode) + a clear-filters affordance shown
// only when something is active.
//
// MCP-client filter is intentionally absent here. Adding it would need a
// separate source for the dropdown options (the existing list response
// only shows clients that have *sessions*, which is filter-dependent and
// would cause the option list to collapse as filters narrow). When we
// want it we'll piggyback on useGetMCPClientsQuery.

import { Button } from "@/components/ui/button";
import { ComboboxSelect } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Fingerprint, KeyRound, Search, UserRound, X } from "lucide-react";

// Labels mirror the Type column's TypeBadge ("OAuth" / "Headers") so the
// filter vocabulary matches what the user sees in the table.
const KIND_OPTIONS = [
{ label: "OAuth", value: "token" },
{ label: "Headers", value: "header" },
];

const STATUS_OPTIONS = [
{ label: "Active", value: "active" },
{ label: "Orphaned", value: "orphaned" },
{ label: "Needs re-auth", value: "needs_reauth" },
{ label: "Needs update", value: "needs_update" },
{ label: "Pending", value: "pending" },
];

// Identity-mode icons match the glyphs used in BindingCell so the dropdown
// reads as the same vocabulary as the rendered table column.
const AUTH_MODE_OPTIONS = [
{ label: "User", value: "user", icon: <UserRound className="size-3.5" /> },
{ label: "Virtual key", value: "vk", icon: <KeyRound className="size-3.5" /> },
{ label: "Session", value: "session", icon: <Fingerprint className="size-3.5" /> },
];

export interface SessionsFilterBarProps {
search: string;
onSearchChange: (value: string) => void;
kindFilter: string[];
onKindFilterChange: (value: string[]) => void;
statusFilter: string[];
onStatusFilterChange: (value: string[]) => void;
authModeFilter: string[];
onAuthModeFilterChange: (value: string[]) => void;
hasActiveFilters: boolean;
onClearFilters: () => void;
}

export default function SessionsFilterBar(props: SessionsFilterBarProps) {
return (
<div className="flex shrink-0 flex-wrap items-center gap-3">
<div className="relative max-w-sm flex-1 min-w-[200px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
aria-label="Search sessions"
placeholder="Search MCP, user, VK, session..."
value={props.search}
onChange={(e) => props.onSearchChange(e.target.value)}
className="pl-9"
data-testid="mcp-sessions-search-input"
/>
</div>
<ComboboxSelect
multiple
disableSearch
compactTrigger
data-testid="mcp-sessions-kind-filter"
options={KIND_OPTIONS}
value={props.kindFilter}
onValueChange={props.onKindFilterChange}
placeholder="All types"
className="h-9 w-[180px]"
/>
<ComboboxSelect
multiple
disableSearch
compactTrigger
data-testid="mcp-sessions-status-filter"
options={STATUS_OPTIONS}
value={props.statusFilter}
onValueChange={props.onStatusFilterChange}
placeholder="All statuses"
className="h-9 w-[180px]"
/>
<ComboboxSelect
multiple
disableSearch
compactTrigger
data-testid="mcp-sessions-auth-mode-filter"
options={AUTH_MODE_OPTIONS}
value={props.authModeFilter}
onValueChange={props.onAuthModeFilterChange}
placeholder="All identities"
className="h-9 w-[180px]"
/>
{props.hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={props.onClearFilters}
data-testid="mcp-sessions-clear-filters-btn"
className="h-9"
>
<X className="h-4 w-4" />
Clear filters
</Button>
)}
</div>
);
}
97 changes: 90 additions & 7 deletions ui/app/workspace/mcp-sessions/views/sessionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,51 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { PIN_SHADOW_RIGHT } from "@/components/table/columnPinning";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Info } from "lucide-react";
import { ChevronLeft, ChevronRight, Info } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { getErrorMessage, useReauthMCPSessionMutation, useRevokeMCPSessionMutation } from "@/lib/store";
import { MCPSessionRow } from "@/lib/types/mcpSessions";
import { ExternalLink, Fingerprint, KeyRound, Loader2, MoreHorizontal, Pencil, RefreshCcw, Trash2, UserRound } from "lucide-react";
import { useState } from "react";
import SessionsFilterBar from "./sessionsFilterBar";

interface SessionsTableProps {
sessions: MCPSessionRow[];
totalCount: number;
isFetching: boolean;
search: string;
onSearchChange: (value: string) => void;
kindFilter: string[];
onKindFilterChange: (value: string[]) => void;
statusFilter: string[];
onStatusFilterChange: (value: string[]) => void;
authModeFilter: string[];
onAuthModeFilterChange: (value: string[]) => void;
hasActiveFilters: boolean;
onClearFilters: () => void;
offset: number;
limit: number;
onOffsetChange: (offset: number) => void;
}

export default function SessionsTable({ sessions }: SessionsTableProps) {
export default function SessionsTable({
sessions,
totalCount,
isFetching,
search,
onSearchChange,
kindFilter,
onKindFilterChange,
statusFilter,
onStatusFilterChange,
authModeFilter,
onAuthModeFilterChange,
hasActiveFilters,
onClearFilters,
offset,
limit,
onOffsetChange,
}: SessionsTableProps) {
const { toast } = useToast();
const [reauth, { isLoading: reauthing }] = useReauthMCPSessionMutation();
const [revoke, { isLoading: revoking }] = useRevokeMCPSessionMutation();
Expand Down Expand Up @@ -124,7 +157,20 @@ export default function SessionsTable({ sessions }: SessionsTableProps) {
</div>
</div>

<div className="overflow-auto rounded-sm border">
<SessionsFilterBar
search={search}
onSearchChange={onSearchChange}
kindFilter={kindFilter}
onKindFilterChange={onKindFilterChange}
statusFilter={statusFilter}
onStatusFilterChange={onStatusFilterChange}
authModeFilter={authModeFilter}
onAuthModeFilterChange={onAuthModeFilterChange}
hasActiveFilters={hasActiveFilters}
onClearFilters={onClearFilters}
/>

<div className={`overflow-auto rounded-sm border ${isFetching ? "opacity-70 transition-opacity" : ""}`}>
<Table>
<TableHeader>
<TableRow>
Expand Down Expand Up @@ -161,10 +207,17 @@ export default function SessionsTable({ sessions }: SessionsTableProps) {
{sessions.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
<span className="text-muted-foreground text-sm">
No sessions yet. Sessions appear here when an inference request or MCP gateway call triggers per-user authentication
(OAuth or header submission).
</span>
{hasActiveFilters ? (
<div className="text-muted-foreground text-sm">
No sessions match these filters.

</div>
) : (
<span className="text-muted-foreground text-sm">
No sessions yet. Sessions appear here when an inference request or MCP gateway call triggers per-user authentication
(OAuth or header submission).
</span>
)}
</TableCell>
</TableRow>
) : (
Expand Down Expand Up @@ -205,6 +258,36 @@ export default function SessionsTable({ sessions }: SessionsTableProps) {
</TableBody>
</Table>
</div>

{totalCount > 0 && (
<div className="flex shrink-0 items-center justify-between px-2 text-xs">
<p className="text-muted-foreground">
Showing {offset + 1}-{Math.min(offset + limit, totalCount)} of {totalCount}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={offset === 0}
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
data-testid="mcp-sessions-pagination-prev-btn"
>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={offset + limit >= totalCount}
onClick={() => onOffsetChange(offset + limit)}
data-testid="mcp-sessions-pagination-next-btn"
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
Expand Down
Loading
Loading