Skip to content

Commit

Permalink
feat: Prompt demo UI and CLI command (#222)
Browse files Browse the repository at this point in the history
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
johnjcsmith authored May 8, 2024
1 parent e47de19 commit 4cab4ca
Show file tree
Hide file tree
Showing 11 changed files with 448 additions and 2 deletions.
213 changes: 213 additions & 0 deletions admin/app/clusters/[clusterId]/tasks/page.tsx
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>
);
}
23 changes: 23 additions & 0 deletions admin/client/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const definition = {
.array(
z.object({
name: z.string(),
schema: z.string(),
}),
)
.optional(),
Expand Down Expand Up @@ -394,6 +395,7 @@ export const definition = {
query: z.object({
jobId: z.string().optional(),
deploymentId: z.string().optional(),
taskId: z.string().optional(),
}),
},
createDeployment: {
Expand Down Expand Up @@ -712,6 +714,27 @@ export const definition = {
401: z.undefined(),
},
},
executeTask: {
method: "POST",
path: "/clusters/:clusterId/task",
headers: z.object({
authorization: z.string(),
}),
body: z.object({
task: z.string(),
}),
responses: {
401: z.undefined(),
404: z.undefined(),
200: z.object({
result: z.any(),
taskId: z.string(),
}),
500: z.object({
error: z.string(),
}),
},
},
} as const;

export const contract = c.router(definition);
1 change: 1 addition & 0 deletions admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"react-hook-form": "^7.50.1",
"react-hot-toast": "^2.4.1",
"react-json-pretty": "^2.2.0",
"react-loader-spinner": "^6.1.6",
"react-syntax-highlighter": "^15.5.0",
"recharts": "^2.10.4",
"tailwind-merge": "^2.2.0",
Expand Down
21 changes: 21 additions & 0 deletions cli/src/client/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const definition = {
.array(
z.object({
name: z.string(),
schema: z.string(),
}),
)
.optional(),
Expand Down Expand Up @@ -712,6 +713,26 @@ export const definition = {
401: z.undefined(),
},
},
executeTask: {
method: "POST",
path: "/clusters/:clusterId/task",
headers: z.object({
authorization: z.string(),
}),
body: z.object({
task: z.string(),
}),
responses: {
401: z.undefined(),
404: z.undefined(),
200: z.object({
result: z.any(),
}),
500: z.object({
error: z.string(),
}),
},
},
} as const;

export const contract = c.router(definition);
70 changes: 70 additions & 0 deletions cli/src/commands/task.ts
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;
};
2 changes: 2 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Repl } from "./commands/repl";
import { Context } from "./commands/context";
import { setCurrentContext } from "./lib/context";
import { Services } from "./commands/services";
import { Task } from "./commands/task";

const cli = yargs(hideBin(process.argv))
.scriptName("differential")
Expand All @@ -35,6 +36,7 @@ if (authenticated) {
.command(ClientLibrary)
.command(Repl)
.command(Context)
.command(Task)
.command(Services);
} else {
console.log(
Expand Down
Loading

0 comments on commit 4cab4ca

Please sign in to comment.