Skip to content
This repository has been archived by the owner on Apr 16, 2024. It is now read-only.

Commit

Permalink
Merge pull request #17 from FalkorDB/staging
Browse files Browse the repository at this point in the history
ref #3 add query box
  • Loading branch information
gkorland authored Oct 2, 2023
2 parents bf59a22 + e320c11 commit da4fa1a
Show file tree
Hide file tree
Showing 13 changed files with 1,993 additions and 51 deletions.
8 changes: 8 additions & 0 deletions app/api/cron/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';


export async function GET() {

return NextResponse.json({ ok: true });

}
2 changes: 1 addition & 1 deletion app/api/db/password.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@


// Define the characters that can be used in the password
const PASSWORD_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!&#$^<>-"
const PASSWORD_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"


// A function that generates a random password of a given length
Expand Down
41 changes: 41 additions & 0 deletions app/api/query/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import authOptions from '@/app/api/auth/[...nextauth]/options';
import { getServerSession } from "next-auth/next"
import { NextResponse } from "next/server";
import dataSource from '../db/appDataSource';
import { UserEntity } from '../models/entities';
import { createClient, Graph } from 'redis';


export async function GET(request: Request) {

const session = await getServerSession(authOptions)

if (!session) {
return NextResponse.json({ message: "You must be logged in." }, { status: 401 })
}

const email = session.user?.email;
if (!email) {
return NextResponse.json({ message: "Can't find user details" }, { status: 500 })
}

const user = await dataSource.manager.findOneBy(UserEntity, {
email: email
})

const client = await createClient( {
url: `redis://:${user?.db_password}@${user?.db_host}:${user?.db_port}`
}).connect();

const graph = new Graph(client, 'graph');

const {searchParams} = new URL(request.url);
let q = searchParams.get('q')?.toString() || ""

try{
let result = await graph.query(q)
return NextResponse.json({ result: result }, { status: 200 })
} catch (err: any) {
return NextResponse.json({ message: err.message }, { status: 400 })
}
}
72 changes: 72 additions & 0 deletions app/components/combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"use client"

import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"

export interface Option {
value: string
label: string
}

export function Combobox(props: { title: string, options: Option[] }) {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value
? props.options.find((option) => option.value === value)?.label
: props.title}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search framework..." />
<CommandEmpty>No framework found.</CommandEmpty>
<CommandGroup>
{props.options.map((option) => (
<CommandItem
key={option.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue)
setOpen(false)
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)
}
96 changes: 96 additions & 0 deletions app/components/cypherInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Combobox } from './combobox';

// A function that checks if a string is a valid Cypher query
// This is a very basic and incomplete validation, you may want to use a more robust parser
function isValidCypher(query: string) {
// Check if the query starts with a valid clause (e.g. MATCH, CREATE, RETURN, etc.)
const clauses = ['MATCH', 'CREATE', 'MERGE', 'DELETE', 'DETACH DELETE', 'SET', 'REMOVE', 'WITH', 'UNWIND', 'RETURN', 'ORDER BY', 'SKIP', 'LIMIT', 'UNION', 'CALL', 'LOAD CSV', 'FOREACH', 'PROFILE', 'EXPLAIN'];
const firstWord = query.split(' ')[0].toUpperCase();
if (!clauses.includes(firstWord)) {
return false;
}
// Check if the query has balanced parentheses and brackets
const stack = [];
for (let char of query) {
if (char === '(' || char === '[') {
stack.push(char);
} else if (char === ')' || char === ']') {
if (stack.length === 0) {
return false;
}
const top = stack.pop();
if ((char === ')' && top !== '(') || (char === ']' && top !== '[')) {
return false;
}
}
}
if (stack.length !== 0) {
return false;
}
// You can add more validation rules here
return true;
}

// A component that renders an input box for Cypher queries
export function CypherInput(props: { graphs: string[], onSubmit: (query: string) => Promise<any> }) {

const [results, setResults] = useState<any[]>([]);

// A state variable that stores the user input
const [query, setQuery] = useState('');

// A state variable that stores the validation result
const [valid, setValid] = useState(true);

const options = props.graphs.map((graph) => {
return {label: graph, value: graph}
})

// A function that handles the change event of the input box
function handleChange(event: any) {
// Get the new value of the input box
const value = event.target.value;

// Update the query state
setQuery(value);

// Validate the query and update the valid state
setValid(isValidCypher(value));
}

// A function that handles the submit event of the form
async function handleSubmit(event: any) {
// Prevent the default browser behavior of reloading the page
event.preventDefault();

// If the query is valid, pass it to the parent component as a prop
if (valid) {
let newResults = await props.onSubmit(query);
setResults(newResults.data?? [])
}
}

// Return the JSX element that renders the input box and a submit button
return (
<div >
<Combobox options={options} title={"Select Graph..."}/>
<form className="flex items-center py-4 space-x-4" onSubmit={handleSubmit}>
<Label className="text-2xl" htmlFor="cypher">Query:</Label>
<Input type="text" id="cypher" name="cypher" value={query} onChange={handleChange} />
<Button className="rounded-full bg-blue-600 text-1xl p-4 text-black" type="submit">Submit</Button>
</form>
{/* Show an error message if the query is invalid */}
{!valid && <p>Invalid Cypher query. Please check the syntax.</p>}
<ul>
{/* Render the lines as list items */}
{results.map((line, index) => (
<li key={index}>{JSON.stringify(line)}</li>
))}
</ul>
</div>
);
}
57 changes: 44 additions & 13 deletions app/sandbox/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { useState, useEffect, use } from 'react'
import { Button } from "@/components/ui/button"
import Spinning from "../components/spinning";
import { useToast } from "@/components/ui/use-toast"
import { CypherInput } from "../components/cypherInput";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"


