ewdlop's picture
App.tsx
42ae0b9
Raw
History Blame Contribute Delete
15 kB
import { useState, useRef, useCallback, useEffect } from "react";
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
import { Play, Loader2, BookOpen, Zap, Database } from "lucide-react";
import { toast } from "sonner";
import { Link } from "wouter";
import { api, type LintResult, type ParseResult, type FormatResult, type InjectionResult, type HealthResult } from "@/lib/api";
import { SqlEditor } from "@/components/SqlEditor";
import { AstTreeView } from "@/components/AstTreeView";
import { LintPanel } from "@/components/LintPanel";
import { InjectionPanel } from "@/components/InjectionPanel";
import { FormatterPanel } from "@/components/FormatterPanel";
// ── Constants ──────────────────────────────────────────────────────────────
type AnalysisTab = "ast" | "lint" | "injection" | "formatted";
const TABS: { id: AnalysisTab; label: string }[] = [
{ id: "ast", label: "AST Tree" },
{ id: "lint", label: "Lint" },
{ id: "injection", label: "Injection" },
{ id: "formatted", label: "Formatted" },
];
const DIALECTS = [
{ value: "ansi", label: "ANSI SQL" },
{ value: "postgres", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL" },
{ value: "tsql", label: "T-SQL" },
{ value: "sqlite", label: "SQLite" },
{ value: "bigquery", label: "BigQuery" },
{ value: "snowflake", label: "Snowflake" },
{ value: "redshift", label: "Redshift" },
{ value: "duckdb", label: "DuckDB" },
{ value: "hive", label: "Hive" },
{ value: "sparksql", label: "Spark SQL" },
{ value: "trino", label: "Trino" },
{ value: "databricks", label: "Databricks" },
{ value: "oracle", label: "Oracle" },
{ value: "teradata", label: "Teradata" },
{ value: "clickhouse", label: "ClickHouse" },
{ value: "athena", label: "Athena" },
];
const DEFAULT_SQL = `-- SQL Analyzer Demo
-- Press Ctrl+Enter (or Cmd+Enter) to analyze
SELECT
u.id,
u.name,
u.email,
o.total,
o.created_at
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE u.active = 1
AND o.created_at >= '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 50;`;
const INJECTION_SQL = `-- SQL Injection Demo
-- This query contains several injection patterns
SELECT * FROM users
WHERE username = 'admin' OR 1=1 --
AND password = 'anything';
-- Stacked query attempt
SELECT * FROM products WHERE id = 1;
DROP TABLE users;
-- UNION-based exfiltration
SELECT id, name FROM products
UNION SELECT username, password FROM users;`;
// ── Status indicator ───────────────────────────────────────────────────────
function StatusDot({ health }: { health: HealthResult | null }) {
const isOk = health?.status === "ok";
return (
<div className="flex items-center gap-1.5">
<div
className={`w-1.5 h-1.5 rounded-full ${isOk ? "animate-pulse" : ""}`}
style={{ background: isOk ? "var(--accent-green)" : "var(--accent-yellow)" }}
/>
<span className="text-[10px]" style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
{isOk ? `SQLFluff ${health!.version}` : "starting…"}
</span>
</div>
);
}
// ── Main page ──────────────────────────────────────────────────────────────
export default function Analyzer() {
const [sql, setSql] = useState(DEFAULT_SQL);
const [dialect, setDialect] = useState("ansi");
const [activeTab, setActiveTab] = useState<AnalysisTab>("ast");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [health, setHealth] = useState<HealthResult | null>(null);
const [lintResult, setLintResult] = useState<LintResult | null>(null);
const [parseResult, setParseResult] = useState<ParseResult | null>(null);
const [formatResult, setFormatResult] = useState<FormatResult | null>(null);
const [injectResult, setInjectResult] = useState<InjectionResult | null>(null);
const [analysisTime, setAnalysisTime] = useState<number | null>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
// Poll health endpoint every 6 s until ready
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const h = await api.health();
if (!cancelled) setHealth(h);
} catch {
// still starting
}
}
poll();
const id = setInterval(poll, 6000);
return () => { cancelled = true; clearInterval(id); };
}, []);
// ── Analyze ────────────────────────────────────────────────────────────
const handleAnalyze = useCallback(async () => {
if (!sql.trim()) { toast.error("Please enter some SQL to analyze"); return; }
if (health?.status !== "ok") { toast.error("API is not ready yet — please wait a moment"); return; }
setIsAnalyzing(true);
const start = Date.now();
try {
const [lint, parse, format, inject] = await Promise.allSettled([
api.lint(sql, dialect),
api.parse(sql, dialect),
api.format(sql, dialect),
api.inject(sql, dialect),
]);
if (lint.status === "fulfilled") setLintResult(lint.value);
if (parse.status === "fulfilled") setParseResult(parse.value);
if (format.status === "fulfilled") setFormatResult(format.value);
if (inject.status === "fulfilled") setInjectResult(inject.value);
setAnalysisTime(Date.now() - start);
const lv = lint.status === "fulfilled" ? lint.value : null;
const iv = inject.status === "fulfilled" ? inject.value : null;
if (lv && iv) {
if (!lv.passed || !iv.safe) {
toast.warning(
`Analysis complete — ${lv.stats.total} lint issue${lv.stats.total !== 1 ? "s" : ""}${!iv.safe ? `, risk score ${iv.risk_score}/100` : ""}`,
{ duration: 4000 }
);
} else {
toast.success("Analysis complete — no issues found", { duration: 3000 });
}
}
} catch (err) {
toast.error((err instanceof Error ? err.message : "Analysis failed").slice(0, 120));
} finally {
setIsAnalyzing(false);
}
}, [sql, dialect, health]);
// ── Jump to line ───────────────────────────────────────────────────────
const jumpToLine = useCallback((lineNo: number) => {
const container = editorContainerRef.current;
if (!container) return;
const editorEl = container.querySelector("[data-editor-container]");
if (!editorEl) return;
editorEl.dispatchEvent(new CustomEvent("jump-to-line", { detail: lineNo }));
}, []);
const lintBadge = lintResult && !lintResult.passed ? lintResult.stats.total : null;
const injectBadge = injectResult && !injectResult.safe ? injectResult.patterns.length : null;
// ── Render ─────────────────────────────────────────────────────────────
return (
<div className="flex flex-col h-screen" style={{ background: "var(--bg-base)", color: "var(--text-primary)" }}>
{/* Header */}
<header
className="flex items-center gap-3 px-4 py-2.5 flex-shrink-0"
style={{ borderBottom: "1px solid var(--border)", background: "var(--bg-surface)" }}
>
{/* Logo */}
<div className="flex items-center gap-2 mr-2">
<div
className="w-7 h-7 rounded flex items-center justify-center"
style={{ background: "rgba(88,166,255,0.12)", border: "1px solid rgba(88,166,255,0.25)" }}
>
<Database size={14} style={{ color: "var(--accent-blue)" }} />
</div>
<span className="text-sm font-semibold tracking-tight">SQL Analyzer</span>
</div>
{/* Dialect selector */}
<select
value={dialect}
onChange={(e) => setDialect(e.target.value)}
className="h-7 text-xs rounded px-2 pr-6 appearance-none cursor-pointer"
style={{
background: "var(--bg-elevated)",
border: "1px solid var(--border)",
color: "var(--text-primary)",
fontFamily: "var(--font-mono)",
outline: "none",
}}
>
{DIALECTS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
{/* Example loader */}
<select
defaultValue=""
onChange={(e) => {
if (e.target.value === "demo") setSql(DEFAULT_SQL);
else if (e.target.value === "injection") setSql(INJECTION_SQL);
e.target.value = "";
}}
className="h-7 text-xs rounded px-2 pr-6 appearance-none cursor-pointer"
style={{
background: "var(--bg-elevated)",
border: "1px solid var(--border)",
color: "var(--text-secondary)",
outline: "none",
}}
>
<option value="" disabled>Examples…</option>
<option value="demo">Demo Query</option>
<option value="injection">Injection Demo</option>
</select>
<div className="flex-1" />
<StatusDot health={health} />
{analysisTime !== null && (
<span className="text-[10px]" style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
{analysisTime}ms
</span>
)}
{/* API Docs link */}
<Link href="/swagger">
<button
className="flex items-center gap-1.5 h-7 px-2 text-xs rounded transition-colors"
style={{ color: "var(--text-secondary)" }}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--text-primary)")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--text-secondary)")}
>
<BookOpen size={11} />
API Docs
</button>
</Link>
{/* Analyze button */}
<button
onClick={handleAnalyze}
disabled={isAnalyzing || health?.status !== "ok"}
className="flex items-center gap-1.5 h-7 px-3 text-xs font-semibold rounded transition-opacity disabled:opacity-40"
style={{ background: "var(--accent-blue)", color: "var(--bg-base)" }}
>
{isAnalyzing ? <Loader2 size={12} className="animate-spin" /> : <Play size={11} />}
{isAnalyzing ? "Analyzing…" : "Analyze"}
<span className="text-[9px] opacity-60 ml-0.5">⌘↵</span>
</button>
</header>
{/* Split panels */}
<div className="flex-1 overflow-hidden">
<PanelGroup direction="horizontal" className="h-full">
{/* Left: SQL Editor */}
<Panel defaultSize={50} minSize={20}>
<div className="flex flex-col h-full">
<div
className="flex items-center gap-2 px-3 py-1.5 flex-shrink-0"
style={{ borderBottom: "1px solid var(--border-muted)" }}
>
<span className="text-[10px]" style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
SQL Editor
</span>
<span className="ml-auto text-[10px]" style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>
Ctrl+Enter to analyze
</span>
</div>
<div ref={editorContainerRef} className="flex-1 overflow-hidden">
<SqlEditor value={sql} onChange={setSql} onAnalyze={handleAnalyze} dialect={dialect} />
</div>
</div>
</Panel>
{/* Resize handle */}
<PanelResizeHandle
className="w-1 cursor-col-resize transition-colors"
style={{ background: "var(--border)" }}
/>
{/* Right: Results */}
<Panel defaultSize={50} minSize={20}>
<div className="flex flex-col h-full">
{/* Tab bar */}
<div
className="flex items-center flex-shrink-0 px-1"
style={{ borderBottom: "1px solid var(--border)" }}
>
{TABS.map((tab) => {
const badge = tab.id === "lint" ? lintBadge : tab.id === "injection" ? injectBadge : null;
const isActive = activeTab === tab.id;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className="relative flex items-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors border-b-2"
style={{
color: isActive ? "var(--accent-blue)" : "var(--text-secondary)",
borderBottomColor: isActive ? "var(--accent-blue)" : "transparent",
}}
>
{tab.label}
{badge !== null && (
<span
className="text-[9px] font-bold px-1 py-0.5 rounded-full min-w-[16px] text-center"
style={{ background: "rgba(255,123,114,0.2)", color: "var(--accent-red)" }}
>
{badge}
</span>
)}
</button>
);
})}
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{activeTab === "ast" && (
<AstTreeView
tree={parseResult?.tree ?? null}
tokenCount={parseResult?.token_count}
depth={parseResult?.depth}
onJumpToLine={jumpToLine}
/>
)}
{activeTab === "lint" && (
<LintPanel result={lintResult} onJumpToLine={jumpToLine} />
)}
{activeTab === "injection" && (
<InjectionPanel result={injectResult} onJumpToLine={jumpToLine} />
)}
{activeTab === "formatted" && (
<FormatterPanel result={formatResult} onApplyToEditor={setSql} />
)}
</div>
</div>
</Panel>
</PanelGroup>
</div>
</div>
);
}