MCP Server 2.0 (#452)

* MCP Server 2.0

- Updated application version from 2.23.14 to 2.24.0 in package.json.
- MCP Server improved with new features
- Migrated functionality from Dev Tools to MCP Server
- Improved getMonitoringProvider not to crash application when misconfigured
This commit is contained in:
Giancarlo Buomprisco
2026-02-11 20:42:01 +01:00
committed by GitHub
parent 059408a70a
commit f3ac595d06
123 changed files with 17803 additions and 5265 deletions

View File

@@ -0,0 +1,117 @@
'use client';
import { useState } from 'react';
import {
DatabaseIcon,
FunctionSquareIcon,
LayersIcon,
ListIcon,
SearchIcon,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kit/ui/tabs';
import type {
DatabaseEnum,
DatabaseFunction,
DatabaseTable,
SchemaFile,
} from '../_lib/server/database-tools.loader';
import { EnumBrowser } from './enum-browser';
import { FunctionBrowser } from './function-browser';
import { SchemaExplorer } from './schema-explorer';
import { TableBrowser } from './table-browser';
interface DatabaseToolsData {
schemaFiles: SchemaFile[];
tables: DatabaseTable[];
functions: DatabaseFunction[];
enums: DatabaseEnum[];
}
interface DatabaseToolsInterfaceProps {
searchTerm: string;
databaseData: DatabaseToolsData;
}
export function DatabaseToolsInterface({
searchTerm: initialSearchTerm,
databaseData,
}: DatabaseToolsInterfaceProps) {
const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
return (
<div className="space-y-6 p-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<DatabaseIcon className="h-6 w-6" />
<h1 className="text-2xl font-bold">Database Tools</h1>
</div>
<p className="text-muted-foreground">
Explore database schemas, tables, functions, and enums
</p>
</div>
</div>
{/* Search */}
<div className="relative max-w-md">
<SearchIcon className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search tables, functions, schemas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Database Tools Tabs */}
<Tabs defaultValue="schemas" className="space-y-6">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="schemas" className="flex items-center gap-2">
<LayersIcon className="h-4 w-4" />
Schemas
</TabsTrigger>
<TabsTrigger value="tables" className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4" />
Tables
</TabsTrigger>
<TabsTrigger value="functions" className="flex items-center gap-2">
<FunctionSquareIcon className="h-4 w-4" />
Functions
</TabsTrigger>
<TabsTrigger value="enums" className="flex items-center gap-2">
<ListIcon className="h-4 w-4" />
Enums
</TabsTrigger>
</TabsList>
<TabsContent value="schemas" className="space-y-4">
<SchemaExplorer
searchTerm={searchTerm}
schemaFiles={databaseData.schemaFiles}
/>
</TabsContent>
<TabsContent value="tables" className="space-y-4">
<TableBrowser searchTerm={searchTerm} tables={databaseData.tables} />
</TabsContent>
<TabsContent value="functions" className="space-y-4">
<FunctionBrowser
searchTerm={searchTerm}
functions={databaseData.functions}
/>
</TabsContent>
<TabsContent value="enums" className="space-y-4">
<EnumBrowser searchTerm={searchTerm} enums={databaseData.enums} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,384 @@
'use client';
import { useState } from 'react';
import {
BellIcon,
CheckCircleIcon,
CircleDollarSignIcon,
CreditCardIcon,
DatabaseIcon,
ListIcon,
ShieldIcon,
TagIcon,
} from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Separator } from '@kit/ui/separator';
interface DatabaseEnum {
name: string;
values: string[];
sourceFile: string;
category: string;
description: string;
}
interface EnumBrowserProps {
searchTerm: string;
enums: DatabaseEnum[];
}
const categoryColors: Record<string, string> = {
'Security & Permissions': 'bg-red-100 text-red-800',
'Billing & Payments': 'bg-green-100 text-green-800',
Notifications: 'bg-blue-100 text-blue-800',
};
const categoryIcons: Record<string, React.ComponentType<any>> = {
'Security & Permissions': ShieldIcon,
'Billing & Payments': CreditCardIcon,
Notifications: BellIcon,
};
const valueColors: Record<string, string> = {
// Permission colors
'roles.manage': 'bg-purple-100 text-purple-800',
'billing.manage': 'bg-green-100 text-green-800',
'settings.manage': 'bg-blue-100 text-blue-800',
'members.manage': 'bg-orange-100 text-orange-800',
'invites.manage': 'bg-teal-100 text-teal-800',
// Payment provider colors
stripe: 'bg-purple-100 text-purple-800',
'lemon-squeezy': 'bg-yellow-100 text-yellow-800',
paddle: 'bg-blue-100 text-blue-800',
// Notification channel colors
in_app: 'bg-blue-100 text-blue-800',
email: 'bg-green-100 text-green-800',
// Notification type colors
info: 'bg-blue-100 text-blue-800',
warning: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
// Payment status colors
pending: 'bg-yellow-100 text-yellow-800',
succeeded: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
// Subscription item type colors
flat: 'bg-gray-100 text-gray-800',
per_seat: 'bg-orange-100 text-orange-800',
metered: 'bg-purple-100 text-purple-800',
// Subscription status colors
active: 'bg-green-100 text-green-800',
trialing: 'bg-blue-100 text-blue-800',
past_due: 'bg-yellow-100 text-yellow-800',
canceled: 'bg-red-100 text-red-800',
unpaid: 'bg-red-100 text-red-800',
incomplete: 'bg-gray-100 text-gray-800',
incomplete_expired: 'bg-gray-100 text-gray-800',
paused: 'bg-gray-100 text-gray-800',
};
export function EnumBrowser({ searchTerm, enums }: EnumBrowserProps) {
const [selectedEnum, setSelectedEnum] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Filter enums based on search term
const filteredEnums = enums.filter(
(enumItem) =>
enumItem.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
enumItem.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
enumItem.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
enumItem.values.some((value) =>
value.toLowerCase().includes(searchTerm.toLowerCase()),
),
);
// Group enums by category
const enumsByCategory = filteredEnums.reduce(
(acc, enumItem) => {
if (!acc[enumItem.category]) {
acc[enumItem.category] = [];
}
acc[enumItem.category].push(enumItem);
return acc;
},
{} as Record<string, DatabaseEnum[]>,
);
const handleEnumClick = (enumName: string) => {
setSelectedEnum(enumName);
setIsDialogOpen(true);
};
const closeDialog = () => {
setIsDialogOpen(false);
setSelectedEnum(null);
};
const getValueBadgeColor = (value: string): string => {
return valueColors[value] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
{/* Summary */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Total Enums
</p>
<p className="text-2xl font-bold">{filteredEnums.length}</p>
</div>
<ListIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Categories
</p>
<p className="text-2xl font-bold">
{Object.keys(enumsByCategory).length}
</p>
</div>
<TagIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Total Values
</p>
<p className="text-2xl font-bold">
{filteredEnums.reduce(
(acc, enumItem) => acc + enumItem.values.length,
0,
)}
</p>
</div>
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
</div>
{/* Enums by Category */}
{Object.entries(enumsByCategory).map(([category, categoryEnums]) => {
const IconComponent = categoryIcons[category] || ListIcon;
return (
<div key={category} className="space-y-4">
<div className="flex items-center gap-2">
<Badge
className={
categoryColors[category] || 'bg-gray-100 text-gray-800'
}
>
<IconComponent className="mr-1 h-3 w-3" />
{category}
</Badge>
<span className="text-muted-foreground text-sm">
{categoryEnums.length} enum
{categoryEnums.length !== 1 ? 's' : ''}
</span>
</div>
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
{categoryEnums.map((enumItem) => (
<Card
key={enumItem.name}
className="cursor-pointer transition-all hover:shadow-md"
onClick={() => handleEnumClick(enumItem.name)}
>
<CardHeader className="pb-3">
<CardTitle className="flex items-start justify-between gap-2 text-sm">
<span className="flex items-center gap-2">
<ListIcon className="text-muted-foreground h-4 w-4" />
<span className="font-mono">{enumItem.name}</span>
</span>
<Badge variant="outline" className="text-xs">
{enumItem.values.length} value
{enumItem.values.length !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground line-clamp-2 text-sm">
{enumItem.description}
</p>
<div className="flex flex-wrap gap-1">
{enumItem.values.slice(0, 4).map((value) => (
<Badge
key={value}
variant="outline"
className={`text-xs ${getValueBadgeColor(value)}`}
>
{value}
</Badge>
))}
{enumItem.values.length > 4 && (
<Badge variant="secondary" className="text-xs">
+{enumItem.values.length - 4} more
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-xs">
{enumItem.sourceFile}
</span>
<Badge variant="secondary" className="text-xs">
Click for details
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
})}
{/* Enum Details Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ListIcon className="h-5 w-5" />
Enum Details: {selectedEnum}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{(() => {
const enumItem = enums.find((e) => e.name === selectedEnum);
if (!enumItem) return null;
return (
<>
<div>
<h4 className="mb-2 font-medium">Description</h4>
<p className="text-muted-foreground text-sm">
{enumItem.description}
</p>
</div>
<Separator />
<div>
<h4 className="mb-3 font-medium">
Values ({enumItem.values.length})
</h4>
<div className="grid gap-2 md:grid-cols-2 lg:grid-cols-3">
{enumItem.values.map((value, index) => (
<div
key={value}
className="flex items-center gap-2 rounded border p-2"
>
<span className="text-muted-foreground font-mono text-xs">
{index + 1}.
</span>
<Badge
variant="outline"
className={`text-sm ${getValueBadgeColor(value)}`}
>
{value}
</Badge>
</div>
))}
</div>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="mb-2 font-medium">Category</h4>
<Badge
className={
categoryColors[enumItem.category] ||
'bg-gray-100 text-gray-800'
}
>
<TagIcon className="mr-1 h-3 w-3" />
{enumItem.category}
</Badge>
</div>
<div>
<h4 className="mb-2 font-medium">Source File</h4>
<span className="text-muted-foreground font-mono text-sm">
{enumItem.sourceFile}
</span>
</div>
</div>
<Separator />
<div>
<h4 className="mb-2 font-medium">Usage Examples</h4>
<div className="space-y-2">
<div className="bg-muted rounded p-3">
<code className="text-muted-foreground text-sm">
CREATE TABLE example_table (
</code>
<br />
<code className="text-muted-foreground ml-4 text-sm">
id uuid PRIMARY KEY,
</code>
<br />
<code className="text-muted-foreground ml-4 text-sm">
status {enumItem.name} NOT NULL
</code>
<br />
<code className="text-muted-foreground text-sm">
);
</code>
</div>
<div className="bg-muted rounded p-3">
<code className="text-muted-foreground text-sm">
SELECT * FROM table_name WHERE status = '
{enumItem.values[0]}';
</code>
</div>
</div>
</div>
</>
);
})()}
</div>
</DialogContent>
</Dialog>
{filteredEnums.length === 0 && (
<Card>
<CardContent className="flex h-32 items-center justify-center">
<div className="text-center">
<ListIcon className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2">
{searchTerm ? 'No enums match your search' : 'No enums found'}
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,333 @@
'use client';
import { useState } from 'react';
import {
ArrowRightIcon,
DatabaseIcon,
FunctionSquareIcon,
KeyIcon,
ShieldIcon,
UserIcon,
ZapIcon,
} from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Separator } from '@kit/ui/separator';
interface DatabaseFunction {
name: string;
signature: string;
returnType: string;
purpose: string;
source: string;
isSecurityDefiner: boolean;
isTrigger: boolean;
category: string;
}
interface FunctionBrowserProps {
searchTerm: string;
functions: DatabaseFunction[];
}
const categoryColors: Record<string, string> = {
Triggers: 'bg-red-100 text-red-800',
Authentication: 'bg-indigo-100 text-indigo-800',
Accounts: 'bg-green-100 text-green-800',
Permissions: 'bg-orange-100 text-orange-800',
Invitations: 'bg-teal-100 text-teal-800',
Billing: 'bg-yellow-100 text-yellow-800',
Utilities: 'bg-blue-100 text-blue-800',
'Text Processing': 'bg-purple-100 text-purple-800',
};
const categoryIcons: Record<string, React.ComponentType<any>> = {
Triggers: ZapIcon,
Authentication: ShieldIcon,
Accounts: UserIcon,
Permissions: KeyIcon,
Invitations: UserIcon,
Billing: DatabaseIcon,
Utilities: FunctionSquareIcon,
'Text Processing': FunctionSquareIcon,
};
export function FunctionBrowser({
searchTerm,
functions,
}: FunctionBrowserProps) {
const [selectedFunction, setSelectedFunction] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Filter functions based on search term
const filteredFunctions = functions.filter(
(func) =>
func.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
func.purpose.toLowerCase().includes(searchTerm.toLowerCase()) ||
func.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
func.source.toLowerCase().includes(searchTerm.toLowerCase()),
);
// Group functions by category
const functionsByCategory = filteredFunctions.reduce(
(acc, func) => {
if (!acc[func.category]) {
acc[func.category] = [];
}
acc[func.category].push(func);
return acc;
},
{} as Record<string, DatabaseFunction[]>,
);
const handleFunctionClick = (functionName: string) => {
setSelectedFunction(functionName);
setIsDialogOpen(true);
};
const getReturnTypeColor = (returnType: string): string => {
if (returnType === 'trigger') return 'bg-red-100 text-red-800';
if (returnType === 'boolean') return 'bg-green-100 text-green-800';
if (returnType === 'uuid') return 'bg-purple-100 text-purple-800';
if (returnType === 'text') return 'bg-blue-100 text-blue-800';
if (returnType === 'json' || returnType === 'jsonb')
return 'bg-yellow-100 text-yellow-800';
if (returnType === 'TABLE') return 'bg-orange-100 text-orange-800';
return 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
{/* Summary */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Total Functions
</p>
<p className="text-2xl font-bold">{filteredFunctions.length}</p>
</div>
<FunctionSquareIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Categories
</p>
<p className="text-2xl font-bold">
{Object.keys(functionsByCategory).length}
</p>
</div>
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Security Definer
</p>
<p className="text-2xl font-bold">
{filteredFunctions.filter((f) => f.isSecurityDefiner).length}
</p>
</div>
<ShieldIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
</div>
{/* Functions by Category */}
{Object.entries(functionsByCategory).map(
([category, categoryFunctions]) => {
const IconComponent = categoryIcons[category] || FunctionSquareIcon;
return (
<div key={category} className="space-y-4">
<div className="flex items-center gap-2">
<Badge
className={
categoryColors[category] || 'bg-gray-100 text-gray-800'
}
>
<IconComponent className="mr-1 h-3 w-3" />
{category}
</Badge>
<span className="text-muted-foreground text-sm">
{categoryFunctions.length} function
{categoryFunctions.length !== 1 ? 's' : ''}
</span>
</div>
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-2">
{categoryFunctions.map((func) => (
<Card
key={func.name}
className="cursor-pointer transition-all hover:shadow-md"
onClick={() => handleFunctionClick(func.name)}
>
<CardHeader className="pb-3">
<CardTitle className="flex items-start justify-between gap-2 text-sm">
<span className="flex items-center gap-2">
<FunctionSquareIcon className="text-muted-foreground h-4 w-4" />
<span className="font-mono">{func.name}</span>
</span>
<div className="flex items-center gap-1">
{func.isSecurityDefiner && (
<Badge variant="default" className="text-xs">
<ShieldIcon className="mr-1 h-3 w-3" />
DEFINER
</Badge>
)}
{func.isTrigger && (
<Badge variant="outline" className="text-xs">
<ZapIcon className="mr-1 h-3 w-3" />
TRIGGER
</Badge>
)}
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<ArrowRightIcon className="text-muted-foreground h-3 w-3" />
<Badge
variant="outline"
className={`text-xs ${getReturnTypeColor(func.returnType)}`}
>
{func.returnType}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-sm">
{func.purpose}
</p>
<div className="flex items-center justify-between">
<span className="text-muted-foreground font-mono text-xs">
{func.source}
</span>
<Badge variant="secondary" className="text-xs">
Click for details
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
},
)}
{/* Function Details Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FunctionSquareIcon className="h-5 w-5" />
Function Details: {selectedFunction}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{(() => {
const func = functions.find((f) => f.name === selectedFunction);
if (!func) return null;
return (
<>
<div>
<h4 className="mb-2 font-medium">Signature</h4>
<code className="text-muted-foreground bg-muted block rounded p-3 text-sm">
{func.signature}
</code>
</div>
<Separator />
<div>
<h4 className="mb-2 font-medium">Purpose</h4>
<p className="text-muted-foreground text-sm">
{func.purpose}
</p>
</div>
<Separator />
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="mb-2 font-medium">Return Type</h4>
<Badge
variant="outline"
className={getReturnTypeColor(func.returnType)}
>
{func.returnType}
</Badge>
</div>
<div>
<h4 className="mb-2 font-medium">Source File</h4>
<span className="text-muted-foreground font-mono text-sm">
{func.source}
</span>
</div>
</div>
<Separator />
<div>
<h4 className="mb-2 font-medium">Properties</h4>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">
<DatabaseIcon className="mr-1 h-3 w-3" />
{func.category}
</Badge>
{func.isSecurityDefiner && (
<Badge variant="default">
<ShieldIcon className="mr-1 h-3 w-3" />
Security Definer
</Badge>
)}
{func.isTrigger && (
<Badge variant="secondary">
<ZapIcon className="mr-1 h-3 w-3" />
Trigger Function
</Badge>
)}
</div>
</div>
</>
);
})()}
</div>
</DialogContent>
</Dialog>
{filteredFunctions.length === 0 && (
<Card>
<CardContent className="flex h-32 items-center justify-center">
<div className="text-center">
<FunctionSquareIcon className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2">
{searchTerm
? 'No functions match your search'
: 'No functions found'}
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import { useState } from 'react';
import { FileTextIcon, LayersIcon } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Separator } from '@kit/ui/separator';
interface SchemaFile {
filename: string;
topic: string;
description: string;
section?: string;
tables?: string[];
functions?: string[];
}
interface SchemaExplorerProps {
searchTerm: string;
schemaFiles: SchemaFile[];
}
const topicColors: Record<string, string> = {
security: 'bg-red-100 text-red-800',
types: 'bg-purple-100 text-purple-800',
configuration: 'bg-blue-100 text-blue-800',
accounts: 'bg-green-100 text-green-800',
permissions: 'bg-orange-100 text-orange-800',
teams: 'bg-teal-100 text-teal-800',
billing: 'bg-yellow-100 text-yellow-800',
notifications: 'bg-pink-100 text-pink-800',
auth: 'bg-indigo-100 text-indigo-800',
admin: 'bg-gray-100 text-gray-800',
storage: 'bg-cyan-100 text-cyan-800',
};
export function SchemaExplorer({
searchTerm,
schemaFiles,
}: SchemaExplorerProps) {
const [selectedSchema, setSelectedSchema] = useState<SchemaFile | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Filter schemas based on search term
const filteredSchemas = schemaFiles.filter(
(schema) =>
schema.filename.toLowerCase().includes(searchTerm.toLowerCase()) ||
schema.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
schema.topic.toLowerCase().includes(searchTerm.toLowerCase()) ||
schema.section?.toLowerCase().includes(searchTerm.toLowerCase()),
);
// Group schemas by topic for better organization
const schemasByTopic = filteredSchemas.reduce(
(acc, schema) => {
if (!acc[schema.topic]) {
acc[schema.topic] = [];
}
acc[schema.topic].push(schema);
return acc;
},
{} as Record<string, SchemaFile[]>,
);
const handleSchemaClick = (schema: SchemaFile) => {
setSelectedSchema(schema);
setIsDialogOpen(true);
};
const closeDialog = () => {
setIsDialogOpen(false);
setSelectedSchema(null);
};
return (
<div className="space-y-6">
{/* Summary */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Total Schemas
</p>
<p className="text-2xl font-bold">{filteredSchemas.length}</p>
</div>
<LayersIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Topics
</p>
<p className="text-2xl font-bold">
{Object.keys(schemasByTopic).length}
</p>
</div>
<FileTextIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Total Tables
</p>
<p className="text-2xl font-bold">
{filteredSchemas.reduce(
(acc, schema) => acc + (schema.tables?.length || 0),
0,
)}
</p>
</div>
<LayersIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
</div>
{/* Schema Files by Topic */}
{Object.entries(schemasByTopic).map(([topic, schemas]) => (
<div key={topic} className="space-y-4">
<div className="flex items-center gap-2">
<Badge
className={topicColors[topic] || 'bg-gray-100 text-gray-800'}
>
{topic.toUpperCase()}
</Badge>
<span className="text-muted-foreground text-sm">
{schemas.length} file{schemas.length !== 1 ? 's' : ''}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{schemas.map((schema) => (
<Card
key={schema.filename}
className="cursor-pointer transition-shadow hover:shadow-md"
onClick={() => handleSchemaClick(schema)}
>
<CardHeader className="pb-3">
<CardTitle className="flex items-start justify-between gap-2 text-sm">
<span className="line-clamp-1 flex-1">
{schema.filename}
</span>
<FileTextIcon className="text-muted-foreground h-4 w-4" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground line-clamp-2 text-sm">
{schema.description}
</p>
{schema.section && (
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs">
Section:
</span>
<Badge variant="outline" className="text-xs">
{schema.section}
</Badge>
</div>
)}
{(schema.tables || schema.functions) && (
<div className="space-y-2 text-xs">
{schema.tables && schema.tables.length > 0 && (
<div>
<span className="font-medium">Tables: </span>
<span className="text-muted-foreground">
{schema.tables.slice(0, 3).join(', ')}
{schema.tables.length > 3 &&
` +${schema.tables.length - 3} more`}
</span>
</div>
)}
{schema.functions && schema.functions.length > 0 && (
<div>
<span className="font-medium">Functions: </span>
<span className="text-muted-foreground">
{schema.functions.slice(0, 2).join(', ')}
{schema.functions.length > 2 &&
` +${schema.functions.length - 2} more`}
</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
))}
{/* Schema Details Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileTextIcon className="h-5 w-5" />
{selectedSchema?.filename}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{selectedSchema && (
<>
<div>
<h4 className="mb-2 font-medium">Description</h4>
<p className="text-muted-foreground text-sm">
{selectedSchema.description}
</p>
</div>
{selectedSchema.section && (
<>
<Separator />
<div>
<h4 className="mb-2 font-medium">Section</h4>
<Badge variant="outline">{selectedSchema.section}</Badge>
</div>
</>
)}
{selectedSchema.tables && selectedSchema.tables.length > 0 && (
<>
<Separator />
<div>
<h4 className="mb-2 font-medium">
Tables ({selectedSchema.tables.length})
</h4>
<div className="flex flex-wrap gap-2">
{selectedSchema.tables.map((table) => (
<Badge
key={table}
variant="secondary"
className="text-xs"
>
{table}
</Badge>
))}
</div>
</div>
</>
)}
{selectedSchema.functions &&
selectedSchema.functions.length > 0 && (
<>
<Separator />
<div>
<h4 className="mb-2 font-medium">
Functions ({selectedSchema.functions.length})
</h4>
<div className="flex flex-wrap gap-2">
{selectedSchema.functions.map((func) => (
<Badge
key={func}
variant="outline"
className="text-xs"
>
{func}
</Badge>
))}
</div>
</div>
</>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
{filteredSchemas.length === 0 && (
<Card>
<CardContent className="flex h-32 items-center justify-center">
<div className="text-center">
<LayersIcon className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2">
{searchTerm
? 'No schemas match your search'
: 'No schemas found'}
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,431 @@
'use client';
import { useState } from 'react';
import { DatabaseIcon, KeyIcon, LinkIcon, TableIcon } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { Separator } from '@kit/ui/separator';
import { getTableDetailsAction } from '../_lib/server/table-server-actions';
interface TableColumn {
name: string;
type: string;
nullable: boolean;
defaultValue: string | null;
isPrimaryKey: boolean;
isForeignKey: boolean;
referencedTable: string | null;
referencedColumn: string | null;
}
interface TableInfo {
name: string;
schema: string;
sourceFile: string;
topic: string;
columns: TableColumn[];
foreignKeys: Array<{
name: string;
columns: string[];
referencedTable: string;
referencedColumns: string[];
onDelete: string;
onUpdate: string;
}>;
indexes: Array<{
name: string;
columns: string[];
unique: boolean;
type: string;
}>;
}
interface TableSummary {
name: string;
schema: string;
sourceFile: string;
topic: string;
}
interface TableBrowserProps {
searchTerm: string;
tables: TableSummary[];
}
const topicColors: Record<string, string> = {
accounts: 'bg-green-100 text-green-800',
teams: 'bg-teal-100 text-teal-800',
billing: 'bg-yellow-100 text-yellow-800',
configuration: 'bg-blue-100 text-blue-800',
auth: 'bg-indigo-100 text-indigo-800',
notifications: 'bg-pink-100 text-pink-800',
permissions: 'bg-orange-100 text-orange-800',
general: 'bg-gray-100 text-gray-800',
};
const typeColors: Record<string, string> = {
uuid: 'bg-purple-100 text-purple-800',
text: 'bg-blue-100 text-blue-800',
'character varying': 'bg-blue-100 text-blue-800',
boolean: 'bg-green-100 text-green-800',
integer: 'bg-orange-100 text-orange-800',
'timestamp with time zone': 'bg-gray-100 text-gray-800',
jsonb: 'bg-yellow-100 text-yellow-800',
'USER-DEFINED': 'bg-red-100 text-red-800',
};
export function TableBrowser({ searchTerm, tables }: TableBrowserProps) {
const [selectedTable, setSelectedTable] = useState<string | null>(null);
const [tableDetails, setTableDetails] = useState<TableInfo | null>(null);
const [loading, setLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
// Filter tables based on search term
const filteredTables = tables.filter(
(table) =>
table.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.topic.toLowerCase().includes(searchTerm.toLowerCase()) ||
table.sourceFile.toLowerCase().includes(searchTerm.toLowerCase()),
);
// Group tables by topic
const tablesByTopic = filteredTables.reduce(
(acc, table) => {
if (!acc[table.topic]) {
acc[table.topic] = [];
}
acc[table.topic].push(table);
return acc;
},
{} as Record<string, TableSummary[]>,
);
const handleTableClick = async (tableName: string) => {
setSelectedTable(tableName);
setIsDialogOpen(true);
setLoading(true);
setTableDetails(null);
try {
const result = await getTableDetailsAction(tableName, 'public');
if (result.success && result.data) {
setTableDetails(result.data);
} else {
console.error('Error fetching table details:', result.error);
setTableDetails(null);
}
} catch (error) {
console.error('Error fetching table details:', error);
setTableDetails(null);
} finally {
setLoading(false);
}
};
const closeDialog = () => {
setIsDialogOpen(false);
setSelectedTable(null);
setTableDetails(null);
};
const getColumnTypeDisplay = (type: string) => {
const cleanType = type.replace('character varying', 'varchar');
return cleanType;
};
return (
<div className="space-y-6">
{/* Summary */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Total Tables
</p>
<p className="text-2xl font-bold">{filteredTables.length}</p>
</div>
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Topics
</p>
<p className="text-2xl font-bold">
{Object.keys(tablesByTopic).length}
</p>
</div>
<TableIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between p-6">
<div>
<p className="text-muted-foreground text-sm font-medium">
Schema
</p>
<p className="text-2xl font-bold">public</p>
</div>
<DatabaseIcon className="text-muted-foreground h-8 w-8" />
</CardContent>
</Card>
</div>
{/* Tables by Topic */}
{Object.entries(tablesByTopic).map(([topic, topicTables]) => (
<div key={topic} className="space-y-4">
<div className="flex items-center gap-2">
<Badge
className={topicColors[topic] || 'bg-gray-100 text-gray-800'}
>
{topic.toUpperCase()}
</Badge>
<span className="text-muted-foreground text-sm">
{topicTables.length} table{topicTables.length !== 1 ? 's' : ''}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{topicTables.map((table) => (
<Card
key={table.name}
className="cursor-pointer transition-all hover:shadow-md"
onClick={() => handleTableClick(table.name)}
>
<CardHeader className="pb-3">
<CardTitle className="flex items-start justify-between gap-2 text-sm">
<span className="flex items-center gap-2">
<TableIcon className="text-muted-foreground h-4 w-4" />
<span className="font-mono">{table.name}</span>
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
Schema:
</span>
<Badge variant="outline" className="text-xs">
{table.schema}
</Badge>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
Source:
</span>
<span className="text-muted-foreground font-mono text-xs">
{table.sourceFile}
</span>
</div>
<div className="pt-2">
<Badge variant="secondary" className="text-xs">
Click for details
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
{/* Table Details Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-h-[80vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TableIcon className="h-5 w-5" />
Table Details: {selectedTable}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{loading && (
<div className="py-8 text-center">
<p className="text-muted-foreground">
Loading table details...
</p>
</div>
)}
{selectedTable && tableDetails && (
<>
{/* Columns */}
<div>
<h4 className="mb-3 font-medium">
Columns ({tableDetails.columns.length})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 text-left">Name</th>
<th className="py-2 text-left">Type</th>
<th className="py-2 text-left">Nullable</th>
<th className="py-2 text-left">Default</th>
<th className="py-2 text-left">Constraints</th>
</tr>
</thead>
<tbody>
{tableDetails.columns.map((column) => (
<tr key={column.name} className="border-b">
<td className="py-2 font-mono">{column.name}</td>
<td className="py-2">
<Badge
variant="outline"
className={`text-xs ${
typeColors[column.type] ||
'bg-gray-100 text-gray-800'
}`}
>
{getColumnTypeDisplay(column.type)}
</Badge>
</td>
<td className="py-2">
<Badge
variant={
column.nullable ? 'secondary' : 'outline'
}
>
{column.nullable ? 'YES' : 'NO'}
</Badge>
</td>
<td className="text-muted-foreground py-2 font-mono text-xs">
{column.defaultValue || '—'}
</td>
<td className="py-2">
<div className="flex gap-1">
{column.isPrimaryKey && (
<Badge variant="default" className="text-xs">
<KeyIcon className="mr-1 h-3 w-3" />
PK
</Badge>
)}
{column.isForeignKey && (
<Badge variant="outline" className="text-xs">
<LinkIcon className="mr-1 h-3 w-3" />
FK
</Badge>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Foreign Keys */}
{tableDetails.foreignKeys.length > 0 && (
<>
<Separator />
<div>
<h4 className="mb-3 font-medium">
Foreign Keys ({tableDetails.foreignKeys.length})
</h4>
<div className="space-y-2">
{tableDetails.foreignKeys.map((fk) => (
<div
key={fk.name}
className="flex items-center gap-2 text-sm"
>
<LinkIcon className="text-muted-foreground h-4 w-4" />
<span className="font-mono">
{fk.columns.join(', ')}
</span>
<span className="text-muted-foreground"></span>
<span className="font-mono">
{fk.referencedTable}.
{fk.referencedColumns.join(', ')}
</span>
<Badge variant="outline" className="text-xs">
ON DELETE {fk.onDelete}
</Badge>
</div>
))}
</div>
</div>
</>
)}
{/* Indexes */}
{tableDetails.indexes.length > 0 && (
<>
<Separator />
<div>
<h4 className="mb-3 font-medium">
Indexes ({tableDetails.indexes.length})
</h4>
<div className="space-y-2">
{tableDetails.indexes.map((index) => (
<div
key={index.name}
className="flex items-center gap-2 text-sm"
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">
{index.name}
</span>
{index.unique && (
<Badge variant="default" className="text-xs">
UNIQUE
</Badge>
)}
<Badge variant="outline" className="text-xs">
{index.type.toUpperCase()}
</Badge>
</div>
<span className="text-muted-foreground">on</span>
<span className="font-mono text-xs">
{index.columns.join(', ')}
</span>
</div>
))}
</div>
</div>
</>
)}
</>
)}
{selectedTable && !tableDetails && !loading && (
<div className="py-8 text-center">
<p className="text-muted-foreground">
No detailed information available for this table.
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
{filteredTables.length === 0 && (
<Card>
<CardContent className="flex h-32 items-center justify-center">
<div className="text-center">
<DatabaseIcon className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2">
{searchTerm ? 'No tables match your search' : 'No tables found'}
</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}