| 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"; |
|
|
| |
|
|
| 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;`; |
|
|
| |
|
|
| 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> |
| ); |
| } |
|
|
| |
|
|
| 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); |
|
|
| |
| useEffect(() => { |
| let cancelled = false; |
| async function poll() { |
| try { |
| const h = await api.health(); |
| if (!cancelled) setHealth(h); |
| } catch { |
| |
| } |
| } |
| poll(); |
| const id = setInterval(poll, 6000); |
| return () => { cancelled = true; clearInterval(id); }; |
| }, []); |
|
|
| |
|
|
| 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]); |
|
|
| |
|
|
| 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; |
|
|
| |
|
|
| 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> |
| ); |
| } |
|
|