feat(autogpt_builder/monitor): Monitor page v0.2 (#7385)

- Improve responsive grid layout
  - Remove `container` class from `<main>` to utilize full screen width

- Improve detail views & add view for run details
  - Make flow run list entries selectable
  - Create `FlowInfo` and `FlowRunInfo` components
  - Improve layout of `FlowRunsStats`

- Improve ScrollableLegend spacing & styling
  - Hide scroll bar of scrollable legend
  - Center legend items if there is space left
  - Round icons
  - Vertically align icons with labels
  - FIX: Add condition to not display legend items for series with `legendType="none"`

- Add periodic 5s refresh of non-terminal flow run statuses
  - Split off `refreshFlowRuns(flowID)` from `fetchFlowsAndRuns()`
  - Add effect to run `refreshFlowRuns` every 5 seconds

- Improve and expand FlowRun info
  - Add `FlowRun.totalRunTime`: sum of the individual execution durations of all nodes
  - Add `FlowRun.endTime`
  - Use `NodeExecutionResult.add_time` instead of `start_time` as `FlowRun.startTime`

- Sort Flows by last executed

- Add icons to navbar items & hide unused items Backtrack and Explore

- Change UI mentions of "(Agent) Flow" to "Agent"
pull/7395/head
Reinier van der Leer 2024-07-12 06:56:48 -06:00 committed by GitHub
parent b7096e01fb
commit a2d8d9bac9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 277 additions and 157 deletions

View File

@ -4,6 +4,7 @@ import { ThemeProvider as NextThemeProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
import { Inter } from "next/font/google";
import Link from "next/link";
import { CubeIcon, Pencil1Icon, ReaderIcon, TimerIcon } from "@radix-ui/react-icons";
import "./globals.css";
@ -27,10 +28,12 @@ function ThemeProvider({ children, ...props }: ThemeProviderProps) {
const NavBar = () => (
<nav className="bg-white dark:bg-slate-800 p-4 flex justify-between items-center shadow">
<div className="flex space-x-4">
<Link href="/monitor" className={buttonVariants({ variant: "ghost" })}>Monitor</Link>
<Link href="/build" className={buttonVariants({ variant: "ghost" })}>Build</Link>
<Link href="/backtrack" className={buttonVariants({ variant: "ghost" })}>Backtrack</Link>
<Link href="/explore" className={buttonVariants({ variant: "ghost" })}>Explore</Link>
<Link href="/monitor" className={buttonVariants({ variant: "ghost" })}>
<TimerIcon className="mr-1" /> Monitor
</Link>
<Link href="/build" className={buttonVariants({ variant: "ghost" })}>
<Pencil1Icon className="mr-1" /> Build
</Link>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -66,7 +69,7 @@ export default function RootLayout({
>
<div className="min-h-screen bg-gray-200 text-gray-900">
<NavBar />
<main className="container mx-auto p-4">
<main className="mx-auto p-4">
{children}
</main>
</div>

View File

@ -2,10 +2,20 @@
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 {
ComposedChart,
DefaultLegendContentProps,
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 { cn, hashString } from '@/lib/utils';
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@ -17,35 +27,22 @@ const Monitor = () => {
const [flows, setFlows] = useState<Flow[]>([]);
const [flowRuns, setFlowRuns] = useState<FlowRun[]>([]);
const [selectedFlow, setSelectedFlow] = useState<Flow | null>(null);
const [selectedRun, setSelectedRun] = useState<FlowRun | null>(null);
const api = new AutoGPTServerAPI();
useEffect(() => fetchFlowsAndRuns(), []);
useEffect(() => {
const intervalId = setInterval(() => flows.map(f => refreshFlowRuns(f.id)), 5000);
return () => clearInterval(intervalId);
}, []);
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
}));
});
});
refreshFlowRuns(flowID);
// Fetch flow
return api.getFlow(flowID);
@ -54,50 +51,75 @@ const Monitor = () => {
});
}
function refreshFlowRuns(flowID: string) {
// Fetch flow run IDs
api.listFlowRunIDs(flowID)
.then(runIDs => runIDs.map(runID => {
let run;
if (
(run = flowRuns.find(fr => fr.id == runID))
&& !["waiting", "running"].includes(run.status)
) {
return
}
// 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
}));
}));
}
return (
<div className="grid grid-cols-1 lg:grid-cols-4 xl:grid-cols-10 gap-4">
<div className="lg:col-span-2 xl:col-span-2">
<AgentFlowList
flows={flows}
flowRuns={flowRuns}
selectedFlow={selectedFlow}
onSelectFlow={f => setSelectedFlow(f.id == selectedFlow?.id ? null : f)}
/>
</div>
<div className="lg:col-span-2 xl:col-span-2 space-y-4">
<FlowRunsList
flows={flows}
runs={
(
selectedFlow
? flowRuns.filter(v => v.flowID == selectedFlow.id)
: flowRuns
)
.toSorted((a, b) => Number(a.startTime) - Number(b.startTime))
}
/>
</div>
<div className="col-span-1 lg:col-span-4 xl:col-span-6">
{selectedFlow && (
<Card>
<CardHeader className="flex-row items-center justify-between space-y-0 space-x-3">
<div>
<CardTitle>{selectedFlow.name}</CardTitle>
<p className="mt-2"><code>{selectedFlow.id}</code></p>
</div>
<Link className={buttonVariants({ variant: "outline" })} href={`/build?flowID=${selectedFlow.id}`}>
<Pencil2Icon className="mr-2" /> Edit Flow
</Link>
</CardHeader>
<CardContent>
<FlowRunsStats
flows={flows}
flowRuns={flowRuns.filter(v => v.flowID == selectedFlow.id)}
/>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-5 lg:grid-cols-4 xl:grid-cols-10 gap-4">
<AgentFlowList
className="md:col-span-2 xl:col-span-3 xxl:col-span-2"
flows={flows}
flowRuns={flowRuns}
selectedFlow={selectedFlow}
onSelectFlow={f => {
setSelectedRun(null);
setSelectedFlow(f.id == selectedFlow?.id ? null : f);
}}
/>
<FlowRunsList
className="md:col-span-3 lg:col-span-2 xl:col-span-3 space-y-4"
flows={flows}
runs={
(
selectedFlow
? flowRuns.filter(v => v.flowID == selectedFlow.id)
: flowRuns
)
.toSorted((a, b) => Number(a.startTime) - Number(b.startTime))
}
selectedRun={selectedRun}
onSelectRun={r => setSelectedRun(r.id == selectedRun?.id ? null : r)}
/>
<div className="col-span-full xl:col-span-4 xxl:col-span-5">
{selectedRun && (
<FlowRunInfo
flow={selectedFlow || flows.find(f => f.id == selectedRun.flowID)!}
flowRun={selectedRun}
/>
) || selectedFlow && (
<FlowInfo
flow={selectedFlow}
flowRuns={flowRuns.filter(r => r.flowID == selectedFlow.id)}
/>
) || (
<FlowRunsStats flows={flows} flowRuns={flowRuns} />
<Card className="p-6">
<FlowRunsStats flows={flows} flowRuns={flowRuns} />
</Card>
)}
</div>
</div>
@ -109,7 +131,9 @@ type FlowRun = {
flowID: string
status: 'running' | 'waiting' | 'success' | 'failed'
startTime: number // unix timestamp (ms)
endTime: number // unix timestamp (ms)
duration: number // seconds
totalRunTime: number // seconds
nodeExecutionResults: NodeExecutionResult[]
};
@ -131,38 +155,47 @@ function flowRunFromNodeExecutionResults(
}
}
// Determine aggregate startTime and duration
// Determine aggregate startTime, endTime, and totalRunTime
const now = Date.now();
const startTime = Math.min(
...nodeExecutionResults.map(ner => ner.start_time?.getTime() || Date.now())
...nodeExecutionResults.map(ner => ner.add_time.getTime()), now
);
const endTime = (
['success', 'failed'].includes(status)
? Math.max(...nodeExecutionResults.map(ner => ner.end_time?.getTime() || 0))
: Date.now()
? Math.max(
...nodeExecutionResults.map(ner => ner.end_time?.getTime() || 0), startTime
)
: now
);
const duration = (endTime - startTime) / 1000; // Convert to seconds
const duration = (endTime - startTime) / 1000; // Convert to seconds
const totalRunTime = nodeExecutionResults.reduce((cum, node) => (
cum + ((node.end_time?.getTime() ?? now) - (node.start_time?.getTime() ?? now))
), 0) / 1000;
return {
id: runID,
flowID: flowID,
status,
startTime,
endTime,
duration,
totalRunTime,
nodeExecutionResults: nodeExecutionResults
};
}
const AgentFlowList = (
{ flows, flowRuns, selectedFlow, onSelectFlow }: {
{ flows, flowRuns, selectedFlow, onSelectFlow, className }: {
flows: Flow[],
flowRuns?: FlowRun[],
selectedFlow: Flow | null,
onSelectFlow: (f: Flow) => void,
className?: string,
}
) => (
<Card>
<Card className={className}>
<CardHeader>
<CardTitle>Agent Flows</CardTitle>
<CardTitle>Agents</CardTitle>
</CardHeader>
<CardContent>
<Table>
@ -171,21 +204,30 @@ const AgentFlowList = (
<TableHead>Name</TableHead>
{/* <TableHead>Status</TableHead> */}
{/* <TableHead>Last updated</TableHead> */}
{flowRuns && <TableHead># of runs</TableHead>}
{flowRuns && <TableHead className="md:hidden lg:table-cell"># of runs</TableHead>}
{flowRuns && <TableHead>Last run</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{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 (
{flows
.map((flow) => {
let runCount = 0, lastRun: FlowRun | null = 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 { flow, runCount, lastRun };
})
.sort((a, b) => {
if (!a.lastRun && !b.lastRun) return 0;
if (!a.lastRun) return 1;
if (!b.lastRun) return -1;
return b.lastRun.startTime - a.lastRun.startTime;
})
.map(({ flow, runCount, lastRun }) => (
<TableRow
key={flow.id}
className="cursor-pointer"
@ -197,14 +239,14 @@ const AgentFlowList = (
{/* <TableCell>
{flow.updatedAt ?? "???"}
</TableCell> */}
{flowRuns && <TableCell>{runCount}</TableCell>}
{flowRuns && <TableCell className="md:hidden lg:table-cell">{runCount}</TableCell>}
{flowRuns && (!lastRun ? <TableCell /> :
<TableCell title={moment(lastRun.startTime).toString()}>
{moment(lastRun.startTime).fromNow()}
</TableCell>)}
</TableRow>
)
})}
))
}
</TableBody>
</Table>
</CardContent>
@ -224,16 +266,22 @@ const FlowStatusBadge = ({ status }: { status: "active" | "disabled" | "failing"
</Badge>
);
const FlowRunsList = ({ flows, runs }: { flows: Flow[], runs: FlowRun[] }) => (
<Card>
const FlowRunsList: React.FC<{
flows: Flow[];
runs: FlowRun[];
className?: string;
selectedRun?: FlowRun | null;
onSelectRun: (r: FlowRun) => void;
}> = ({ flows, runs, selectedRun, onSelectRun, className }) => (
<Card className={className}>
<CardHeader>
<CardTitle>Flow Runs</CardTitle>
<CardTitle>Runs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Flow</TableHead>
<TableHead>Agent</TableHead>
<TableHead>Started</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
@ -241,7 +289,12 @@ const FlowRunsList = ({ flows, runs }: { flows: Flow[], runs: FlowRun[] }) => (
</TableHeader>
<TableBody>
{runs.map((run) => (
<TableRow key={run.id}>
<TableRow
key={run.id}
className="cursor-pointer"
onClick={() => onSelectRun(run)}
data-state={selectedRun?.id == run.id ? "selected" : null}
>
<TableCell>{flows.find(f => f.id == run.flowID)!.name}</TableCell>
<TableCell>{moment(run.startTime).format("HH:mm")}</TableCell>
<TableCell><FlowRunStatusBadge status={run.status} /></TableCell>
@ -254,54 +307,82 @@ const FlowRunsList = ({ flows, runs }: { flows: Flow[], runs: FlowRun[] }) => (
</Card>
);
const FlowRunStatusBadge = ({ status }: { status: FlowRun['status'] }) => (
const FlowRunStatusBadge: React.FC<{
status: FlowRun['status'];
className?: string;
}> = ({ status, className }) => (
<Badge
variant="default"
className={
className={cn(
status === 'running' ? 'bg-blue-500 dark:bg-blue-700' :
status === 'waiting' ? 'bg-yellow-500 dark:bg-yellow-600' :
status === 'success' ? 'bg-green-500 dark:bg-green-600' :
'bg-red-500 dark:bg-red-700'
}
'bg-red-500 dark:bg-red-700',
className,
)}
>
{status}
</Badge>
);
const ScrollableLegend = ({ payload }) => {
return (
<div style={{
overflowX: 'auto',
overflowY: 'hidden',
whiteSpace: 'nowrap',
padding: '10px 0',
fontSize: '0.75em'
}}>
{payload.map((entry, index) => (
<span key={`item-${index}`} style={{ display: 'inline-block', marginRight: '10px' }}>
<span
style={{
display: 'inline-block',
marginRight: '5px',
width: '8px',
height: '8px',
backgroundColor: entry.color,
}}
/>
<span>{entry.value}</span>
</span>
))}
</div>
);
const FlowInfo: React.FC<{
flow: Flow;
flowRuns: FlowRun[];
}> = ({ flow, flowRuns }) => {
return <Card>
<CardHeader className="flex-row items-center justify-between space-y-0 space-x-3">
<div>
<CardTitle>{flow.name}</CardTitle>
<p className="mt-2">Agent ID: <code>{flow.id}</code></p>
</div>
<Link className={buttonVariants({ variant: "outline" })} href={`/build?flowID=${flow.id}`}>
<Pencil2Icon className="mr-2" /> Edit Agent
</Link>
</CardHeader>
<CardContent>
<FlowRunsStats
flows={[flow]}
flowRuns={flowRuns.filter(r => r.flowID == flow.id)}
/>
</CardContent>
</Card>;
};
const FlowRunsStats = (
{ flows, flowRuns }: {
flows: Flow[],
flowRuns: FlowRun[],
const FlowRunInfo: React.FC<{
flow: Flow;
flowRun: FlowRun;
}> = ({ flow, flowRun }) => {
if (flowRun.flowID != flow.id) {
throw new Error(`FlowRunInfo can't be used with non-matching flowRun.flowID and flow.id`)
}
) => {
return <Card>
<CardHeader className="flex-row items-center justify-between space-y-0 space-x-3">
<div>
<CardTitle>{flow.name}</CardTitle>
<p className="mt-2">Agent ID: <code>{flow.id}</code></p>
<p className="mt-1">Run ID: <code>{flowRun.id}</code></p>
</div>
<Link className={buttonVariants({ variant: "outline" })} href={`/build?flowID=${flow.id}`}>
<Pencil2Icon className="mr-2" /> Edit Agent
</Link>
</CardHeader>
<CardContent>
<p><strong>Status:</strong> <FlowRunStatusBadge status={flowRun.status} /></p>
<p><strong>Started:</strong> {moment(flowRun.startTime).format('YYYY-MM-DD HH:mm:ss')}</p>
<p><strong>Finished:</strong> {moment(flowRun.endTime).format('YYYY-MM-DD HH:mm:ss')}</p>
<p><strong>Duration (run time):</strong> {flowRun.duration} ({flowRun.totalRunTime}) seconds</p>
{/* <p><strong>Total cost:</strong> €1,23</p> */}
</CardContent>
</Card>;
};
const FlowRunsStats: React.FC<{
flows: Flow[],
flowRuns: FlowRun[],
title?: string,
className?: string,
}> = ({ flows, flowRuns, title, className }) => {
/* "dateMin": since the first flow in the dataset
* number > 0: custom date (unix timestamp)
* number < 0: offset relative to Date.now() (in seconds) */
@ -318,9 +399,9 @@ const FlowRunsStats = (
: flowRuns;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Flow Run Stats</CardTitle>
<div className={className}>
<div className="flex flex-row items-center justify-between">
<CardTitle>{ title || "Stats" }</CardTitle>
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={() => setStatsSince(-2*3600)}>2h</Button>
<Button variant="outline" size="sm" onClick={() => setStatsSince(-8*3600)}>8h</Button>
@ -340,20 +421,19 @@ const FlowRunsStats = (
</Popover>
<Button variant="outline" size="sm" onClick={() => setStatsSince("dataMin")}>All</Button>
</div>
</CardHeader>
<CardContent>
<FlowRunsTimeline flows={flows} flowRuns={flowRuns} dataMin={statsSince} className={"mb-6"} />
<Card className="p-3">
<p><strong>Total runs:</strong> {filteredFlowRuns.length}</p>
<p>
<strong>Total duration:</strong> {
filteredFlowRuns.reduce((total, run) => total + run.duration, 0)
} seconds
</p>
{/* <p><strong>Total cost:</strong> €1,23</p> */}
</Card>
</CardContent>
</Card>
</div>
<FlowRunsTimeline flows={flows} flowRuns={flowRuns} dataMin={statsSince} className="mt-3" />
<hr className="my-4" />
<div>
<p><strong>Total runs:</strong> {filteredFlowRuns.length}</p>
<p>
<strong>Total run time:</strong> {
filteredFlowRuns.reduce((total, run) => total + run.totalRunTime, 0)
} seconds
</p>
{/* <p><strong>Total cost:</strong> €1,23</p> */}
</div>
</div>
)
}
@ -401,13 +481,16 @@ const FlowRunsTimeline = (
const data: FlowRun & { time: number, _duration: number } = payload[0].payload;
const flow = flows.find(f => f.id === data.flowID);
return (
<Card className="p-3">
<p><strong>Flow:</strong> {flow ? flow.name : 'Unknown'}</p>
<p><strong>Start Time:</strong> {moment(data.startTime).format('YYYY-MM-DD HH:mm:ss')}</p>
<Card className="p-2 text-xs leading-normal">
<p><strong>Agent:</strong> {flow ? flow.name : 'Unknown'}</p>
<p>
<strong>Duration:</strong> {formatDuration(data.duration)}
<strong>Status:</strong>&nbsp;
<FlowRunStatusBadge status={data.status} className="px-1.5 py-0" />
</p>
<p><strong>Status:</strong> <FlowRunStatusBadge status={data.status} /></p>
<p><strong>Started:</strong> {moment(data.startTime).format('YYYY-MM-DD HH:mm:ss')}</p>
<p><strong>Duration / run time:</strong> {
formatDuration(data.duration)} / {formatDuration(data.totalRunTime)
}</p>
</Card>
);
}
@ -419,8 +502,8 @@ const FlowRunsTimeline = (
key={flow.id}
data={flowRuns.filter(fr => fr.flowID == flow.id).map(fr => ({
...fr,
time: fr.startTime + (fr.duration * 1000),
_duration: fr.duration,
time: fr.startTime + (fr.totalRunTime * 1000),
_duration: fr.totalRunTime,
}))}
name={flow.name}
fill={`hsl(${hashString(flow.id) * 137.5 % 360}, 70%, 50%)`}
@ -433,7 +516,7 @@ const FlowRunsTimeline = (
dataKey="_duration"
data={[
{ ...run, time: run.startTime, _duration: 0 },
{ ...run, time: run.startTime + (run.duration * 1000), _duration: run.duration }
{ ...run, time: run.endTime, _duration: run.totalRunTime }
]}
stroke={`hsl(${hashString(run.flowID) * 137.5 % 360}, 70%, 50%)`}
strokeWidth={2}
@ -441,14 +524,48 @@ const FlowRunsTimeline = (
legendType="none"
/>
))}
<Legend
<Legend
content={<ScrollableLegend />}
wrapperStyle={{ bottom: 0, left: 0, right: 0 }}
wrapperStyle={{
bottom: 0,
left: 0,
right: 0,
width: "100%",
display: "flex",
justifyContent: "center",
}}
/>
</ComposedChart>
</ResponsiveContainer>
);
const ScrollableLegend: React.FC<DefaultLegendContentProps & { className?: string }> = (
{ payload, className }
) => {
return (
<div
className={cn(
"whitespace-nowrap px-4 text-sm overflow-x-auto space-x-3",
className,
)}
style={{ scrollbarWidth: "none" }}
>
{payload.map((entry, index) => {
if (entry.type == "none") return;
return (
<span key={`item-${index}`} className="inline-flex items-center">
<span
className="size-2.5 inline-block mr-1 rounded-full"
style={{backgroundColor: entry.color}}
/>
<span>{entry.value}</span>
</span>
)
})}
</div>
);
};
function formatDuration(seconds: number): string {
return (
seconds < 100