From 976ff04cce9806929f502ba849b2a724fabc3f8a Mon Sep 17 00:00:00 2001 From: Reinier van der Leer Date: Wed, 10 Jul 2024 10:04:51 -0600 Subject: [PATCH] feat(autogpt_builder): Initial Monitor page implementation (#7335) * feat(agent_builder): Add shad/cn + Radix Icons + Tailwind * move favicon.ico to static folder * delete empty custominput.css * feat(agent_builder): Add basic app layout with nav * add timeline chart to Monitor page and improve overall mock-up functionality * add some (mock) stats to the overview * monitor/page.tsx: Switch from mock data to API & reorder file * undo out-of-scope changes to Flow.tsx and .css files * fix linting issue in `FlowRunsTimeline` --------- Co-authored-by: Aarushi Co-authored-by: Swifty --- rnd/autogpt_builder/package.json | 4 + rnd/autogpt_builder/src/app/monitor/page.tsx | 457 +++++++++++++++--- .../src/components/ui/badge.tsx | 36 ++ .../src/components/ui/calendar.tsx | 72 +++ .../src/components/ui/popover.tsx | 33 ++ rnd/autogpt_builder/src/lib/utils.ts | 12 + rnd/autogpt_builder/yarn.lock | 36 ++ 7 files changed, 572 insertions(+), 78 deletions(-) create mode 100644 rnd/autogpt_builder/src/components/ui/badge.tsx create mode 100644 rnd/autogpt_builder/src/components/ui/calendar.tsx create mode 100644 rnd/autogpt_builder/src/components/ui/popover.tsx diff --git a/rnd/autogpt_builder/package.json b/rnd/autogpt_builder/package.json index 0c5c05640..d69d943c6 100644 --- a/rnd/autogpt_builder/package.json +++ b/rnd/autogpt_builder/package.json @@ -12,14 +12,18 @@ "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-slot": "^1.1.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "moment": "^2.30.1", "dotenv": "^16.4.5", "lucide-react": "^0.407.0", "next": "14.2.4", "next-themes": "^0.3.0", "react": "^18", + "react-day-picker": "^8.10.1", "react-dom": "^18", "react-modal": "^3.16.1", "reactflow": "^11.11.4", diff --git a/rnd/autogpt_builder/src/app/monitor/page.tsx b/rnd/autogpt_builder/src/app/monitor/page.tsx index 68ee10324..4dd3af06e 100644 --- a/rnd/autogpt_builder/src/app/monitor/page.tsx +++ b/rnd/autogpt_builder/src/app/monitor/page.tsx @@ -1,11 +1,165 @@ "use client"; -import React, { useState } from 'react'; -import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import React, { useEffect, useState } from 'react'; +import Link from 'next/link'; +import moment from 'moment'; +import { ComposedChart, Legend, Line, ResponsiveContainer, Scatter, Tooltip, XAxis, YAxis } from 'recharts'; +import { Pencil2Icon } from '@radix-ui/react-icons'; +import AutoGPTServerAPI, { Flow, NodeExecutionResult } from '@/lib/autogpt_server_api'; +import { hashString } from '@/lib/utils'; +import { Badge } from "@/components/ui/badge"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -const AgentFlowList = ({ flows, onSelectFlow }) => ( +const Monitor = () => { + const [flows, setFlows] = useState([]); + const [flowRuns, setFlowRuns] = useState([]); + const [selectedFlow, setSelectedFlow] = useState(null); + + const api = new AutoGPTServerAPI(); + + useEffect(() => fetchFlowsAndRuns(), []); + + function fetchFlowsAndRuns() { + // Fetch flow IDs + api.listFlowIDs() + .then(flowIDs => { + Promise.all(flowIDs.map(flowID => { + // Fetch flow run IDs + api.listFlowRunIDs(flowID) + .then(runIDs => { + runIDs.map(runID => { + // Fetch flow run + api.getFlowExecutionInfo(flowID, runID) + .then(execInfo => setFlowRuns(flowRuns => { + const flowRunIndex = flowRuns.findIndex(fr => fr.id == runID); + const flowRun = flowRunFromNodeExecutionResults(flowID, runID, execInfo) + if (flowRunIndex > -1) { + flowRuns.splice(flowRunIndex, 1, flowRun) + } + else { + flowRuns.push(flowRun) + } + return flowRuns + })); + }); + }); + + // Fetch flow + return api.getFlow(flowID); + })) + .then(flows => setFlows(flows)); + }); + } + + return ( +
+
+ setSelectedFlow(f.id == selectedFlow?.id ? null : f)} + /> +
+
+ v.flowID == selectedFlow.id) + : flowRuns + ) + .toSorted((a, b) => Number(a.startTime) - Number(b.startTime)) + } + /> +
+
+ {selectedFlow && ( + + +
+ {selectedFlow.name} +

{selectedFlow.id}

+
+ + Edit Flow + +
+ + v.flowID == selectedFlow.id)} + /> + +
+ ) || ( + + )} +
+
+ ); +}; + +type FlowRun = { + id: string + flowID: string + status: 'running' | 'waiting' | 'success' | 'failed' + startTime: number // unix timestamp (ms) + duration: number // seconds + + nodeExecutionResults: NodeExecutionResult[] +}; + +function flowRunFromNodeExecutionResults( + flowID: string, runID: string, nodeExecutionResults: NodeExecutionResult[] +): FlowRun { + // Determine overall status + let status: 'running' | 'waiting' | 'success' | 'failed' = 'success'; + for (const execution of nodeExecutionResults) { + if (execution.status === 'FAILED') { + status = 'failed'; + break; + } else if (['QUEUED', 'RUNNING'].includes(execution.status)) { + status = 'running'; + break; + } else if (execution.status === 'INCOMPLETE') { + status = 'waiting'; + } + } + + // Determine aggregate startTime and duration + const startTime = Math.min( + ...nodeExecutionResults.map(ner => ner.start_time?.getTime() || Date.now()) + ); + const endTime = ( + ['success', 'failed'].includes(status) + ? Math.max(...nodeExecutionResults.map(ner => ner.end_time?.getTime() || 0)) + : Date.now() + ); + const duration = (endTime - startTime) / 1000; // Convert to seconds + + return { + id: runID, + flowID: flowID, + status, + startTime, + duration, + nodeExecutionResults: nodeExecutionResults + }; +} + +const AgentFlowList = ( + { flows, flowRuns, selectedFlow, onSelectFlow }: { + flows: Flow[], + flowRuns?: FlowRun[], + selectedFlow: Flow | null, + onSelectFlow: (f: Flow) => void, + } +) => ( Agent Flows @@ -15,25 +169,62 @@ const AgentFlowList = ({ flows, onSelectFlow }) => ( Name - Status - Last Run + {/* Status */} + {/* Last updated */} + {flowRuns && # of runs} + {flowRuns && Last run} - {flows.map((flow) => ( - onSelectFlow(flow)} className="cursor-pointer"> - {flow.name} - {flow.status} - {flow.lastRun} - - ))} + {flows.map((flow) => { + let runCount, lastRun: FlowRun | null; + if (flowRuns) { + const _flowRuns = flowRuns.filter(r => r.flowID == flow.id); + runCount = _flowRuns.length; + lastRun = runCount == 0 ? null : _flowRuns.reduce( + (a, c) => a.startTime < c.startTime ? a : c + ); + } + return ( + onSelectFlow(flow)} + data-state={selectedFlow?.id == flow.id ? "selected" : null} + > + {flow.name} + {/* */} + {/* + {flow.updatedAt ?? "???"} + */} + {flowRuns && {runCount}} + {flowRuns && (!lastRun ? : + + {moment(lastRun.startTime).fromNow()} + )} + + ) + })} ); -const FlowRunsList = ({ runs }) => ( +const FlowStatusBadge = ({ status }: { status: "active" | "disabled" | "failing" }) => ( + + {status} + +); + +const FlowRunsList = ({ flows, runs }: { flows: Flow[], runs: FlowRun[] }) => ( Flow Runs @@ -42,7 +233,8 @@ const FlowRunsList = ({ runs }) => ( - Time + Flow + Started Status Duration @@ -50,9 +242,10 @@ const FlowRunsList = ({ runs }) => ( {runs.map((run) => ( - {run.time} - {run.status} - {run.duration} + {flows.find(f => f.id == run.flowID)!.name} + {moment(run.startTime).format("HH:mm")} + + {formatDuration(run.duration)} ))} @@ -61,69 +254,177 @@ const FlowRunsList = ({ runs }) => ( ); -const FlowStats = ({ stats }) => ( - - - Flow Statistics - - - - - - - - - - - - - +const FlowRunStatusBadge = ({ status }: { status: FlowRun['status'] }) => ( + + {status} + ); -const Monitor = () => { - const [selectedFlow, setSelectedFlow] = useState(null); - - // Mock data - const flows = [ - { id: 1, name: 'JARVIS', status: 'Waiting for input', lastRun: '5 minutes ago' }, - { id: 2, name: 'Time machine', status: 'Crashed', lastRun: '10 minutes ago' }, - { id: 3, name: 'BlueSky digest', status: 'Running', lastRun: '2 minutes ago' }, - ]; - const runs = [ - { id: 1, time: '12:34', status: 'Success', duration: '1m 26s' }, - { id: 2, time: '11:49', status: 'Success', duration: '55s' }, - { id: 3, time: '11:23', status: 'Success', duration: '48s' }, - ]; - - const stats = [ - { name: 'Last 24 Hours', value: 16 }, - { name: 'Last 30 Days', value: 106 }, - ]; +const FlowRunsStats = ( + { flows, flowRuns }: { + flows: Flow[], + flowRuns: FlowRun[], + } +) => { + /* "dateMin": since the first flow in the dataset + * number > 0: custom date (unix timestamp) + * number < 0: offset relative to Date.now() (in seconds) */ + const [statsSince, setStatsSince] = useState(-24*3600) + const statsSinceTimestamp = ( // unix timestamp or null + typeof(statsSince) == "string" + ? null + : statsSince < 0 + ? Date.now() + (statsSince*1000) + : statsSince + ) + const filteredFlowRuns = statsSinceTimestamp != null + ? flowRuns.filter(fr => fr.startTime > statsSinceTimestamp) + : flowRuns; return ( -
-
- -
-
- - -
- {selectedFlow && ( -
- - - {selectedFlow.name} - - - - - + + + Flow Run Stats +
+ + + + + + + + + + setStatsSince(selectedDay.getTime())} + initialFocus + /> + + +
- )} -
- ); -}; + + + + +

