feat(builder): Refactor components and types for monitoring page streamlining (#7714)

pull/7722/head^2
Andy Hooker 2024-08-05 21:13:08 -05:00 committed by GitHub
parent fbad0d01ee
commit e6cc8687a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 793 additions and 729 deletions

View File

@ -1,66 +1,20 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import moment from "moment";
import {
ComposedChart,
DefaultLegendContentProps,
Legend,
Line,
ResponsiveContainer,
Scatter,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import AutoGPTServerAPI, {
Graph,
GraphMeta,
NodeExecutionResult,
safeCopyGraph,
} from "@/lib/autogpt-server-api";
import { Card } from "@/components/ui/card";
import { FlowRun } from "@/lib/types";
import {
ChevronDownIcon,
ClockIcon,
EnterIcon,
ExitIcon,
Pencil2Icon,
} from "@radix-ui/react-icons";
import { cn, exportAsJSONFile, 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 {
Dialog,
DialogContent,
DialogHeader,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { AgentImportForm } from "@/components/agent-import-form";
AgentFlowList,
FlowInfo,
FlowRunInfo,
FlowRunsList,
FlowRunsStats,
} from "@/components/monitor";
const Monitor = () => {
const [flows, setFlows] = useState<GraphMeta[]>([]);
@ -165,19 +119,6 @@ const Monitor = () => {
);
};
type FlowRun = {
id: string;
graphID: string;
graphVersion: number;
status: "running" | "waiting" | "success" | "failed";
startTime: number; // unix timestamp (ms)
endTime: number; // unix timestamp (ms)
duration: number; // seconds
totalRunTime: number; // seconds
nodeExecutionResults: NodeExecutionResult[];
};
function flowRunFromNodeExecutionResults(
nodeExecutionResults: NodeExecutionResult[],
): FlowRun {
@ -230,664 +171,4 @@ function flowRunFromNodeExecutionResults(
};
}
const AgentFlowList = ({
flows,
flowRuns,
selectedFlow,
onSelectFlow,
className,
}: {
flows: GraphMeta[];
flowRuns?: FlowRun[];
selectedFlow: GraphMeta | null;
onSelectFlow: (f: GraphMeta) => void;
className?: string;
}) => {
const [templates, setTemplates] = useState<GraphMeta[]>([]);
const api = new AutoGPTServerAPI();
useEffect(() => {
api.listTemplates().then((templates) => setTemplates(templates));
}, []);
return (
<Card className={className}>
<CardHeader className="flex-row justify-between items-center space-x-3 space-y-0">
<CardTitle>Agents</CardTitle>
<div className="flex items-center">
{/* Split "Create" button */}
<Button variant="outline" className="rounded-r-none" asChild>
<Link href="/build">Create</Link>
</Button>
<Dialog>
{/* https://ui.shadcn.com/docs/components/dialog#notes */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className={"rounded-l-none border-l-0 px-2"}
>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger asChild>
<DropdownMenuItem>
<EnterIcon className="mr-2" /> Import from file
</DropdownMenuItem>
</DialogTrigger>
{templates.length > 0 && (
<>
{/* List of templates */}
<DropdownMenuSeparator />
<DropdownMenuLabel>Use a template</DropdownMenuLabel>
{templates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => {
api
.createGraph(template.id, template.version)
.then((newGraph) => {
window.location.href = `/build?flowID=${newGraph.id}`;
});
}}
>
{template.name}
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader className="text-lg">
Import an Agent (template) from a file
</DialogHeader>
<AgentImportForm />
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{/* <TableHead>Status</TableHead> */}
{/* <TableHead>Last updated</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 = 0,
lastRun: FlowRun | null = null;
if (flowRuns) {
const _flowRuns = flowRuns.filter(
(r) => r.graphID == 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"
onClick={() => onSelectFlow(flow)}
data-state={selectedFlow?.id == flow.id ? "selected" : null}
>
<TableCell>{flow.name}</TableCell>
{/* <TableCell><FlowStatusBadge status={flow.status ?? "active"} /></TableCell> */}
{/* <TableCell>
{flow.updatedAt ?? "???"}
</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>
</Card>
);
};
const FlowStatusBadge = ({
status,
}: {
status: "active" | "disabled" | "failing";
}) => (
<Badge
variant="default"
className={
status === "active"
? "bg-green-500 dark:bg-green-600"
: status === "failing"
? "bg-red-500 dark:bg-red-700"
: "bg-gray-500 dark:bg-gray-600"
}
>
{status}
</Badge>
);
const FlowRunsList: React.FC<{
flows: GraphMeta[];
runs: FlowRun[];
className?: string;
selectedRun?: FlowRun | null;
onSelectRun: (r: FlowRun) => void;
}> = ({ flows, runs, selectedRun, onSelectRun, className }) => (
<Card className={className}>
<CardHeader>
<CardTitle>Runs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead>Started</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<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.graphID)!.name}
</TableCell>
<TableCell>{moment(run.startTime).format("HH:mm")}</TableCell>
<TableCell>
<FlowRunStatusBadge status={run.status} />
</TableCell>
<TableCell>{formatDuration(run.duration)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
const FlowRunStatusBadge: React.FC<{
status: FlowRun["status"];
className?: string;
}> = ({ status, className }) => (
<Badge
variant="default"
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",
className,
)}
>
{status}
</Badge>
);
const FlowInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flowRuns: FlowRun[];
flowVersion?: number | "all";
}
> = ({ flow, flowRuns, flowVersion, ...props }) => {
const api = new AutoGPTServerAPI();
const [flowVersions, setFlowVersions] = useState<Graph[] | null>(null);
const [selectedVersion, setSelectedFlowVersion] = useState(
flowVersion ?? "all",
);
const selectedFlowVersion: Graph | undefined = flowVersions?.find(
(v) =>
v.version == (selectedVersion == "all" ? flow.version : selectedVersion),
);
useEffect(() => {
api.getGraphAllVersions(flow.id).then((result) => setFlowVersions(result));
}, [flow.id]);
return (
<Card {...props}>
<CardHeader className="flex-row justify-between space-y-0 space-x-3">
<div>
<CardTitle>
{flow.name} <span className="font-light">v{flow.version}</span>
</CardTitle>
<p className="mt-2">
Agent ID: <code>{flow.id}</code>
</p>
</div>
<div className="flex items-start space-x-2">
{(flowVersions?.length ?? 0) > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<ClockIcon className="mr-2" />
{selectedVersion == "all"
? "All versions"
: `Version ${selectedVersion}`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Choose a version</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={String(selectedVersion)}
onValueChange={(choice) =>
setSelectedFlowVersion(
choice == "all" ? choice : Number(choice),
)
}
>
<DropdownMenuRadioItem value="all">
All versions
</DropdownMenuRadioItem>
{flowVersions?.map((v) => (
<DropdownMenuRadioItem
key={v.version}
value={v.version.toString()}
>
Version {v.version}
{v.is_active ? " (active)" : ""}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<Link
className={buttonVariants({ variant: "outline" })}
href={`/build?flowID=${flow.id}`}
>
<Pencil2Icon className="mr-2" /> Edit
</Link>
<Button
variant="outline"
className="px-2.5"
title="Export to a JSON-file"
onClick={async () =>
exportAsJSONFile(
safeCopyGraph(
flowVersions!.find(
(v) => v.version == selectedFlowVersion!.version,
)!,
await api.getBlocks(),
),
`${flow.name}_v${selectedFlowVersion!.version}.json`,
)
}
>
<ExitIcon />
</Button>
</div>
</CardHeader>
<CardContent>
<FlowRunsStats
flows={[selectedFlowVersion ?? flow]}
flowRuns={flowRuns.filter(
(r) =>
r.graphID == flow.id &&
(selectedVersion == "all" || r.graphVersion == selectedVersion),
)}
/>
</CardContent>
</Card>
);
};
const FlowRunInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flowRun: FlowRun;
}
> = ({ flow, flowRun, ...props }) => {
if (flowRun.graphID != flow.id) {
throw new Error(
`FlowRunInfo can't be used with non-matching flowRun.flowID and flow.id`,
);
}
return (
<Card {...props}>
<CardHeader className="flex-row items-center justify-between space-y-0 space-x-3">
<div>
<CardTitle>
{flow.name} <span className="font-light">v{flow.version}</span>
</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: GraphMeta[];
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) */
const [statsSince, setStatsSince] = useState<number | "dataMin">(-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 (
<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>
<Button
variant="outline"
size="sm"
onClick={() => setStatsSince(-24 * 3600)}
>
24h
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setStatsSince(-7 * 24 * 3600)}
>
7d
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant={"outline"} size="sm">
Custom
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
onSelect={(_, selectedDay) =>
setStatsSince(selectedDay.getTime())
}
initialFocus
/>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
onClick={() => setStatsSince("dataMin")}
>
All
</Button>
</div>
</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>
);
};
const FlowRunsTimeline = ({
flows,
flowRuns,
dataMin,
className,
}: {
flows: GraphMeta[];
flowRuns: FlowRun[];
dataMin: "dataMin" | number;
className?: string;
}) => (
/* TODO: make logarithmic? */
<ResponsiveContainer width="100%" height={120} className={className}>
<ComposedChart>
<XAxis
dataKey="time"
type="number"
domain={[
typeof dataMin == "string"
? dataMin
: dataMin < 0
? Date.now() + dataMin * 1000
: dataMin,
Date.now(),
]}
allowDataOverflow={true}
tickFormatter={(unixTime) => {
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"
/>
<YAxis
dataKey="_duration"
name="Duration (s)"
tickFormatter={(s) => (s > 90 ? `${Math.round(s / 60)}m` : `${s}s`)}
/>
<Tooltip
content={({ payload, label }) => {
if (payload && payload.length) {
const data: FlowRun & { time: number; _duration: number } =
payload[0].payload;
const flow = flows.find((f) => f.id === data.graphID);
return (
<Card className="p-2 text-xs leading-normal">
<p>
<strong>Agent:</strong> {flow ? flow.name : "Unknown"}
</p>
<p>
<strong>Status:</strong>&nbsp;
<FlowRunStatusBadge
status={data.status}
className="px-1.5 py-0"
/>
</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>
);
}
return null;
}}
/>
{flows.map((flow) => (
<Scatter
key={flow.id}
data={flowRuns
.filter((fr) => fr.graphID == flow.id)
.map((fr) => ({
...fr,
time: fr.startTime + fr.totalRunTime * 1000,
_duration: fr.totalRunTime,
}))}
name={flow.name}
fill={`hsl(${(hashString(flow.id) * 137.5) % 360}, 70%, 50%)`}
/>
))}
{flowRuns.map((run) => (
<Line
key={run.id}
type="linear"
dataKey="_duration"
data={[
{ ...run, time: run.startTime, _duration: 0 },
{ ...run, time: run.endTime, _duration: run.totalRunTime },
]}
stroke={`hsl(${(hashString(run.graphID) * 137.5) % 360}, 70%, 50%)`}
strokeWidth={2}
dot={false}
legendType="none"
/>
))}
<Legend
content={<ScrollableLegend />}
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 ? seconds.toPrecision(2) : Math.round(seconds)).toString() +
"s"
);
}
export default Monitor;

View File

@ -0,0 +1,187 @@
import AutoGPTServerAPI, { GraphMeta } from "@/lib/autogpt-server-api";
import React, { useEffect, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDownIcon, EnterIcon } from "@radix-ui/react-icons";
import { AgentImportForm } from "@/components/agent-import-form";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import moment from "moment/moment";
import { FlowRun } from "@/lib/types";
export const AgentFlowList = ({
flows,
flowRuns,
selectedFlow,
onSelectFlow,
className,
}: {
flows: GraphMeta[];
flowRuns?: FlowRun[];
selectedFlow: GraphMeta | null;
onSelectFlow: (f: GraphMeta) => void;
className?: string;
}) => {
const [templates, setTemplates] = useState<GraphMeta[]>([]);
const api = new AutoGPTServerAPI();
useEffect(() => {
api.listTemplates().then((templates) => setTemplates(templates));
}, []);
return (
<Card className={className}>
<CardHeader className="flex-row justify-between items-center space-x-3 space-y-0">
<CardTitle>Agents</CardTitle>
<div className="flex items-center">
{/* Split "Create" button */}
<Button variant="outline" className="rounded-r-none" asChild>
<Link href="/build">Create</Link>
</Button>
<Dialog>
{/* https://ui.shadcn.com/docs/components/dialog#notes */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className={"rounded-l-none border-l-0 px-2"}
>
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DialogTrigger asChild>
<DropdownMenuItem>
<EnterIcon className="mr-2" /> Import from file
</DropdownMenuItem>
</DialogTrigger>
{templates.length > 0 && (
<>
{/* List of templates */}
<DropdownMenuSeparator />
<DropdownMenuLabel>Use a template</DropdownMenuLabel>
{templates.map((template) => (
<DropdownMenuItem
key={template.id}
onClick={() => {
api
.createGraph(template.id, template.version)
.then((newGraph) => {
window.location.href = `/build?flowID=${newGraph.id}`;
});
}}
>
{template.name}
</DropdownMenuItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DialogContent>
<DialogHeader className="text-lg">
Import an Agent (template) from a file
</DialogHeader>
<AgentImportForm />
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{/* <TableHead>Status</TableHead> */}
{/* <TableHead>Last updated</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 = 0,
lastRun: FlowRun | null = null;
if (flowRuns) {
const _flowRuns = flowRuns.filter(
(r) => r.graphID == 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"
onClick={() => onSelectFlow(flow)}
data-state={selectedFlow?.id == flow.id ? "selected" : null}
>
<TableCell>{flow.name}</TableCell>
{/* <TableCell><FlowStatusBadge status={flow.status ?? "active"} /></TableCell> */}
{/* <TableCell>
{flow.updatedAt ?? "???"}
</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>
</Card>
);
};
export default AgentFlowList;

View File

@ -0,0 +1,134 @@
import React, { useEffect, useState } from "react";
import AutoGPTServerAPI, {
Graph,
GraphMeta,
safeCopyGraph,
} from "@/lib/autogpt-server-api";
import { FlowRun } from "@/lib/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button, buttonVariants } from "@/components/ui/button";
import { ClockIcon, ExitIcon, Pencil2Icon } from "@radix-ui/react-icons";
import Link from "next/link";
import { exportAsJSONFile } from "@/lib/utils";
import { FlowRunsStats } from "@/components/monitor/index";
export const FlowInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flowRuns: FlowRun[];
flowVersion?: number | "all";
}
> = ({ flow, flowRuns, flowVersion, ...props }) => {
const api = new AutoGPTServerAPI();
const [flowVersions, setFlowVersions] = useState<Graph[] | null>(null);
const [selectedVersion, setSelectedFlowVersion] = useState(
flowVersion ?? "all",
);
const selectedFlowVersion: Graph | undefined = flowVersions?.find(
(v) =>
v.version == (selectedVersion == "all" ? flow.version : selectedVersion),
);
useEffect(() => {
api.getGraphAllVersions(flow.id).then((result) => setFlowVersions(result));
}, [flow.id]);
return (
<Card {...props}>
<CardHeader className="flex-row justify-between space-y-0 space-x-3">
<div>
<CardTitle>
{flow.name} <span className="font-light">v{flow.version}</span>
</CardTitle>
<p className="mt-2">
Agent ID: <code>{flow.id}</code>
</p>
</div>
<div className="flex items-start space-x-2">
{(flowVersions?.length ?? 0) > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<ClockIcon className="mr-2" />
{selectedVersion == "all"
? "All versions"
: `Version ${selectedVersion}`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>Choose a version</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={String(selectedVersion)}
onValueChange={(choice) =>
setSelectedFlowVersion(
choice == "all" ? choice : Number(choice),
)
}
>
<DropdownMenuRadioItem value="all">
All versions
</DropdownMenuRadioItem>
{flowVersions?.map((v) => (
<DropdownMenuRadioItem
key={v.version}
value={v.version.toString()}
>
Version {v.version}
{v.is_active ? " (active)" : ""}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
<Link
className={buttonVariants({ variant: "outline" })}
href={`/build?flowID=${flow.id}`}
>
<Pencil2Icon className="mr-2" /> Edit
</Link>
<Button
variant="outline"
className="px-2.5"
title="Export to a JSON-file"
onClick={async () =>
exportAsJSONFile(
safeCopyGraph(
flowVersions!.find(
(v) => v.version == selectedFlowVersion!.version,
)!,
await api.getBlocks(),
),
`${flow.name}_v${selectedFlowVersion!.version}.json`,
)
}
>
<ExitIcon />
</Button>
</div>
</CardHeader>
<CardContent>
<FlowRunsStats
flows={[selectedFlowVersion ?? flow]}
flowRuns={flowRuns.filter(
(r) =>
r.graphID == flow.id &&
(selectedVersion == "all" || r.graphVersion == selectedVersion),
)}
/>
</CardContent>
</Card>
);
};
export default FlowInfo;

View File

@ -0,0 +1,66 @@
import React from "react";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { FlowRun } from "@/lib/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { Pencil2Icon } from "@radix-ui/react-icons";
import moment from "moment/moment";
import { FlowRunStatusBadge } from "@/components/monitor/FlowRunStatusBadge";
export const FlowRunInfo: React.FC<
React.HTMLAttributes<HTMLDivElement> & {
flow: GraphMeta;
flowRun: FlowRun;
}
> = ({ flow, flowRun, ...props }) => {
if (flowRun.graphID != flow.id) {
throw new Error(
`FlowRunInfo can't be used with non-matching flowRun.flowID and flow.id`,
);
}
return (
<Card {...props}>
<CardHeader className="flex-row items-center justify-between space-y-0 space-x-3">
<div>
<CardTitle>
{flow.name} <span className="font-light">v{flow.version}</span>
</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>
);
};
export default FlowRunInfo;

View File

@ -0,0 +1,25 @@
import React from "react";
import { FlowRun } from "@/lib/types";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
export const FlowRunStatusBadge: React.FC<{
status: FlowRun["status"];
className?: string;
}> = ({ status, className }) => (
<Badge
variant="default"
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",
className,
)}
>
{status}
</Badge>
);

View File

@ -0,0 +1,68 @@
import React from "react";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { FlowRun } from "@/lib/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import moment from "moment/moment";
import { FlowRunStatusBadge } from "@/components/monitor/FlowRunStatusBadge";
export const FlowRunsList: React.FC<{
flows: GraphMeta[];
runs: FlowRun[];
className?: string;
selectedRun?: FlowRun | null;
onSelectRun: (r: FlowRun) => void;
}> = ({ flows, runs, selectedRun, onSelectRun, className }) => (
<Card className={className}>
<CardHeader>
<CardTitle>Runs</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead>Started</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => (
<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.graphID)!.name}
</TableCell>
<TableCell>{moment(run.startTime).format("HH:mm")}</TableCell>
<TableCell>
<FlowRunStatusBadge status={run.status} />
</TableCell>
<TableCell>{formatDuration(run.duration)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
function formatDuration(seconds: number): string {
return (
(seconds < 100 ? seconds.toPrecision(2) : Math.round(seconds)).toString() +
"s"
);
}
export default FlowRunsList;

View File

@ -0,0 +1,114 @@
import React, { useState } from "react";
import { GraphMeta } from "@/lib/autogpt-server-api";
import { FlowRun } from "@/lib/types";
import { CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { FlowRunsTimeline } from "@/components/monitor/FlowRunsTimeline";
export const FlowRunsStatus: React.FC<{
flows: GraphMeta[];
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) */
const [statsSince, setStatsSince] = useState<number | "dataMin">(-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 (
<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>
<Button
variant="outline"
size="sm"
onClick={() => setStatsSince(-24 * 3600)}
>
24h
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setStatsSince(-7 * 24 * 3600)}
>
7d
</Button>
<Popover>
<PopoverTrigger asChild>
<Button variant={"outline"} size="sm">
Custom
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
onSelect={(_, selectedDay) =>
setStatsSince(selectedDay.getTime())
}
initialFocus
/>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
onClick={() => setStatsSince("dataMin")}
>
All
</Button>
</div>
</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>
);
};
export default FlowRunsStatus;

View File

@ -0,0 +1,170 @@
import { GraphMeta } from "@/lib/autogpt-server-api";
import {
ComposedChart,
DefaultLegendContentProps,
Legend,
Line,
ResponsiveContainer,
Scatter,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import moment from "moment/moment";
import { Card } from "@/components/ui/card";
import { cn, hashString } from "@/lib/utils";
import React from "react";
import { FlowRun } from "@/lib/types";
import { FlowRunStatusBadge } from "@/components/monitor/FlowRunStatusBadge";
export const FlowRunsTimeline = ({
flows,
flowRuns,
dataMin,
className,
}: {
flows: GraphMeta[];
flowRuns: FlowRun[];
dataMin: "dataMin" | number;
className?: string;
}) => (
/* TODO: make logarithmic? */
<ResponsiveContainer width="100%" height={120} className={className}>
<ComposedChart>
<XAxis
dataKey="time"
type="number"
domain={[
typeof dataMin == "string"
? dataMin
: dataMin < 0
? Date.now() + dataMin * 1000
: dataMin,
Date.now(),
]}
allowDataOverflow={true}
tickFormatter={(unixTime) => {
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"
/>
<YAxis
dataKey="_duration"
name="Duration (s)"
tickFormatter={(s) => (s > 90 ? `${Math.round(s / 60)}m` : `${s}s`)}
/>
<Tooltip
content={({ payload, label }) => {
if (payload && payload.length) {
const data: FlowRun & { time: number; _duration: number } =
payload[0].payload;
const flow = flows.find((f) => f.id === data.graphID);
return (
<Card className="p-2 text-xs leading-normal">
<p>
<strong>Agent:</strong> {flow ? flow.name : "Unknown"}
</p>
<p>
<strong>Status:</strong>&nbsp;
<FlowRunStatusBadge
status={data.status}
className="px-1.5 py-0"
/>
</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>
);
}
return null;
}}
/>
{flows.map((flow) => (
<Scatter
key={flow.id}
data={flowRuns
.filter((fr) => fr.graphID == flow.id)
.map((fr) => ({
...fr,
time: fr.startTime + fr.totalRunTime * 1000,
_duration: fr.totalRunTime,
}))}
name={flow.name}
fill={`hsl(${(hashString(flow.id) * 137.5) % 360}, 70%, 50%)`}
/>
))}
{flowRuns.map((run) => (
<Line
key={run.id}
type="linear"
dataKey="_duration"
data={[
{ ...run, time: run.startTime, _duration: 0 },
{ ...run, time: run.endTime, _duration: run.totalRunTime },
]}
stroke={`hsl(${(hashString(run.graphID) * 137.5) % 360}, 70%, 50%)`}
strokeWidth={2}
dot={false}
legendType="none"
/>
))}
<Legend
content={<ScrollableLegend />}
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 ? seconds.toPrecision(2) : Math.round(seconds)).toString() +
"s"
);
}

View File

@ -0,0 +1,6 @@
export { default as AgentFlowList } from "./AgentFlowList";
export { default as FlowRunsList } from "./FlowRunsList";
export { default as FlowInfo } from "./FlowInfo";
export { default as FlowRunInfo } from "./FlowRunInfo";
export { default as FlowRunsStats } from "./FlowRunsStatus";
export { default as FlowRunsTimeline } from "./FlowRunsTimeline";

View File

@ -0,0 +1,13 @@
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
export type FlowRun = {
id: string;
graphID: string;
graphVersion: number;
status: "running" | "waiting" | "success" | "failed";
startTime: number; // unix timestamp (ms)
endTime: number; // unix timestamp (ms)
duration: number; // seconds
totalRunTime: number; // seconds
nodeExecutionResults: NodeExecutionResult[];
};