feat: add AI data validation, test data generator, index advisor, and snapshots
Four new killer features leveraging AI (Ollama) and PostgreSQL internals: - Data Validation: describe quality rules in natural language, AI generates SQL to find violations, run with pass/fail results and sample violations - Test Data Generator: right-click table to generate realistic FK-aware test data with AI, preview before inserting in a transaction - Index Advisor: analyze pg_stat tables + AI recommendations for CREATE/DROP INDEX with one-click apply - Data Snapshots: export selected tables to JSON (FK-ordered), restore from file with optional truncate in a transaction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
232
src/components/index-advisor/IndexAdvisorPanel.tsx
Normal file
232
src/components/index-advisor/IndexAdvisorPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useIndexAdvisorReport, useApplyIndexRecommendation } from "@/hooks/use-index-advisor";
|
||||
import { RecommendationCard } from "./RecommendationCard";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, Gauge, Search, AlertTriangle } from "lucide-react";
|
||||
import type { IndexAdvisorReport } from "@/types";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export function IndexAdvisorPanel({ connectionId }: Props) {
|
||||
const [report, setReport] = useState<IndexAdvisorReport | null>(null);
|
||||
const [appliedDdls, setAppliedDdls] = useState<Set<string>>(new Set());
|
||||
const [applyingDdl, setApplyingDdl] = useState<string | null>(null);
|
||||
|
||||
const reportMutation = useIndexAdvisorReport();
|
||||
const applyMutation = useApplyIndexRecommendation();
|
||||
|
||||
const handleAnalyze = () => {
|
||||
reportMutation.mutate(connectionId, {
|
||||
onSuccess: (data) => {
|
||||
setReport(data);
|
||||
setAppliedDdls(new Set());
|
||||
},
|
||||
onError: (err) => toast.error("Analysis failed", { description: String(err) }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleApply = async (ddl: string) => {
|
||||
if (!confirm("Apply this index change? This will modify the database schema.")) return;
|
||||
|
||||
setApplyingDdl(ddl);
|
||||
try {
|
||||
await applyMutation.mutateAsync({ connectionId, ddl });
|
||||
setAppliedDdls((prev) => new Set(prev).add(ddl));
|
||||
toast.success("Index change applied");
|
||||
} catch (err) {
|
||||
toast.error("Failed to apply", { description: String(err) });
|
||||
} finally {
|
||||
setApplyingDdl(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-sm font-medium">Index Advisor</h2>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAnalyze}
|
||||
disabled={reportMutation.isPending}
|
||||
>
|
||||
{reportMutation.isPending ? (
|
||||
<><Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />Analyzing...</>
|
||||
) : (
|
||||
<><Search className="h-3.5 w-3.5 mr-1" />Analyze</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{!report ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Click Analyze to scan your database for index optimization opportunities.
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="recommendations" className="h-full flex flex-col">
|
||||
<div className="border-b px-4">
|
||||
<TabsList className="h-9">
|
||||
<TabsTrigger value="recommendations" className="text-xs">
|
||||
Recommendations
|
||||
{report.recommendations.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-[10px]">{report.recommendations.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="table-stats" className="text-xs">Table Stats</TabsTrigger>
|
||||
<TabsTrigger value="index-stats" className="text-xs">Index Stats</TabsTrigger>
|
||||
<TabsTrigger value="slow-queries" className="text-xs">
|
||||
Slow Queries
|
||||
{!report.has_pg_stat_statements && (
|
||||
<AlertTriangle className="h-3 w-3 ml-1 text-yellow-500" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="recommendations" className="flex-1 overflow-auto p-4 space-y-2 mt-0">
|
||||
{report.recommendations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
No recommendations found. Your indexes look good!
|
||||
</div>
|
||||
) : (
|
||||
report.recommendations.map((rec, i) => (
|
||||
<RecommendationCard
|
||||
key={rec.id || i}
|
||||
recommendation={rec}
|
||||
onApply={handleApply}
|
||||
isApplying={applyingDdl === rec.ddl}
|
||||
applied={appliedDdls.has(rec.ddl)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="table-stats" className="flex-1 overflow-auto mt-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">Table</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Seq Scans</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Idx Scans</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Rows</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Table Size</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Index Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.table_stats.map((ts) => {
|
||||
const ratio = ts.seq_scan + ts.idx_scan > 0
|
||||
? ts.seq_scan / (ts.seq_scan + ts.idx_scan)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={`${ts.schema}.${ts.table}`} className="border-b">
|
||||
<td className="px-3 py-2 font-mono">{ts.schema}.{ts.table}</td>
|
||||
<td className={`px-3 py-2 text-right ${ratio > 0.8 && ts.n_live_tup > 1000 ? "text-destructive font-medium" : ""}`}>
|
||||
{ts.seq_scan.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{ts.idx_scan.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right">{ts.n_live_tup.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right">{ts.table_size}</td>
|
||||
<td className="px-3 py-2 text-right">{ts.index_size}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="index-stats" className="flex-1 overflow-auto mt-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">Index</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Table</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Scans</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Size</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Definition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.index_stats.map((is) => (
|
||||
<tr key={`${is.schema}.${is.index_name}`} className="border-b">
|
||||
<td className={`px-3 py-2 font-mono ${is.idx_scan === 0 ? "text-yellow-600" : ""}`}>
|
||||
{is.index_name}
|
||||
</td>
|
||||
<td className="px-3 py-2">{is.schema}.{is.table}</td>
|
||||
<td className={`px-3 py-2 text-right ${is.idx_scan === 0 ? "text-yellow-600 font-medium" : ""}`}>
|
||||
{is.idx_scan.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{is.index_size}</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground max-w-xs truncate">
|
||||
{is.definition}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="slow-queries" className="flex-1 overflow-auto mt-0">
|
||||
{!report.has_pg_stat_statements ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
pg_stat_statements extension is not installed
|
||||
</div>
|
||||
<p className="text-xs">
|
||||
Enable it with: CREATE EXTENSION pg_stat_statements;
|
||||
</p>
|
||||
</div>
|
||||
) : report.slow_queries.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground text-center">
|
||||
No slow queries found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">Query</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Calls</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Mean (ms)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Total (ms)</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Rows</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.slow_queries.map((sq, i) => (
|
||||
<tr key={i} className="border-b">
|
||||
<td className="px-3 py-2 font-mono max-w-md truncate" title={sq.query}>
|
||||
{sq.query.substring(0, 150)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">{sq.calls.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right">{sq.mean_time_ms.toFixed(1)}</td>
|
||||
<td className="px-3 py-2 text-right">{sq.total_time_ms.toFixed(0)}</td>
|
||||
<td className="px-3 py-2 text-right">{sq.rows.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user