diff --git a/admin/app/clusters/[clusterId]/tasks/page.tsx b/admin/app/clusters/[clusterId]/tasks/page.tsx new file mode 100644 index 00000000..988ae7c2 --- /dev/null +++ b/admin/app/clusters/[clusterId]/tasks/page.tsx @@ -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) => { + 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 ( +
+
+

Execute Agent Task

+

+ Prompt the cluster to execute a task. +

+
+ + +
+ {data.result && ( +
+
{data.result}
+
+ )} + {data.loading && ( +
+ +
+ )} + + (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 ( + + {jobId.substring(jobId.length - 6)} + + ); + }, + }, + { + accessorKey: "targetFn", + header: "Function", + }, + { + accessorKey: "createdAt", + header: "Called", + }, + { + accessorKey: "status", + header: "", + cell: ({ row }) => { + const status = row.getValue("status"); + + return functionStatusToCircle(status as string); + }, + }, + ]} + /> + +
+
+ ); +} diff --git a/admin/client/contract.ts b/admin/client/contract.ts index 09271663..73625a7b 100644 --- a/admin/client/contract.ts +++ b/admin/client/contract.ts @@ -26,6 +26,7 @@ export const definition = { .array( z.object({ name: z.string(), + schema: z.string(), }), ) .optional(), @@ -394,6 +395,7 @@ export const definition = { query: z.object({ jobId: z.string().optional(), deploymentId: z.string().optional(), + taskId: z.string().optional(), }), }, createDeployment: { @@ -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); diff --git a/admin/package.json b/admin/package.json index d56f3faf..43e77be6 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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", diff --git a/cli/src/client/contract.ts b/cli/src/client/contract.ts index 09271663..9cd28f2e 100644 --- a/cli/src/client/contract.ts +++ b/cli/src/client/contract.ts @@ -26,6 +26,7 @@ export const definition = { .array( z.object({ name: z.string(), + schema: z.string(), }), ) .optional(), @@ -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); diff --git a/cli/src/commands/task.ts b/cli/src/commands/task.ts new file mode 100644 index 00000000..74a704c2 --- /dev/null +++ b/cli/src/commands/task.ts @@ -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; +}; diff --git a/cli/src/index.ts b/cli/src/index.ts index 048e38f6..5cdf189e 100755 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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") @@ -35,6 +36,7 @@ if (authenticated) { .command(ClientLibrary) .command(Repl) .command(Context) + .command(Task) .command(Services); } else { console.log( diff --git a/cli/src/lib/upload.ts b/cli/src/lib/upload.ts index 1730d8cf..234a9ef6 100644 --- a/cli/src/lib/upload.ts +++ b/cli/src/lib/upload.ts @@ -39,6 +39,8 @@ export const uploadAsset = async ({ const { presignedUrl } = upload.body; + log("Uploading asset to s3", { presignedUrl }); + const response = await fetch(presignedUrl, { method: "PUT", body: readFileSync(path), @@ -47,6 +49,8 @@ export const uploadAsset = async ({ }, }); + log("Response from S3 put", response); + if (response.status !== 200) { throw new Error( "Failed to upload asset. Please check provided options and cluster configuration.", diff --git a/control-plane/src/modules/agents/agent.ts b/control-plane/src/modules/agents/agent.ts index 2aba1238..a7f8009c 100644 Binary files a/control-plane/src/modules/agents/agent.ts and b/control-plane/src/modules/agents/agent.ts differ diff --git a/control-plane/src/modules/contract.ts b/control-plane/src/modules/contract.ts index 9cd28f2e..73625a7b 100644 --- a/control-plane/src/modules/contract.ts +++ b/control-plane/src/modules/contract.ts @@ -395,6 +395,7 @@ export const definition = { query: z.object({ jobId: z.string().optional(), deploymentId: z.string().optional(), + taskId: z.string().optional(), }), }, createDeployment: { @@ -727,6 +728,7 @@ export const definition = { 404: z.undefined(), 200: z.object({ result: z.any(), + taskId: z.string(), }), 500: z.object({ error: z.string(), diff --git a/control-plane/src/modules/router.ts b/control-plane/src/modules/router.ts index 58eea05c..a46c2335 100644 --- a/control-plane/src/modules/router.ts +++ b/control-plane/src/modules/router.ts @@ -35,6 +35,7 @@ import { import { deploymentResultFromNotification } from "./deployment/cfn-manager"; import { env } from "../utilities/env"; import { logger } from "../utilities/logger"; +import { ulid } from "ulid"; const readFile = util.promisify(fs.readFile); @@ -958,11 +959,12 @@ export const router = s.router(contract, { const { executeTaskForCluster } = require("./agents/agent"); - const result = await executeTaskForCluster({ clusterId }, task); + const taskId = ulid(); + const result = await executeTaskForCluster({ clusterId }, taskId, task); return { status: 200, - body: { result }, + body: { result, taskId }, }; }, }); diff --git a/package-lock.json b/package-lock.json index ff6764b6..267a6575 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,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", @@ -8050,6 +8051,11 @@ "version": "2.0.3", "license": "MIT" }, + "node_modules/@types/stylis": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", + "integrity": "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==" + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "license": "MIT" @@ -9357,6 +9363,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001612", "funding": [ @@ -10532,6 +10546,14 @@ "node": "*" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, "node_modules/cssesc": { "version": "3.0.0", "license": "MIT", @@ -21145,6 +21167,87 @@ "react-dom": ">=15.0" } }, + "node_modules/react-loader-spinner": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/react-loader-spinner/-/react-loader-spinner-6.1.6.tgz", + "integrity": "sha512-x5h1Jcit7Qn03MuKlrWcMG9o12cp9SNDVHVJTNRi9TgtGPKcjKiXkou4NRfLAtXaFB3+Z8yZsVzONmPzhv2ErA==", + "dependencies": { + "react-is": "^18.2.0", + "styled-components": "^6.1.2" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-loader-spinner/node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/react-loader-spinner/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/react-loader-spinner/node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/react-loader-spinner/node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "node_modules/react-loader-spinner/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/react-loader-spinner/node_modules/styled-components": { + "version": "6.1.10", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.10.tgz", + "integrity": "sha512-4K8IKcn7iOt76riGLjvBhRyNPTkUKTvmnwoRFBOtJLswVvzy2VsoE2KOrfl9FJLQUYbITLJY2wfIZ3tjbkA/Zw==", + "dependencies": { + "@emotion/is-prop-valid": "1.2.2", + "@emotion/unitless": "0.8.1", + "@types/stylis": "4.2.5", + "css-to-react-native": "3.2.0", + "csstype": "3.1.3", + "postcss": "8.4.38", + "shallowequal": "1.1.0", + "stylis": "4.3.2", + "tslib": "2.6.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/react-loader-spinner/node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + }, "node_modules/react-remove-scroll": { "version": "2.5.5", "license": "MIT", @@ -22135,6 +22238,11 @@ "node": ">=8" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "license": "MIT",