-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Prompt demo UI and CLI command (#222)
Admin UI and CLI command for issuing cluster tasks. Currently prepends functions executed as part of a task execution with a `taskId` in order to correlate them and demonstrate function calls. In the future this should be moved into a seperate property. <img width="488" alt="image" src="https://github.com/differentialhq/differential/assets/9162298/3c95517a-a97b-4878-93ae-aba287251124"> <img width="1446" alt="image" src="https://github.com/differentialhq/differential/assets/9162298/03359c1a-dcdb-4d0a-93ea-02547341d3c1">
- Loading branch information
1 parent
e47de19
commit 4cab4ca
Showing
11 changed files
with
448 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
"use client"; | ||
|
||
import { client } from "@/client/client"; | ||
import { DataTable } from "@/components/ui/DataTable"; | ||
import { useAuth } from "@clerk/nextjs"; | ||
import { ScrollArea } from "@radix-ui/react-scroll-area"; | ||
import { formatRelative } from "date-fns"; | ||
import { useEffect, useState } from "react"; | ||
import toast from "react-hot-toast"; | ||
import { ThreeDots } from "react-loader-spinner"; | ||
import { functionStatusToCircle } from "../helpers"; | ||
import Link from "next/link"; | ||
import { Button } from "@/components/ui/button"; | ||
|
||
export default function Page({ params }: { params: { clusterId: string } }) { | ||
const { getToken, isLoaded, isSignedIn } = useAuth(); | ||
|
||
const [data, setData] = useState<{ | ||
loading: boolean; | ||
taskId: string | null; | ||
result: string | null; | ||
jobs: { | ||
id: string; | ||
createdAt: Date; | ||
targetFn: string; | ||
status: string; | ||
functionExecutionTime: number | null; | ||
}[]; | ||
}>({ | ||
loading: false, | ||
taskId: null, | ||
result: null, | ||
jobs: [], | ||
}); | ||
|
||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => { | ||
if (event.key === "Enter") { | ||
callPrompt(); | ||
} | ||
}; | ||
|
||
const callPrompt = async () => { | ||
setData({ | ||
loading: true, | ||
taskId: null, | ||
result: null, | ||
jobs: [], | ||
}); | ||
|
||
const taskPrompt = (document.getElementById("prompt") as HTMLInputElement) | ||
.value; | ||
|
||
const result = await client.executeTask({ | ||
headers: { | ||
authorization: `Bearer ${getToken()}`, | ||
}, | ||
params: { | ||
clusterId: params.clusterId, | ||
}, | ||
body: { | ||
task: taskPrompt, | ||
}, | ||
}); | ||
|
||
if (result.status === 200) { | ||
setData({ | ||
loading: false, | ||
taskId: result.body.taskId, | ||
result: result.body.result, | ||
jobs: data.jobs, | ||
}); | ||
} else { | ||
setData({ | ||
loading: false, | ||
taskId: null, | ||
result: "Failed to execute task", | ||
jobs: data.jobs, | ||
}); | ||
} | ||
}; | ||
|
||
useEffect(() => { | ||
const fetchData = async () => { | ||
if (!data.taskId) { | ||
return; | ||
} | ||
|
||
const clusterResult = await client.getClusterDetailsForUser({ | ||
headers: { | ||
authorization: `Bearer ${await getToken()}`, | ||
}, | ||
params: { | ||
clusterId: params.clusterId, | ||
}, | ||
}); | ||
|
||
if (clusterResult.status === 401) { | ||
window.location.reload(); | ||
} | ||
|
||
if (clusterResult.status === 200) { | ||
setData({ | ||
loading: data.loading, | ||
taskId: data.taskId, | ||
result: data.result, | ||
jobs: clusterResult.body.jobs, | ||
}); | ||
} else { | ||
toast.error("Failed to fetch cluster details."); | ||
} | ||
}; | ||
|
||
const interval = setInterval(fetchData, 500); // Refresh every 500ms | ||
|
||
return () => { | ||
clearInterval(interval); // Clear the interval when the component unmounts | ||
}; | ||
}, [params.clusterId, isLoaded, isSignedIn, getToken, data]); | ||
|
||
return ( | ||
<div className="flex flex-row mt-8 space-x-12 mb-12"> | ||
<div className="flex-grow"> | ||
<h2 className="text-xl mb-4">Execute Agent Task</h2> | ||
<p className="text-gray-400 mb-8"> | ||
Prompt the cluster to execute a task. | ||
</p> | ||
<div className="flex flex-row space-x-4 mb-2"> | ||
<input | ||
type="text" | ||
placeholder="Enter your task prompt here" | ||
disabled={data.loading || data.taskId !== null} | ||
className="flex-grow p-2 rounded-md bg-blue-400 placeholder-blue-200 text-white" | ||
id="prompt" | ||
onKeyDown={handleKeyDown} | ||
/> | ||
<Button | ||
size="sm" | ||
disabled={data.loading || data.taskId !== null} | ||
onClick={callPrompt} | ||
> | ||
Execute | ||
</Button> | ||
</div> | ||
{data.result && ( | ||
<div className="flex-grow rounded-md border mb-4 p-4"> | ||
<pre>{data.result}</pre> | ||
</div> | ||
)} | ||
{data.loading && ( | ||
<div className="flex-grow rounded-md border mb-4 p-4"> | ||
<ThreeDots | ||
visible={true} | ||
height="20" | ||
width="20" | ||
color="#9ca3af" | ||
ariaLabel="three-dots-loading" | ||
/> | ||
</div> | ||
)} | ||
<ScrollArea className="rounded-md border" style={{ height: 400 }}> | ||
<DataTable | ||
data={data.jobs | ||
.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)) | ||
.filter((s) => s.id.startsWith(data.taskId || "")) | ||
.map((s) => ({ | ||
jobId: s.id, | ||
targetFn: s.targetFn, | ||
status: s.status, | ||
createdAt: formatRelative(new Date(s.createdAt), new Date()), | ||
functionExecutionTime: s.functionExecutionTime, | ||
}))} | ||
noDataMessage="No functions have been performed as part of the task." | ||
columnDef={[ | ||
{ | ||
accessorKey: "jobId", | ||
header: "Execution ID", | ||
cell: ({ row }) => { | ||
const jobId: string = row.getValue("jobId"); | ||
|
||
return ( | ||
<Link | ||
className="font-mono text-md underline" | ||
href={`/clusters/${params.clusterId}/activity?jobId=${jobId}`} | ||
> | ||
{jobId.substring(jobId.length - 6)} | ||
</Link> | ||
); | ||
}, | ||
}, | ||
{ | ||
accessorKey: "targetFn", | ||
header: "Function", | ||
}, | ||
{ | ||
accessorKey: "createdAt", | ||
header: "Called", | ||
}, | ||
{ | ||
accessorKey: "status", | ||
header: "", | ||
cell: ({ row }) => { | ||
const status = row.getValue("status"); | ||
|
||
return functionStatusToCircle(status as string); | ||
}, | ||
}, | ||
]} | ||
/> | ||
</ScrollArea> | ||
</div> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import { CommandModule } from "yargs"; | ||
import { selectCluster } from "../utils"; | ||
import { client } from "../lib/client"; | ||
import { input } from "@inquirer/prompts"; | ||
|
||
interface TaskArgs { | ||
cluster?: string; | ||
task?: string; | ||
} | ||
|
||
export const Task: CommandModule<{}, TaskArgs> = { | ||
command: "task", | ||
describe: "Execute a task in the cluster using a human readable prompt", | ||
builder: (yargs) => | ||
yargs | ||
.option("cluster", { | ||
describe: "Cluster ID", | ||
demandOption: false, | ||
type: "string", | ||
}) | ||
.option("task", { | ||
describe: "Task for the cluster to perform", | ||
demandOption: false, | ||
type: "string", | ||
}), | ||
handler: async ({ cluster, task }) => { | ||
if (!cluster) { | ||
cluster = await selectCluster(); | ||
if (!cluster) { | ||
console.log("No cluster selected"); | ||
return; | ||
} | ||
} | ||
|
||
if (!task) { | ||
task = await input({ | ||
message: "Human readable prompt for the cluster to perform", | ||
validate: (value) => { | ||
if (!value) { | ||
return "Prompt is required"; | ||
} | ||
return true; | ||
}, | ||
}); | ||
} | ||
|
||
try { | ||
const result = await executeTask(cluster, task); | ||
console.log(result); | ||
} catch (e) { | ||
console.error(e); | ||
} | ||
}, | ||
}; | ||
|
||
const executeTask = async (clusterId: string, task: string) => { | ||
const result = await client.executeTask({ | ||
params: { | ||
clusterId, | ||
}, | ||
body: { | ||
task, | ||
}, | ||
}); | ||
|
||
if (result.status !== 200) { | ||
throw new Error(`Failed to prompt cluster: ${result.status}`); | ||
} | ||
return result.body.result; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.