Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dashboard/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"jose": "^6.1.3",
"minimatch": "^10.1.1",
"multiformats": "^13.4.2",
"quickjs-emscripten": "^0.31.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.69.0",
Expand Down
20 changes: 20 additions & 0 deletions dashboard/frontend/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,23 @@ export const DEFAULT_ENDPOINT = BuildURI.from("fireproof://cloud.fireproof.direc
export const SYNC_DB_NAME = "fp_sync";

export const DASHAPI_URL = import.meta.env.VITE_DASHAPI_URL ?? "/api";

/**
* Default timeout for query execution in milliseconds.
* Configurable via VITE_QUERY_TIMEOUT environment variable.
* Set to 0 to disable timeouts.
* @default 30000 (30 seconds)
*/
/** Default timeout when no override is provided. */
const DEFAULT_QUERY_TIMEOUT_MS = 30000;
/** Raw VITE_QUERY_TIMEOUT value, if provided. */
const QUERY_TIMEOUT_ENV = import.meta.env.VITE_QUERY_TIMEOUT as string | undefined;
/** Parsed timeout override, NaN if invalid. */
const PARSED_QUERY_TIMEOUT = QUERY_TIMEOUT_ENV !== undefined ? parseInt(QUERY_TIMEOUT_ENV, 10) : Number.NaN;

export const QUERY_TIMEOUT_MS =
QUERY_TIMEOUT_ENV === undefined || QUERY_TIMEOUT_ENV === ""
? DEFAULT_QUERY_TIMEOUT_MS
: Number.isFinite(PARSED_QUERY_TIMEOUT) && PARSED_QUERY_TIMEOUT >= 0
? PARSED_QUERY_TIMEOUT
: DEFAULT_QUERY_TIMEOUT_MS;
168 changes: 151 additions & 17 deletions dashboard/frontend/src/pages/databases/query.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import React, { useState } from "react";
/**
* Query editor page for Fireproof databases.
* Allows users to write and execute map functions against database documents.
*
* Security: User code executes in a WASM sandbox (quickjs-emscripten)
* to prevent code injection attacks.
*/
import React, { useState, useEffect, useMemo } from "react";
import { Link, useParams } from "react-router-dom";
// import { MapFn } from "@fireproof/core";
import { useFireproof } from "use-fireproof";
import { EditableCodeHighlight } from "../../components/CodeHighlight.jsx";
import { DynamicTable, TableRow } from "../../components/DynamicTable.jsx";
import { headersForDocs } from "../../components/dynamicTableHelpers.js";
import {
initSandbox,
validateCode,
executeMapFn,
SandboxDocument,
MapResult,
} from "../../services/sandbox-service.js";

// type AnyMapFn = MapFn<object>;

/**
* Main query editor component.
* Provides a code editor for map functions and displays query results.
*/
export function DatabasesQuery() {
const { name } = useParams();
if (!name) throw new Error("No database name provided");
Expand All @@ -17,26 +32,74 @@ export function DatabasesQuery() {
const [editorCode, setEditorCode] = useState<string>(emptyMap);
const [editorCodeFnString, setEditorCodeFnString] = useState<string>(() => editorCode);
const [userCodeError, setUserCodeError] = useState<string | null>(null);
const [sandboxReady, setSandboxReady] = useState<boolean | null>(null);

// Initialize sandbox on mount
useEffect(() => {
initSandbox().then(setSandboxReady);
}, []);

/**
* Handle code editor changes.
* @param code - Updated code from the editor
*/
function editorChanged({ code }: { code: string }) {
setEditorCode(code);
}

/**
* Validate and run the query.
* Uses sandbox validation instead of eval() for security.
*/
async function runTempQuery() {
try {
// Try to evaluate the function to check for errors
eval(`(${editorCode})`);
setEditorCodeFnString(editorCode);
setUserCodeError(null);
} catch (error) {
setUserCodeError((error as Error).message);
// Check if sandbox is available
if (!sandboxReady) {
setUserCodeError("Query sandbox is not available. Please refresh the page.");
return;
}

// Validate code using sandbox
const validation = validateCode(editorCode);
if (!validation.valid) {
setUserCodeError(validation.error || "Invalid code");
return;
}

setEditorCodeFnString(editorCode);
setUserCodeError(null);
}

/**
* Save the current query (not implemented).
*/
function saveTempQuery() {
console.log("save not implemented");
}

// Show loading state while sandbox initializes
if (sandboxReady === null) {
return (
<div className="p-6 bg-[--muted]">
<div className="text-[--muted-foreground]">Initializing query sandbox...</div>
</div>
);
}

// Show error if sandbox failed to load
if (sandboxReady === false) {
return (
<div className="p-6 bg-[--muted]">
<div className="text-[--destructive] p-4 bg-[--destructive]/10 rounded">
<h3 className="font-bold">Query Editor Unavailable</h3>
<p>
The query sandbox failed to initialize. This feature requires WebAssembly support. Please try refreshing the
page or use a different browser.
</p>
</div>
</div>
);
}

return (
<div className="p-6 bg-[--muted]">
<div className="flex justify-between items-center mb-4">
Expand Down Expand Up @@ -84,12 +147,83 @@ export function DatabasesQuery() {
);
}

function QueryDynamicTable({ mapFn, name }: { mapFn: string; name: string }) {
/**
* Props for the QueryDynamicTable component.
*/
interface QueryDynamicTableProps {
/** Map function code as a string */
mapFn: string;
/** Database name to query */
name: string;
}

/**
* Component that executes the map function and displays results.
* Uses the sandbox for safe code execution.
*
* @param props - Component props
*/
function QueryDynamicTable({ mapFn, name }: QueryDynamicTableProps) {
const { useLiveQuery } = useFireproof(name);
const allDocs = useLiveQuery(eval(`(${mapFn})`));
const docs = allDocs.docs.filter((doc) => doc);
console.log(docs);
const headers = headersForDocs(docs);
const [queryResults, setQueryResults] = useState<SandboxDocument[]>([]);
const [queryError, setQueryError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);

// Fetch all documents from the database
const allDocsResult = useLiveQuery("_id");
const allDocs = useMemo(() => allDocsResult.docs.filter((doc) => doc) as SandboxDocument[], [allDocsResult.docs]);

// Execute map function in sandbox when docs or mapFn change
useEffect(() => {
let cancelled = false;

if (allDocs.length === 0) {
setQueryResults([]);
setIsLoading(false);
return () => {
cancelled = true;
};
}

setIsLoading(true);
setQueryError(null);

executeMapFn(mapFn, allDocs)
.then((results: MapResult[]) => {
if (cancelled) return;
// Extract documents from results
const docs = results
.map((r: MapResult) => r.value)
.filter((v: unknown): v is SandboxDocument => v !== null && typeof v === "object");
setQueryResults(docs);
setIsLoading(false);
})
.catch((error: Error) => {
if (cancelled) return;
setQueryError(error.message);
setQueryResults([]);
setIsLoading(false);
});

return () => {
cancelled = true;
};
}, [mapFn, allDocs]);

if (isLoading) {
return <div className="text-[--muted-foreground] p-4">Executing query...</div>;
}

if (queryError) {
return (
<div className="text-[--destructive] mt-4 p-4 bg-[--destructive]/10 rounded">
<h3 className="font-bold">Query Error:</h3>
<p>{queryError}</p>
</div>
);
}

const headers = headersForDocs(queryResults);

return <DynamicTable headers={headers} th="key" link={["_id"]} rows={docs as TableRow[]} dbName={name} />;
return <DynamicTable headers={headers} th="key" link={["_id"]} rows={queryResults as TableRow[]} dbName={name} />;
}
Loading