enum State {
Expand All @@ -25,7 +27,7 @@ export default function Page() {

// fetch sandbox details if exists
useEffect(() => {
if (loadingState != State.InitialLoading
if (loadingState != State.InitialLoading
&& loadingState != State.BuildingSandbox) return

fetch('/api/db')
Expand All @@ -43,7 +45,7 @@ export default function Page() {
setSandbox(sandbox)

// if sandbox is building, retry after 5 seconds
if(sandbox?.status == "BUILDING") {
if (sandbox?.status == "BUILDING") {
setTimeout(() => {
setLoading(State.BuildingSandbox)
retry(retry_count + 1)
Expand All @@ -55,7 +57,7 @@ export default function Page() {
}, [loadingState, retry_count])

// render loading state if needed
switch (loadingState){
switch (loadingState) {
case State.InitialLoading:
return <Spinning text="Loading Sandbox..." />
case State.BuildingSandbox:
Expand Down Expand Up @@ -101,20 +103,50 @@ export default function Page() {
})
}

async function sendQuery(query: string) {
let result = await fetch(`/api/query?q=${query}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
if (result.status<300) {
let res = await result.json()
return res.result
}
toast({
title: "Error",
description: await result.text(),
})
return []
}

// render the sandbox details if exists
if (sandbox) {
let redisURL = `redis://${sandbox.password}@${sandbox.host}:${sandbox.port}`
return (
<div className="flex flex-col items-center justify-center min-h-screen py-2">
<main className="flex flex-col items-center justify-center flex-1 px-20 space-y-4">
<div className="text-2xl">
<div>Host: <Button className="bg-transparent text-2xl text-blue-600" onClick={copyToClipboard}>{sandbox.host}</Button></div>
<div>Port: <Button className="bg-transparent text-2xl text-blue-600" onClick={copyToClipboard}>{sandbox.port}</Button></div>
<div>Password: <Button className="bg-transparent text-2xl text-blue-600" onClick={copyToClipboard}>{sandbox.password}</Button></div>
<div>Created: <Button className="bg-transparent text-2xl text-blue-600" onClick={copyToClipboard}>{sandbox.create_time}</Button></div>
<div>Redis URL: <Button className="bg-transparent text-2xl text-blue-600" onClick={copyToClipboard}>{redisURL}</Button></div>
<div className="flex flex-col items-center justify-center min-h-screen py-4">
<main className="flex flex-col flex-1">
<div className="border-b-2 text-2xl">
<Dialog>
<DialogTrigger className="rounded-full bg-blue-600 p-2 text-black">Delete Sandbox</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure absolutely sure?</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete your sandbox
and remove your data from our servers.
</DialogDescription>
</DialogHeader>
<Button className="rounded-full bg-blue-600 p-4 text-black" onClick={deleteSandbox}>Delete Sandbox</Button>
</DialogContent>
</Dialog>
<div>Host: <Button className="text-2xl bg-transparent text-blue-600 px-0" onClick={copyToClipboard}>{sandbox.host}</Button></div>
<div>Port: <Button className="text-2xl bg-transparent text-blue-600 px-0" onClick={copyToClipboard}>{sandbox.port}</Button></div>
<div>Password: <Button className="text-2xl bg-transparent text-blue-600 px-0" onClick={copyToClipboard}>{sandbox.password}</Button></div>
<div>Redis URL: <Button className="text-2xl bg-transparent text-blue-600 px-0" onClick={copyToClipboard}>{redisURL}</Button></div>
</div>
<Button className="rounded-full bg-blue-600 text-4xl p-8 text-black" onClick={deleteSandbox}>Delete Sandbox</Button>
<CypherInput graphs={[]} onSubmit={sendQuery} />
</main>
</div>
)
Expand All @@ -128,4 +160,3 @@ export default function Page() {
)
}
}

Loading

0 comments on commit da4fa1a

Please sign in to comment.