Skip to content

Commit c5a577e

Browse files
hyperpolymathclaude
andcommitted
feat(quandledb): frontend query UI — textarea editor, results table, trace panel
- Page_Query.res: full KRL/SQL query page with useReducer state machine, Ctrl+Enter shortcut, example quick-load buttons, stats bar, collapsible trace panel, warning list, dynamic result table, error card - Api.res: postQuery — POST /api/query, returns queryResponse or queryError - Decoders.res: decodeQueryResponse, decodeQueryError, decodeStageTrace - Types.res: stageTrace, queryResponse, queryError types - Route.res: Query route added (["query"] ↔ "/query") - View_Helpers.res: "Query" nav link in NavBar - App.res: Query route dispatch; fix pre-existing dispatch(…->ignore) bug; replace Webapi.Dom bindings with direct @Val FFI (rescript-webapi@0.8 incompatible with rescript 12) - deno.json: nodeModulesDir auto; fix rescript import specifier (add npm: prefix) - style.css: full query page stylesheet (textarea, controls, stats bar, trace table, warnings, result table, error card, badges) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 02ce526 commit c5a577e

File tree

10 files changed

+893
-6
lines changed

10 files changed

+893
-6
lines changed

quandledb/frontend/deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
{
2+
"nodeModulesDir": "auto",
23
"tasks": {
34
"build": "deno run -A npm:rescript build",
45
"watch": "deno run -A npm:rescript build -w",
56
"clean": "deno run -A npm:rescript clean"
67
},
78
"imports": {
8-
"rescript": "^12.0.0",
9+
"rescript": "npm:rescript@^12.0.0",
910
"@rescript/react": "npm:@rescript/react@^0.14.0",
1011
"react": "npm:react@^19.0.0",
1112
"react-dom": "npm:react-dom@^19.0.0"

quandledb/frontend/deno.lock

Lines changed: 82 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

quandledb/frontend/src/Api.res

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,42 @@ let fetchStatistics = async (): result<Types.statistics, string> => {
8383
| Error(e) => Error(e)
8484
}
8585
}
86+
87+
let postQuery = async (
88+
~src: string,
89+
~format: string="auto",
90+
~maxRows: int=1000,
91+
): result<Types.queryResponse, Types.queryError> => {
92+
try {
93+
let bodyObj = Js.Dict.empty()
94+
Js.Dict.set(bodyObj, "query", Js.Json.string(src))
95+
Js.Dict.set(bodyObj, "format", Js.Json.string(format))
96+
Js.Dict.set(bodyObj, "max_rows", Js.Json.number(Belt.Int.toFloat(maxRows)))
97+
let bodyStr = Js.Json.stringify(Js.Json.object_(bodyObj))
98+
99+
let resp = await fetch(baseUrl ++ "/api/query", {
100+
"method": "POST",
101+
"headers": {"Content-Type": "application/json"},
102+
"body": bodyStr,
103+
})
104+
let json = await responseJson(resp)
105+
106+
if responseOk(resp) {
107+
switch Decoders.decodeQueryResponse(json) {
108+
| Ok(r) => Ok(r)
109+
| Error(e) =>
110+
Error({Types.errorKind: "decode_error", message: e, line: None, col: None})
111+
}
112+
} else {
113+
Error(Decoders.decodeQueryError(json))
114+
}
115+
} catch {
116+
| exn =>
117+
Error({
118+
Types.errorKind: "network_error",
119+
message: Js.String2.make(exn),
120+
line: None,
121+
col: None,
122+
})
123+
}
124+
}

quandledb/frontend/src/App.res

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ type msg =
2121
| SetFilters(Types.filters)
2222

2323
@val external pushState: (Js.Nullable.t<string>, string, string) => unit = "history.pushState"
24+
@val external addWindowEventListener: (string, 'a => unit) => unit = "window.addEventListener"
25+
@val external removeWindowEventListener: (string, 'a => unit) => unit = "window.removeEventListener"
2426

2527
@react.component
2628
let make = () => {
@@ -50,18 +52,15 @@ let make = () => {
5052
// Listen for popstate (browser back/forward)
5153
React.useEffect0(() => {
5254
let handler = _ => dispatch(UrlChanged)
53-
Webapi.Dom.Window.addEventListener(Webapi.Dom.window, "popstate", handler)
54-
Some(
55-
() => Webapi.Dom.Window.removeEventListener(Webapi.Dom.window, "popstate", handler),
56-
)
55+
addWindowEventListener("popstate", handler)
56+
Some(() => removeWindowEventListener("popstate", handler))
5757
})
5858

5959
// Fetch data when route changes
6060
React.useEffect1(() => {
6161
switch model.route {
6262
| Dashboard =>
6363
if model.stats == NotAsked {
64-
dispatch(GotStatistics(Error(""))->ignore)
6564
let _ = {
6665
open Promise
6766
Api.fetchStatistics()->then(result => {
@@ -89,6 +88,7 @@ let make = () => {
8988
})
9089
}
9190
}
91+
| Query => ()
9292
| NotFound => ()
9393
}
9494
None
@@ -124,6 +124,7 @@ let make = () => {
124124
/>
125125
| KnotDetail(_) =>
126126
<Page_KnotDetail knot={model.knotDetail} onNavigate=navigate />
127+
| Query => <Page_Query />
127128
| NotFound =>
128129
<div className="not-found">
129130
<h1> {React.string("404 - Not Found")} </h1>

quandledb/frontend/src/Decoders.res

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,124 @@ let decodeIntDict = (json: Js.Json.t): Js.Dict.t<int> => {
168168
result
169169
}
170170

171+
let decodeStageTrace = (json: Js.Json.t): option<Types.stageTrace> => {
172+
open Js.Json
173+
switch classify(json) {
174+
| JSONObject(obj) => {
175+
let str = key =>
176+
switch Js.Dict.get(obj, key) {
177+
| Some(v) => switch classify(v) { | JSONString(s) => s | _ => "" }
178+
| None => ""
179+
}
180+
let int_ = key =>
181+
switch Js.Dict.get(obj, key) {
182+
| Some(v) => switch classify(v) { | JSONNumber(n) => Belt.Float.toInt(n) | _ => 0 }
183+
| None => 0
184+
}
185+
let float_ = key =>
186+
switch Js.Dict.get(obj, key) {
187+
| Some(v) => switch classify(v) { | JSONNumber(n) => n | _ => 0.0 }
188+
| None => 0.0
189+
}
190+
Some({
191+
Types.stage: str("stage"),
192+
rowsIn: int_("rows_in"),
193+
rowsOut: int_("rows_out"),
194+
elapsedMs: float_("elapsed_ms"),
195+
note: str("note"),
196+
})
197+
}
198+
| _ => None
199+
}
200+
}
201+
202+
let decodeQueryResponse = (json: Js.Json.t): result<Types.queryResponse, string> => {
203+
open Js.Json
204+
switch classify(json) {
205+
| JSONObject(obj) => {
206+
let float_ = (key, def) =>
207+
switch Js.Dict.get(obj, key) {
208+
| Some(v) => switch classify(v) { | JSONNumber(n) => n | _ => def }
209+
| None => def
210+
}
211+
let int_ = (key, def) =>
212+
switch Js.Dict.get(obj, key) {
213+
| Some(v) => switch classify(v) { | JSONNumber(n) => Belt.Float.toInt(n) | _ => def }
214+
| None => def
215+
}
216+
let bool_ = (key, def) =>
217+
switch Js.Dict.get(obj, key) {
218+
| Some(v) => switch classify(v) { | JSONTrue => true | JSONFalse => false | _ => def }
219+
| None => def
220+
}
221+
let str_ = (key, def) =>
222+
switch Js.Dict.get(obj, key) {
223+
| Some(v) => switch classify(v) { | JSONString(s) => s | _ => def }
224+
| None => def
225+
}
226+
let rows = switch Js.Dict.get(obj, "rows") {
227+
| Some(v) => switch classify(v) { | JSONArray(arr) => arr | _ => [] }
228+
| None => []
229+
}
230+
let warnings = switch Js.Dict.get(obj, "warnings") {
231+
| Some(v) =>
232+
switch classify(v) {
233+
| JSONArray(arr) =>
234+
arr->Belt.Array.keepMap(item =>
235+
switch classify(item) { | JSONString(s) => Some(s) | _ => None })
236+
| _ => []
237+
}
238+
| None => []
239+
}
240+
let trace = switch Js.Dict.get(obj, "trace") {
241+
| Some(v) =>
242+
switch classify(v) {
243+
| JSONArray(arr) => arr->Belt.Array.keepMap(decodeStageTrace)
244+
| _ => []
245+
}
246+
| None => []
247+
}
248+
Ok({
249+
Types.rows,
250+
count: int_("count", Belt.Array.length(rows)),
251+
parseTimeMs: float_("parse_time_ms", 0.0),
252+
evalTimeMs: float_("eval_time_ms", 0.0),
253+
totalMs: float_("total_ms", 0.0),
254+
pushdownUsed: bool_("pushdown_used", false),
255+
parseSource: str_("parse_source", "krl"),
256+
warnings,
257+
trace,
258+
})
259+
}
260+
| _ => Error("Expected JSON object for query response")
261+
}
262+
}
263+
264+
let decodeQueryError = (json: Js.Json.t): Types.queryError => {
265+
open Js.Json
266+
switch classify(json) {
267+
| JSONObject(obj) => {
268+
let str_ = (key, def) =>
269+
switch Js.Dict.get(obj, key) {
270+
| Some(v) => switch classify(v) { | JSONString(s) => s | _ => def }
271+
| None => def
272+
}
273+
let optInt = key =>
274+
switch Js.Dict.get(obj, key) {
275+
| Some(v) => switch classify(v) { | JSONNumber(n) => Some(Belt.Float.toInt(n)) | _ => None }
276+
| None => None
277+
}
278+
{
279+
Types.errorKind: str_("error", "unknown"),
280+
message: str_("message", "Unknown error"),
281+
line: optInt("line"),
282+
col: optInt("col"),
283+
}
284+
}
285+
| _ => {Types.errorKind: "decode_error", message: "Could not parse error response", line: None, col: None}
286+
}
287+
}
288+
171289
let decodeStatistics = (json: Js.Json.t): result<Types.statistics, string> => {
172290
open Js.Json
173291
switch classify(json) {

0 commit comments

Comments
 (0)