Skip to content

Commit 4a7a1c4

Browse files
authored
feat: SQL Query Interface UX — result table, chart layout, tooltips, prompt history (#62)
* fix: quote Retention column in KPIs query and coerce pg string counts to numbers for Recharts * fix: use exact case-sensitive column names in LLM schema prompt to prevent Postgres quoting errors * fix: chart ratios, tooltip overflow, and raw data table below viz * feat: add POST /api/query-history for server-side audit logging Appends JSONL entries to logs/query-history.jsonl on each POST. Returns 400 on missing/invalid fields, 500 on write errors. Adds logs/ to .gitignore so log file is not committed. * feat: add prompt history panel with localStorage persistence and re-run * fix: bar chart colors, tooltip contrast, bar width, remove API Query String field - Replace hsl(var(--x)) with var(--x) throughout — CSS vars use oklch() not HSL, so the hsl() wrapper produced invalid colors (black bars, broken tooltips) - Extract shared TOOLTIP_STYLE constant with popover-foreground color for contrast - Add maxBarSize={48} to Bar to prevent bars spanning full category width - Remove 'API Query String' field from QueryPlanPanel * fix: remove bar chart hover cursor background
1 parent ea4ffd3 commit 4a7a1c4

7 files changed

Lines changed: 362 additions & 120 deletions

File tree

codebenders-dashboard/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,6 @@ yarn-error.log*
3131
# Typescript
3232
*.tsbuildinfo
3333
next-env.d.ts
34+
35+
# Audit logs
36+
logs/
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { type NextRequest, NextResponse } from "next/server"
2+
import { mkdir, appendFile } from "fs/promises"
3+
import path from "path"
4+
5+
const LOGS_DIR = path.join(process.cwd(), "logs")
6+
const LOG_FILE = path.join(LOGS_DIR, "query-history.jsonl")
7+
8+
interface QueryHistoryEntry {
9+
prompt: string
10+
institution: string
11+
vizType: string
12+
rowCount: number
13+
timestamp: string
14+
}
15+
16+
export async function POST(request: NextRequest) {
17+
let body: unknown
18+
19+
try {
20+
body = await request.json()
21+
} catch {
22+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 })
23+
}
24+
25+
const entry = body as Record<string, unknown>
26+
27+
// Validate required fields
28+
if (
29+
typeof entry.prompt !== "string" ||
30+
typeof entry.institution !== "string" ||
31+
typeof entry.vizType !== "string" ||
32+
typeof entry.rowCount !== "number" ||
33+
typeof entry.timestamp !== "string"
34+
) {
35+
return NextResponse.json(
36+
{ error: "Missing or invalid required fields: prompt, institution, vizType, rowCount, timestamp" },
37+
{ status: 400 }
38+
)
39+
}
40+
41+
const record: QueryHistoryEntry = {
42+
prompt: entry.prompt,
43+
institution: entry.institution,
44+
vizType: entry.vizType,
45+
rowCount: entry.rowCount,
46+
timestamp: entry.timestamp,
47+
}
48+
49+
try {
50+
await mkdir(LOGS_DIR, { recursive: true })
51+
await appendFile(LOG_FILE, JSON.stringify(record) + "\n", "utf8")
52+
return NextResponse.json({ ok: true }, { status: 200 })
53+
} catch (error) {
54+
console.error("query-history write error:", error)
55+
return NextResponse.json(
56+
{ error: "Failed to write log entry" },
57+
{ status: 500 }
58+
)
59+
}
60+
}

codebenders-dashboard/app/query/page.tsx

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ import { Switch } from "@/components/ui/switch"
99
import { Label } from "@/components/ui/label"
1010
import { AnalysisResult } from "@/components/analysis-result"
1111
import { QueryPlanPanel } from "@/components/query-plan-panel"
12+
import { QueryHistoryPanel } from "@/components/query-history-panel"
1213
import { analyzePrompt } from "@/lib/prompt-analyzer"
1314
import { executeQuery } from "@/lib/query-executor"
14-
import type { QueryPlan, QueryResult } from "@/lib/types"
15+
import type { QueryPlan, QueryResult, HistoryEntry } from "@/lib/types"
1516
import Link from "next/link"
1617
import { ArrowLeft } from "lucide-react"
1718