Total runs: {filteredFlowRuns.length}

+

+ Total duration: { + filteredFlowRuns.reduce((total, run) => total + run.duration, 0) + } seconds +

+ {/*

Total cost: €1,23

*/} +
+
+ + ) +} + +const FlowRunsTimeline = ( + { flows, flowRuns, dataMin, className }: { + flows: Flow[], + flowRuns: FlowRun[], + dataMin: "dataMin" | number, + className?: string, + } +) => ( + /* TODO: make logarithmic? */ + + + { + const now = moment(); + const time = moment(unixTime); + return now.diff(time, 'hours') < 24 + ? time.format('HH:mm') + : time.format('YYYY-MM-DD HH:mm'); + }} + name="Time" + scale="time" + /> + s > 90 ? `${Math.round(s / 60)}m` : `${s}s`} + /> + { + if (payload && payload.length) { + const data: FlowRun & { time: number, _duration: number } = payload[0].payload; + const flow = flows.find(f => f.id === data.flowID); + return ( + +

Flow: {flow ? flow.name : 'Unknown'}

+

Start Time: {moment(data.startTime).format('YYYY-MM-DD HH:mm:ss')}

+

+ Duration: {formatDuration(data.duration)} +

+

Status:

+
+ ); + } + return null; + }} + /> + {flows.map((flow) => ( + fr.flowID == flow.id).map(fr => ({ + ...fr, + time: fr.startTime + (fr.duration * 1000), + _duration: fr.duration, + }))} + name={flow.name} + fill={`hsl(${hashString(flow.id) * 137.5 % 360}, 70%, 50%)`} + /> + ))} + {flowRuns.map((run) => ( + + ))} + +
+
+); + +function formatDuration(seconds: number): string { + return ( + seconds < 100 + ? seconds.toPrecision(2) + : Math.round(seconds) + ).toString() + "s"; +} export default Monitor; diff --git a/rnd/autogpt_builder/src/components/ui/badge.tsx b/rnd/autogpt_builder/src/components/ui/badge.tsx new file mode 100644 index 000000000..a73999b6a --- /dev/null +++ b/rnd/autogpt_builder/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border border-neutral-200 px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-950 focus:ring-offset-2 dark:border-neutral-800 dark:focus:ring-neutral-300 cursor-default", + { + variants: { + variant: { + default: + "border-transparent bg-neutral-900 text-neutral-50 shadow dark:bg-neutral-50 dark:text-neutral-900", + secondary: + "border-transparent bg-neutral-100 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50", + destructive: + "border-transparent bg-red-500 text-neutral-50 shadow dark:bg-red-900 dark:text-neutral-50", + outline: "text-neutral-950 dark:text-neutral-50", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/rnd/autogpt_builder/src/components/ui/calendar.tsx b/rnd/autogpt_builder/src/components/ui/calendar.tsx new file mode 100644 index 000000000..6e1396dc1 --- /dev/null +++ b/rnd/autogpt_builder/src/components/ui/calendar.tsx @@ -0,0 +1,72 @@ +"use client" + +import * as React from "react" +import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-neutral-900 text-neutral-50 hover:bg-neutral-900 hover:text-neutral-50 focus:bg-neutral-900 focus:text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-neutral-50 dark:hover:text-neutral-900 dark:focus:bg-neutral-50 dark:focus:text-neutral-900", + day_today: "bg-neutral-100 text-neutral-900 dark:bg-neutral-800 dark:text-neutral-50", + day_outside: + "day-outside text-neutral-500 opacity-50 aria-selected:bg-neutral-100/50 aria-selected:text-neutral-500 aria-selected:opacity-30 dark:text-neutral-400 dark:aria-selected:bg-neutral-800/50 dark:aria-selected:text-neutral-400", + day_disabled: "text-neutral-500 opacity-50 dark:text-neutral-400", + day_range_middle: + "aria-selected:bg-neutral-100 aria-selected:text-neutral-900 dark:aria-selected:bg-neutral-800 dark:aria-selected:text-neutral-50", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ ...props }) => , + IconRight: ({ ...props }) => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/rnd/autogpt_builder/src/components/ui/popover.tsx b/rnd/autogpt_builder/src/components/ui/popover.tsx new file mode 100644 index 000000000..b270062e2 --- /dev/null +++ b/rnd/autogpt_builder/src/components/ui/popover.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverAnchor = PopoverPrimitive.Anchor + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } diff --git a/rnd/autogpt_builder/src/lib/utils.ts b/rnd/autogpt_builder/src/lib/utils.ts index d084ccade..9ca3eac67 100644 --- a/rnd/autogpt_builder/src/lib/utils.ts +++ b/rnd/autogpt_builder/src/lib/utils.ts @@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +/** Derived from https://stackoverflow.com/a/7616484 */ +export function hashString(str: string): number { + let hash = 0, chr: number; + if (str.length === 0) return hash; + for (let i = 0; i < str.length; i++) { + chr = str.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; // Convert to 32bit integer + } + return hash; +} diff --git a/rnd/autogpt_builder/yarn.lock b/rnd/autogpt_builder/yarn.lock index bd0e2722e..f0bd8a686 100644 --- a/rnd/autogpt_builder/yarn.lock +++ b/rnd/autogpt_builder/yarn.lock @@ -340,6 +340,27 @@ aria-hidden "^1.1.1" react-remove-scroll "2.5.7" +"@radix-ui/react-popover@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.1.tgz#604b783cdb3494ed4f16a58c17f0e81e61ab7775" + integrity sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g== + dependencies: + "@radix-ui/primitive" "1.1.0" + "@radix-ui/react-compose-refs" "1.1.0" + "@radix-ui/react-context" "1.1.0" + "@radix-ui/react-dismissable-layer" "1.1.0" + "@radix-ui/react-focus-guards" "1.1.0" + "@radix-ui/react-focus-scope" "1.1.0" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.0" + "@radix-ui/react-portal" "1.1.1" + "@radix-ui/react-presence" "1.1.0" + "@radix-ui/react-primitive" "2.0.0" + "@radix-ui/react-slot" "1.1.0" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.7" + "@radix-ui/react-popper@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a" @@ -1339,6 +1360,11 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +date-fns@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" + integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== + debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -2556,6 +2582,11 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== +moment@^2.30.1: + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" @@ -2891,6 +2922,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +react-day-picker@^8.10.1: + version "8.10.1" + resolved "https://registry.yarnpkg.com/react-day-picker/-/react-day-picker-8.10.1.tgz#4762ec298865919b93ec09ba69621580835b8e80" + integrity sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA== + react-dom@^18: version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4"