From 252da989a84cbe829d6aeadb55217a8c149f8db6 Mon Sep 17 00:00:00 2001 From: Beinan Date: Fri, 24 Jul 2026 01:19:31 +0000 Subject: [PATCH 1/2] feat(master-ui): add read-only SQL query tab for experiments Adds a SQL console to the master control-plane UI so operators can run ad-hoc read-only SELECT queries against a chosen experiment's rollout records (exposed as a table named `records`), covering aggregations and group-bys the fixed-filter record browser cannot express. Backend: - RolloutStore::query_sql materializes the merged base+WAL view into an in-memory DataFusion table and runs the query; binary_payload excluded. - SELECT-only enforcement via DataFusion's SQL parser; DML/DDL, multiple statements, and non-queries are rejected as InvalidInput (HTTP 400). - Bounded by SQL_MAX_SCAN_ROWS (materialization) and SQL_MAX_RESULT_ROWS (result); the latter sets a `truncated` flag rather than dropping rows silently. - POST /api/v1/experiments/{name}/query route; SqlQueryRequest/Response DTOs; MasterError::from_lance_user maps user SQL errors to 400. - datafusion 53 + arrow-json 58 pinned to the versions lance 7 already resolves, so no duplicate arrow/datafusion is introduced. Frontend: - New SQL tab/route with an experiment picker, SQL editor (Cmd/Ctrl+Enter to run), results grid, error box, and row-count/truncation notes. Tests: core unit tests (count, group-by over merged data, blob column rejected, DELETE rejected, empty-experiment columns) and an (etcd-gated) route test for 200/400/404. Co-Authored-By: Claude --- Cargo.lock | 2 + crates/lance-context-api/src/lib.rs | 21 ++ crates/lance-context-core/Cargo.toml | 2 + crates/lance-context-core/src/lib.rs | 2 +- .../lance-context-core/src/rollout_store.rs | 285 +++++++++++++++++- crates/lance-context-master/src/error.rs | 10 + crates/lance-context-master/src/routes.rs | 91 +++++- crates/lance-context-master/ui/src/App.tsx | 133 +++++++- crates/lance-context-master/ui/src/api.ts | 21 ++ crates/lance-context-master/ui/src/styles.css | 56 ++++ 10 files changed, 618 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4a4a13..763e7b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5481,8 +5481,10 @@ version = "0.6.4" dependencies = [ "arrow-array 58.3.0", "arrow-ipc 58.3.0", + "arrow-json 58.3.0", "arrow-schema 58.3.0", "chrono", + "datafusion 53.1.0", "futures", "lance 7.0.0", "lance-context-api", diff --git a/crates/lance-context-api/src/lib.rs b/crates/lance-context-api/src/lib.rs index 4b19b30..8dbc682 100644 --- a/crates/lance-context-api/src/lib.rs +++ b/crates/lance-context-api/src/lib.rs @@ -952,6 +952,27 @@ pub struct ExperimentRecordsResponse { pub source: String, } +/// Body for a read-only SQL query against one experiment's rollout records. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SqlQueryRequest { + /// A single read-only `SELECT` statement. The records are exposed as a + /// table named `records`. + pub sql: String, +} + +/// Result of a read-only SQL query: output columns plus JSON-encoded rows. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SqlQueryResponse { + /// Output column names, in select order. + pub columns: Vec, + /// Rows as JSON values (one inner vec per row, aligned to `columns`). + pub rows: Vec>, + /// Number of rows returned in this response. + pub row_count: usize, + /// True when the result was capped at the server's row limit. + pub truncated: bool, +} + /// State of a manual or automatic compaction job for one experiment. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "state")] diff --git a/crates/lance-context-core/Cargo.toml b/crates/lance-context-core/Cargo.toml index 1597007..f7000d8 100644 --- a/crates/lance-context-core/Cargo.toml +++ b/crates/lance-context-core/Cargo.toml @@ -13,8 +13,10 @@ categories = ["database", "data-structures", "science"] [dependencies] arrow-array = "58" arrow-ipc = "58" +arrow-json = "58" arrow-schema = "58" chrono = { version = "0.4", default-features = false, features = ["clock"] } +datafusion = { version = "53", default-features = false, features = ["nested_expressions"] } lance = "7.0.0" lance-context-api = { version = "0.6.4", path = "../lance-context-api" } lance-index = "7.0.0" diff --git a/crates/lance-context-core/src/lib.rs b/crates/lance-context-core/src/lib.rs index 1d7e2c7..716e350 100644 --- a/crates/lance-context-core/src/lib.rs +++ b/crates/lance-context-core/src/lib.rs @@ -47,7 +47,7 @@ pub use registry::{RegistryEntry, RolloutRegistry}; pub use rollout::{RolloutRecord, ROLE_ARTIFACT, ROLE_ASSISTANT, ROLE_GRADE, ROLE_TOOL}; pub use rollout_store::{ rollout_schema, ListSource, RolloutFilters, RolloutObservation, RolloutPage, RolloutStore, - RolloutStoreOptions, + RolloutStoreOptions, SqlQueryResult, SQL_MAX_RESULT_ROWS, SQL_MAX_SCAN_ROWS, SQL_TABLE_NAME, }; pub use storage::{create_local_dir_if_needed, join_uri, validate_store_name, MAX_STORE_NAME_LEN}; pub use store::{ diff --git a/crates/lance-context-core/src/rollout_store.rs b/crates/lance-context-core/src/rollout_store.rs index bc164f7..8bef997 100644 --- a/crates/lance-context-core/src/rollout_store.rs +++ b/crates/lance-context-core/src/rollout_store.rs @@ -58,8 +58,12 @@ use arrow_array::{ Int64Array, Int8Array, LargeBinaryArray, LargeStringArray, ListArray, RecordBatch, RecordBatchIterator, StringArray, TimestampMicrosecondArray, UInt64Array, }; -use arrow_schema::{ArrowError, DataType, Field, Schema, TimeUnit}; +use arrow_schema::{ArrowError, DataType, Field, FieldRef, Schema, TimeUnit}; use chrono::{DateTime, Utc}; +use datafusion::datasource::MemTable; +use datafusion::prelude::SessionContext; +use datafusion::sql::parser::{DFParser, Statement as DFStatement}; +use datafusion::sql::sqlparser::ast::Statement as SqlStatement; use futures::{stream, StreamExt, TryStreamExt}; use lance::dataset::mem_wal::{ DatasetMemWalExt, LsmScanner, ShardManifestStore, ShardSnapshot, ShardWriter, ShardWriterConfig, @@ -198,6 +202,96 @@ pub struct RolloutPage { pub has_more: bool, } +/// Table name the ad-hoc SQL console binds an experiment's records to. +pub const SQL_TABLE_NAME: &str = "records"; + +/// Upper bound on rows materialized into the in-memory `records` table for an +/// ad-hoc SQL query. Guards master memory against a huge experiment. +pub const SQL_MAX_SCAN_ROWS: usize = 200_000; + +/// Upper bound on rows returned by an ad-hoc SQL query. Hitting it flags the +/// result `truncated` rather than silently dropping rows. +pub const SQL_MAX_RESULT_ROWS: usize = 10_000; + +/// Result of [`RolloutStore::query_sql`]: column names plus JSON-encoded rows. +#[derive(Debug, Clone)] +pub struct SqlQueryResult { + /// Output column names, in select order. + pub columns: Vec, + /// Rows as JSON values (one inner vec per row, aligned to `columns`). + pub rows: Vec>, + /// True when the result was capped at [`SQL_MAX_RESULT_ROWS`]. + pub truncated: bool, +} + +/// Reject anything that is not a single read-only `SELECT` (or CTE) statement. +/// +/// Parsing with DataFusion's SQL parser is more robust than string matching: +/// it rejects trailing/multiple statements, DML (`INSERT`/`UPDATE`/`DELETE`), +/// DDL (`CREATE`/`DROP`/…), `COPY`, and `EXPLAIN ANALYZE` side effects. Because +/// the console only registers one fixed in-memory table, there is no catalog +/// surface to mutate even if a statement slipped through. +fn ensure_select_only(sql: &str) -> LanceResult<()> { + let statements = DFParser::parse_sql(sql) + .map_err(|err| LanceError::invalid_input(format!("could not parse SQL: {err}")))?; + if statements.len() != 1 { + return Err(LanceError::invalid_input( + "exactly one SQL statement is allowed".to_string(), + )); + } + match &statements[0] { + DFStatement::Statement(stmt) if matches!(stmt.as_ref(), SqlStatement::Query(_)) => Ok(()), + _ => Err(LanceError::invalid_input( + "only read-only SELECT queries are allowed".to_string(), + )), + } +} + +/// Convert DataFusion result batches into a JSON [`SqlQueryResult`], capping at +/// [`SQL_MAX_RESULT_ROWS`] and flagging `truncated` when the cap is reached. +/// `columns` is taken from the query plan schema so it is populated even for a +/// zero-row result. +fn sql_batches_to_result( + columns: Vec, + batches: Vec, +) -> LanceResult { + let mut rows: Vec> = Vec::new(); + let mut truncated = false; + 'outer: for batch in &batches { + // arrow-json encodes each row as a JSON object keyed by column name; + // re-key to a positional array so duplicate/expression column names are + // preserved in select order. + let mut writer = arrow_json::ArrayWriter::new(Vec::::new()); + writer + .write(batch) + .map_err(|err| LanceError::from(ArrowError::from(err)))?; + writer + .finish() + .map_err(|err| LanceError::from(ArrowError::from(err)))?; + let json_rows: Vec> = + serde_json::from_slice(&writer.into_inner()).map_err(|err| { + LanceError::from(ArrowError::InvalidArgumentError(err.to_string())) + })?; + for obj in json_rows { + if rows.len() >= SQL_MAX_RESULT_ROWS { + truncated = true; + break 'outer; + } + let row = columns + .iter() + .map(|name| obj.get(name).cloned().unwrap_or(serde_json::Value::Null)) + .collect(); + rows.push(row); + } + } + + Ok(SqlQueryResult { + columns, + rows, + truncated, + }) +} + /// Which data source a rollout list scan reads. /// /// A rollout store's rows live in two tiers: the compacted **base table** @@ -1130,6 +1224,100 @@ impl RolloutStore { Ok(RolloutPage { records, has_more }) } + /// Run a read-only `SELECT` against this experiment's rollout records. + /// + /// The merged view ([`ListSource::All`]: base table ∪ pending MemWAL + /// generations) is materialized into an in-memory table named `records`, + /// then queried with DataFusion. Only a single `SELECT`/CTE statement is + /// accepted — any DML/DDL/multi-statement input is rejected before + /// execution (see [`ensure_select_only`]). The `binary_payload` column is + /// excluded (blob bytes are fetched via [`Self::get_blob`]). + /// + /// Two bounds keep a query from exhausting master memory: + /// - [`SQL_MAX_SCAN_ROWS`] caps how many rows are materialized into the + /// `records` table; exceeding it is a hard error asking the user to work + /// on a smaller experiment. + /// - [`SQL_MAX_RESULT_ROWS`] caps returned rows; hitting it sets + /// `truncated = true` rather than silently dropping rows. + /// + /// A syntactically invalid or non-`SELECT` query returns + /// [`LanceError::InvalidInput`] so callers can surface it as a 400. + pub async fn query_sql(&self, sql: &str) -> LanceResult { + ensure_select_only(sql)?; + + // Materialize the merged (base ∪ WAL) non-blob rows, bounded. + let shard_snapshots = self.wal_shard_snapshots().await?; + let columns = self.non_blob_columns(); + let refs: Vec<&str> = columns.iter().map(String::as_str).collect(); + let scanner = self + .lsm_scanner_for_source(ListSource::All, shard_snapshots) + .project(&refs); + let mut stream = scanner.try_into_stream().await?; + + let mut batches: Vec = Vec::new(); + let mut scanned_rows = 0usize; + let mut table_schema: Option> = None; + while let Some(batch) = stream.try_next().await? { + if table_schema.is_none() { + table_schema = Some(batch.schema()); + } + scanned_rows += batch.num_rows(); + if scanned_rows > SQL_MAX_SCAN_ROWS { + return Err(LanceError::invalid_input(format!( + "experiment has more than {SQL_MAX_SCAN_ROWS} rows, which is too large \ + for ad-hoc SQL; use the record browser filters instead" + ))); + } + batches.push(batch); + } + + // Empty experiment: fall back to the projected dataset schema so + // `SELECT`s still resolve column names against an empty `records` table. + let schema = match table_schema { + Some(schema) => schema, + None => { + let full: Schema = self.dataset.schema().into(); + let projected: Vec = full + .fields() + .iter() + .filter(|f| f.name() != "binary_payload") + .cloned() + .collect(); + Arc::new(Schema::new(projected)) + } + }; + + let ctx = SessionContext::new(); + let provider = MemTable::try_new(schema, vec![batches]) + .map_err(|err| LanceError::from(ArrowError::from_external_error(Box::new(err))))?; + ctx.register_table(SQL_TABLE_NAME, Arc::new(provider)) + .map_err(|err| LanceError::from(ArrowError::from_external_error(Box::new(err))))?; + + // DataFusion planning/exec errors (unknown column, bad function, …) are + // user errors → InvalidInput so the API returns 400, not 500. + let df = ctx + .sql(sql) + .await + .map_err(|err| LanceError::invalid_input(err.to_string()))?; + let df = df + .limit(0, Some(SQL_MAX_RESULT_ROWS + 1)) + .map_err(|err| LanceError::invalid_input(err.to_string()))?; + // Capture the output columns from the plan schema so they are known even + // when the query returns zero rows. + let columns: Vec = df + .schema() + .fields() + .iter() + .map(|f| f.name().clone()) + .collect(); + let result_batches = df + .collect() + .await + .map_err(|err| LanceError::invalid_input(err.to_string()))?; + + sql_batches_to_result(columns, result_batches) + } + /// Retrieve a single rollout row by its unique id, including any freshly /// appended (MemWAL-flushed) row on any instance. `binary_payload` is /// projected out (fetch bytes via [`Self::get_blob`]). @@ -4207,4 +4395,99 @@ mod tests { assert!(store.get_record_with_blob("nope").await.unwrap().is_none()); }); } + + #[test] + fn ensure_select_only_accepts_select_and_cte() { + assert!(ensure_select_only("SELECT * FROM records").is_ok()); + assert!(ensure_select_only(" select id from records where reward > 0 ").is_ok()); + assert!( + ensure_select_only("WITH t AS (SELECT id FROM records) SELECT * FROM t").is_ok() + ); + } + + #[test] + fn ensure_select_only_rejects_mutations_and_multi_statements() { + for sql in [ + "DELETE FROM records", + "UPDATE records SET reward = 1", + "INSERT INTO records (id) VALUES ('x')", + "DROP TABLE records", + "CREATE TABLE t (a INT)", + "SELECT 1; SELECT 2", + "not sql at all", + ] { + assert!( + ensure_select_only(sql).is_err(), + "expected rejection for: {sql}" + ); + } + } + + #[test] + fn query_sql_runs_select_over_merged_records() { + let dir = TempDir::new().unwrap(); + let uri = dir.path().to_string_lossy().to_string(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let mut store = RolloutStore::open(&uri).await.unwrap(); + store + .add(&[ + assistant_record("a-0"), + assistant_record("a-1"), + assistant_record("a-2"), + ]) + .await + .unwrap(); + + // Aggregate over the merged (base + pending WAL) view. + let result = store + .query_sql("SELECT count(*) AS n FROM records") + .await + .unwrap(); + assert_eq!(result.columns, vec!["n".to_string()]); + assert_eq!(result.rows.len(), 1); + assert_eq!(result.rows[0][0], serde_json::json!(3)); + assert!(!result.truncated); + + // GROUP BY on a real column returns the expected shape. + let grouped = store + .query_sql("SELECT role, count(*) AS n FROM records GROUP BY role") + .await + .unwrap(); + assert_eq!(grouped.columns, vec!["role".to_string(), "n".to_string()]); + assert_eq!(grouped.rows.len(), 1); + assert_eq!(grouped.rows[0][0], serde_json::json!(ROLE_ASSISTANT)); + assert_eq!(grouped.rows[0][1], serde_json::json!(3)); + + // The blob column is not exposed to SQL. + let err = store + .query_sql("SELECT binary_payload FROM records") + .await + .unwrap_err(); + assert!(matches!(err, LanceError::InvalidInput { .. })); + + // A non-SELECT is rejected as invalid input (→ 400 at the API). + let rejected = store.query_sql("DELETE FROM records").await.unwrap_err(); + assert!(matches!(rejected, LanceError::InvalidInput { .. })); + }); + } + + #[test] + fn query_sql_on_empty_experiment_returns_zero_rows() { + let dir = TempDir::new().unwrap(); + let uri = dir.path().to_string_lossy().to_string(); + + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let store = RolloutStore::open(&uri).await.unwrap(); + let result = store + .query_sql("SELECT id FROM records") + .await + .unwrap(); + assert_eq!(result.columns, vec!["id".to_string()]); + assert!(result.rows.is_empty()); + assert!(!result.truncated); + }); + } } diff --git a/crates/lance-context-master/src/error.rs b/crates/lance-context-master/src/error.rs index 9275348..afefefc 100644 --- a/crates/lance-context-master/src/error.rs +++ b/crates/lance-context-master/src/error.rs @@ -18,6 +18,16 @@ impl MasterError { pub fn from_lance(err: lance::Error) -> Self { MasterError::Internal(err.to_string()) } + + /// Map a Lance error from a user-driven operation (e.g. an ad-hoc SQL + /// query): `InvalidInput` becomes a 400 so the caller sees their own + /// mistake, everything else stays a 500. + pub fn from_lance_user(err: lance::Error) -> Self { + match err { + lance::Error::InvalidInput { .. } => MasterError::InvalidRequest(err.to_string()), + other => MasterError::Internal(other.to_string()), + } + } } impl IntoResponse for MasterError { diff --git a/crates/lance-context-master/src/routes.rs b/crates/lance-context-master/src/routes.rs index af5e9ca..6db326b 100644 --- a/crates/lance-context-master/src/routes.rs +++ b/crates/lance-context-master/src/routes.rs @@ -12,8 +12,8 @@ use serde::Deserialize; use lance_context_api::{ CompactJobStatus, EnqueueTaskRequest, ExperimentDetail, ExperimentListResponse, - ExperimentRecordsResponse, ExperimentSummary, TaskKind, TaskListResponse, TaskRecord, - TaskState, + ExperimentRecordsResponse, ExperimentSummary, SqlQueryRequest, SqlQueryResponse, TaskKind, + TaskListResponse, TaskRecord, TaskState, }; use lance_context_core::{rollout_record_to_dto, ListSource, RolloutFilters, RolloutStore}; use tokio::sync::RwLock; @@ -198,6 +198,33 @@ pub async fn list_experiment_records( })) } +/// `POST /api/v1/experiments/{name}/query` — run a read-only `SELECT` against +/// one experiment's rollout records (exposed as a table named `records`, +/// merged base ∪ pending WAL view). Non-`SELECT` or malformed SQL returns 400. +pub async fn query_experiment_sql( + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> Result, MasterError> { + let store = open_registered_store(&state, &name).await?; + let mut store = store.write().await; + store + .refresh_latest() + .await + .map_err(MasterError::from_lance)?; + let result = store + .query_sql(&body.sql) + .await + .map_err(MasterError::from_lance_user)?; + let row_count = result.rows.len(); + Ok(Json(SqlQueryResponse { + columns: result.columns, + rows: result.rows, + row_count, + truncated: result.truncated, + })) +} + /// `GET /api/v1/experiments/{name}/records/{id}/blob` pub async fn download_experiment_blob( State(state): State>, @@ -418,6 +445,7 @@ pub fn api_router() -> Router> { .route("/experiments", get(list_experiments)) .route("/experiments/{name}", get(get_experiment)) .route("/experiments/{name}/records", get(list_experiment_records)) + .route("/experiments/{name}/query", post(query_experiment_sql)) .route( "/experiments/{name}/records/{id}/blob", get(download_experiment_blob), @@ -788,6 +816,65 @@ mod tests { assert!(matches!(missing, Err(MasterError::NotFound(_)))); } + #[tokio::test] + #[ignore = "requires ETCD_TEST_ENDPOINTS"] + async fn query_endpoint_runs_select_and_rejects_mutations() { + let dir = TempDir::new().unwrap(); + let state = MasterState::new(test_config(&dir)).await.unwrap(); + let uri = state.rollout_uri("records"); + let mut store = RolloutStore::open(&uri).await.unwrap(); + store + .add(&[ + test_record("assistant-1", false), + test_record("artifact-1", true), + ]) + .await + .unwrap(); + state + .registry + .write() + .await + .upsert("records", &uri) + .await + .unwrap(); + + // A valid SELECT returns rows over the merged view. + let Json(result) = query_experiment_sql( + State(state.clone()), + Path("records".to_string()), + Json(SqlQueryRequest { + sql: "SELECT count(*) AS n FROM records".to_string(), + }), + ) + .await + .unwrap(); + assert_eq!(result.columns, vec!["n".to_string()]); + assert_eq!(result.row_count, 1); + assert!(!result.truncated); + + // A mutation is rejected as a 400-class InvalidRequest. + let rejected = query_experiment_sql( + State(state.clone()), + Path("records".to_string()), + Json(SqlQueryRequest { + sql: "DROP TABLE records".to_string(), + }), + ) + .await; + assert!(matches!(rejected, Err(MasterError::InvalidRequest(_)))); + + // Unknown experiment is a 404. + let missing = query_experiment_sql( + State(state), + Path("missing".to_string()), + Json(SqlQueryRequest { + sql: "SELECT 1".to_string(), + }), + ) + .await; + assert!(matches!(missing, Err(MasterError::NotFound(_)))); + } + #[test] fn parse_list_source_maps_and_defaults() { assert_eq!(parse_list_source(None).unwrap(), ListSource::Fragments); diff --git a/crates/lance-context-master/ui/src/App.tsx b/crates/lance-context-master/ui/src/App.tsx index 22e8d08..4e0a073 100644 --- a/crates/lance-context-master/ui/src/App.tsx +++ b/crates/lance-context-master/ui/src/App.tsx @@ -1,5 +1,11 @@ import { useMutation, useQuery, useQueryClient, useIsFetching } from "@tanstack/react-query"; -import { Fragment, useEffect, useState, type FormEvent } from "react"; +import { + Fragment, + useEffect, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; import { NavLink, Navigate, @@ -16,12 +22,14 @@ import { listExperimentRecords, listExperiments, listTasks, + runSql, triggerCompaction, type CompactJobStatus, type ExperimentSummary, type RecordFilters, type RecordSource, type RolloutRecord, + type SqlQueryResponse, type TaskRecord, type TaskState, } from "./api"; @@ -977,6 +985,125 @@ function TaskQueue() { /* ---- app ----------------------------------------------------------------- */ +const SQL_PLACEHOLDER = + "SELECT problem_id, count(*) AS n\nFROM records\nGROUP BY problem_id\nORDER BY n DESC"; + +/** Render one SQL result cell: objects/arrays as compact JSON, null as em dash. */ +function sqlCell(value: unknown): string { + if (value === null || value === undefined) return "—"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +/** Read-only SQL console: pick an experiment, run a SELECT against `records`. */ +function SqlConsole() { + const experiments = useQuery({ + queryKey: ["experiments", "sql-picker"], + queryFn: () => listExperiments("", 1000, 0), + }); + const options = experiments.data?.experiments ?? []; + + const [selected, setSelected] = useState(""); + const [sql, setSql] = useState(SQL_PLACEHOLDER); + + // Default the picker to the first experiment once the list loads. + useEffect(() => { + if (!selected && options.length > 0) setSelected(options[0].name); + }, [selected, options]); + + const run = useMutation({ + mutationFn: () => runSql(selected, sql), + }); + + const canRun = Boolean(selected) && sql.trim().length > 0 && !run.isPending; + const submit = () => { + if (canRun) run.mutate(); + }; + const onKeyDown = (e: ReactKeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + submit(); + } + }; + + const result = run.data; + + return ( + <> +
+ + +
+