@@ -29,10 +30,25 @@ export default function QueryPage() {
2930
const [queryPlan, setQueryPlan] = useState<QueryPlan | null>(null)
3031
const [queryResult, setQueryResult] = useState<QueryResult | null>(null)
3132
const [useDirectDB, setUseDirectDB] = useState(true)
33+
const [history, setHistory] = useState<HistoryEntry[]>(() => {
34+
// Read from localStorage on mount (client-only)
35+
if (typeof window === "undefined") return []
36+
try {
37+
return JSON.parse(localStorage.getItem("bishop_query_history") || "[]")
38+
} catch {
39+
return []
40+
}
41+
})
3242

33-
const handleAnalyze = async () => {
34-
console.log("handleAnalyze", prompt, institution)
35-
if (!prompt.trim()) return
43+
const handleAnalyze = async (
44+
overridePrompt?: string,
45+
overrideInstitution?: string,
46+
) => {
47+
const activePrompt = overridePrompt ?? prompt
48+
const activeInstitution = overrideInstitution ?? institution
49+
50+
console.log("handleAnalyze", activePrompt, activeInstitution)
51+
if (!activePrompt.trim()) return
3652

3753
setIsAnalyzing(true)
3854
try {
@@ -45,11 +61,11 @@ export default function QueryPage() {
4561
const response = await fetch("/api/analyze", {
4662
method: "POST",
4763
headers: { "Content-Type": "application/json" },
48-
body: JSON.stringify({ prompt, institution }),
64+
body: JSON.stringify({ prompt: activePrompt, institution: activeInstitution }),
4965
})
5066

5167
console.log("response status:", response.status)
52-
68+
5369
if (!response.ok) {
5470
const errorData = await response.json().catch(() => ({}))
5571
console.error("response error", response.status, errorData)
@@ -59,14 +75,37 @@ export default function QueryPage() {
5975
plan = await response.json()
6076
console.log("plan received:", plan)
6177
} else {
62-
plan = analyzePrompt(prompt, institution)
78+
plan = analyzePrompt(activePrompt, activeInstitution)
6379
}
6480

6581
setQueryPlan(plan)
6682
console.log("executing query with plan:", plan)
67-
const result = await executeQuery(plan, institution, useDirectDB)
83+
const result = await executeQuery(plan, activeInstitution, useDirectDB)
6884
console.log("query result:", result)
6985
setQueryResult(result)
86+
87+
// Persist history entry
88+
const entry: HistoryEntry = {
89+
id: crypto.randomUUID(),
90+
timestamp: new Date().toISOString(),
91+
institution: activeInstitution,
92+
prompt: activePrompt,
93+
rowCount: result.rowCount,
94+
vizType: plan.vizType,
95+
}
96+
// Prepend and cap at 50 entries
97+
setHistory(prev => {
98+
const updated = [entry, ...prev].slice(0, 50)
99+
localStorage.setItem("bishop_query_history", JSON.stringify(updated))
100+
return updated
101+
})
102+
103+
// Fire-and-forget audit log — don't await or block on failure
104+
fetch("/api/query-history", {
105+
method: "POST",
106+
headers: { "Content-Type": "application/json" },
107+
body: JSON.stringify(entry),
108+
}).catch(() => {/* ignore audit failures */})
70109
} catch (error) {
71110
console.error("Error analyzing prompt:", error)
72111
alert("Error: " + (error instanceof Error ? error.message : String(error)))
@@ -75,6 +114,17 @@ export default function QueryPage() {
75114
}
76115
}
77116

117+
const handleRerun = (entry: HistoryEntry) => {
118+
setInstitution(entry.institution)
119+
setPrompt(entry.prompt)
120+
handleAnalyze(entry.prompt, entry.institution)
121+
}
122+
123+
const handleClear = () => {
124+
setHistory([])
125+
localStorage.removeItem("bishop_query_history")
126+
}
127+
78128
return (
79129
<div className="min-h-screen bg-background">
80130
<div className="container mx-auto p-6 space-y-6">
@@ -136,14 +186,22 @@ export default function QueryPage() {
136186
</div>
137187

138188
<div className="flex items-end">
139-
<Button onClick={handleAnalyze} disabled={isAnalyzing || !prompt.trim()} className="w-full md:w-auto">
189+
<Button onClick={() => handleAnalyze()} disabled={isAnalyzing || !prompt.trim()} className="w-full md:w-auto">
140190
{isAnalyzing ? "Analyzing..." : "Analyze"}
141191
</Button>
142192
</div>
143193
</div>
144194
</CardContent>
145195
</Card>
146196

197+
{history.length > 0 && (
198+
<QueryHistoryPanel
199+
entries={history}
200+
onRerun={handleRerun}
201+
onClear={handleClear}
202+
/>
203+
)}
204+
147205
{queryResult && queryPlan && (
148206
<div className="grid gap-6 lg:grid-cols-[1fr_400px]">
149207
<AnalysisResult result={queryResult} plan={queryPlan} />
@@ -165,4 +223,3 @@ export default function QueryPage() {
165223
</div>
166224
)
167225
}
168-

0 commit comments

Comments
 (0)