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>
233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|