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:
216
src/components/validation/ValidationPanel.tsx
Normal file
216
src/components/validation/ValidationPanel.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
useGenerateValidationSql,
|
||||
useRunValidationRule,
|
||||
useSuggestValidationRules,
|
||||
} from "@/hooks/use-validation";
|
||||
import { ValidationRuleCard } from "./ValidationRuleCard";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Sparkles, PlayCircle, Loader2, ShieldCheck } from "lucide-react";
|
||||
import type { ValidationRule, ValidationStatus } from "@/types";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export function ValidationPanel({ connectionId }: Props) {
|
||||
const [rules, setRules] = useState<ValidationRule[]>([]);
|
||||
const [ruleInput, setRuleInput] = useState("");
|
||||
const [runningIds, setRunningIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const generateSql = useGenerateValidationSql();
|
||||
const runRule = useRunValidationRule();
|
||||
const suggestRules = useSuggestValidationRules();
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id: string, updates: Partial<ValidationRule>) => {
|
||||
setRules((prev) =>
|
||||
prev.map((r) => (r.id === id ? { ...r, ...updates } : r))
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addRule = useCallback(
|
||||
async (description: string) => {
|
||||
const id = crypto.randomUUID();
|
||||
const newRule: ValidationRule = {
|
||||
id,
|
||||
description,
|
||||
generated_sql: "",
|
||||
status: "generating" as ValidationStatus,
|
||||
violation_count: 0,
|
||||
sample_violations: [],
|
||||
violation_columns: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
setRules((prev) => [...prev, newRule]);
|
||||
|
||||
try {
|
||||
const sql = await generateSql.mutateAsync({
|
||||
connectionId,
|
||||
ruleDescription: description,
|
||||
});
|
||||
updateRule(id, { generated_sql: sql, status: "pending" });
|
||||
} catch (err) {
|
||||
updateRule(id, {
|
||||
status: "error",
|
||||
error: String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
[connectionId, generateSql, updateRule]
|
||||
);
|
||||
|
||||
const handleAddRule = () => {
|
||||
if (!ruleInput.trim()) return;
|
||||
addRule(ruleInput.trim());
|
||||
setRuleInput("");
|
||||
};
|
||||
|
||||
const handleRunRule = useCallback(
|
||||
async (id: string) => {
|
||||
const rule = rules.find((r) => r.id === id);
|
||||
if (!rule || !rule.generated_sql) return;
|
||||
|
||||
setRunningIds((prev) => new Set(prev).add(id));
|
||||
updateRule(id, { status: "running" });
|
||||
|
||||
try {
|
||||
const result = await runRule.mutateAsync({
|
||||
connectionId,
|
||||
sql: rule.generated_sql,
|
||||
});
|
||||
updateRule(id, {
|
||||
status: result.status,
|
||||
violation_count: result.violation_count,
|
||||
sample_violations: result.sample_violations,
|
||||
violation_columns: result.violation_columns,
|
||||
error: result.error,
|
||||
});
|
||||
} catch (err) {
|
||||
updateRule(id, { status: "error", error: String(err) });
|
||||
} finally {
|
||||
setRunningIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[rules, connectionId, runRule, updateRule]
|
||||
);
|
||||
|
||||
const handleRemoveRule = useCallback((id: string) => {
|
||||
setRules((prev) => prev.filter((r) => r.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleRunAll = async () => {
|
||||
const runnableRules = rules.filter(
|
||||
(r) => r.generated_sql && r.status !== "generating"
|
||||
);
|
||||
for (const rule of runnableRules) {
|
||||
await handleRunRule(rule.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggest = async () => {
|
||||
try {
|
||||
const suggestions = await suggestRules.mutateAsync(connectionId);
|
||||
for (const desc of suggestions) {
|
||||
await addRule(desc);
|
||||
}
|
||||
toast.success(`Added ${suggestions.length} suggested rules`);
|
||||
} catch (err) {
|
||||
toast.error("Failed to suggest rules", { description: String(err) });
|
||||
}
|
||||
};
|
||||
|
||||
const passed = rules.filter((r) => r.status === "passed").length;
|
||||
const failed = rules.filter((r) => r.status === "failed").length;
|
||||
const errors = rules.filter((r) => r.status === "error").length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-sm font-medium">Data Validation</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSuggest}
|
||||
disabled={suggestRules.isPending}
|
||||
>
|
||||
{suggestRules.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
|
||||
) : (
|
||||
<Sparkles className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Auto-suggest
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRunAll}
|
||||
disabled={rules.length === 0 || runningIds.size > 0}
|
||||
>
|
||||
<PlayCircle className="h-3.5 w-3.5 mr-1" />
|
||||
Run All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Describe a data quality rule (e.g., 'All orders must have a positive total')"
|
||||
value={ruleInput}
|
||||
onChange={(e) => setRuleInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAddRule()}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="sm" onClick={handleAddRule} disabled={!ruleInput.trim()}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rules.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{rules.length} rules</span>
|
||||
{passed > 0 && <Badge className="bg-green-600 text-white text-[10px]">{passed} passed</Badge>}
|
||||
{failed > 0 && <Badge variant="destructive" className="text-[10px]">{failed} failed</Badge>}
|
||||
{errors > 0 && <Badge variant="outline" className="text-[10px]">{errors} errors</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rules List */}
|
||||
<div className="flex-1 overflow-auto p-4 space-y-2">
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Add a validation rule or click Auto-suggest to get started.
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<ValidationRuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onRun={() => handleRunRule(rule.id)}
|
||||
onRemove={() => handleRemoveRule(rule.id)}
|
||||
isRunning={runningIds.has(rule.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/components/validation/ValidationRuleCard.tsx
Normal file
138
src/components/validation/ValidationRuleCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { ValidationRule } from "@/types";
|
||||
|
||||
interface Props {
|
||||
rule: ValidationRule;
|
||||
onRun: () => void;
|
||||
onRemove: () => void;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case "passed":
|
||||
return <Badge className="bg-green-600 text-white">Passed</Badge>;
|
||||
case "failed":
|
||||
return <Badge variant="destructive">Failed</Badge>;
|
||||
case "error":
|
||||
return <Badge variant="outline" className="text-destructive border-destructive">Error</Badge>;
|
||||
case "generating":
|
||||
case "running":
|
||||
return <Badge variant="secondary"><Loader2 className="h-3 w-3 animate-spin mr-1" />Running</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">Pending</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
export function ValidationRuleCard({ rule, onRun, onRemove, isRunning }: Props) {
|
||||
const [showSql, setShowSql] = useState(false);
|
||||
const [showViolations, setShowViolations] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm">{rule.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{statusBadge(rule.status)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onRun}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rule.status === "failed" && (
|
||||
<p className="text-xs text-destructive">
|
||||
{rule.violation_count} violation{rule.violation_count !== 1 ? "s" : ""} found
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rule.error && (
|
||||
<p className="text-xs text-destructive">{rule.error}</p>
|
||||
)}
|
||||
|
||||
{rule.generated_sql && (
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowSql(!showSql)}
|
||||
>
|
||||
{showSql ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
SQL
|
||||
</button>
|
||||
{showSql && (
|
||||
<pre className="mt-1 rounded bg-muted p-2 text-xs font-mono overflow-x-auto max-h-32 overflow-y-auto">
|
||||
{rule.generated_sql}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.status === "failed" && rule.sample_violations.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowViolations(!showViolations)}
|
||||
>
|
||||
{showViolations ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Sample Violations ({rule.sample_violations.length})
|
||||
</button>
|
||||
{showViolations && (
|
||||
<div className="mt-1 overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{rule.violation_columns.map((col) => (
|
||||
<th key={col} className="px-2 py-1 text-left font-medium text-muted-foreground">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rule.sample_violations.map((row, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
{(row as unknown[]).map((val, j) => (
|
||||
<td key={j} className="px-2 py-1 font-mono">
|
||||
{val === null ? <span className="text-muted-foreground">NULL</span> : String(val)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user