Skyvern/skyvern-frontend/src/routes/workflows/WorkflowScriptDetailPage.tsx

453 lines
15 KiB
TypeScript

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
import {
ArrowLeftIcon,
DrawingPinFilledIcon,
DrawingPinIcon,
} from "@radix-ui/react-icons";
import { Link, useParams, useSearchParams } from "react-router-dom";
import { CodeEditor } from "./components/CodeEditor";
import { usePinScriptMutation } from "./hooks/usePinScriptMutation";
import { useScriptRunsQuery } from "./hooks/useScriptRunsQuery";
import { useScriptVersionCodeQuery } from "./hooks/useScriptVersionCodeQuery";
import { useScriptVersionsQuery } from "./hooks/useScriptVersionsQuery";
import { useWorkflowScriptsQuery } from "./hooks/useWorkflowScriptsQuery";
import { ScriptFixInput } from "./workflowRun/ScriptFixInput";
const statusVariant: Record<string, "default" | "secondary" | "destructive"> = {
completed: "default",
running: "secondary",
created: "secondary",
queued: "secondary",
failed: "destructive",
terminated: "destructive",
canceled: "secondary",
timed_out: "destructive",
};
function StatusDistribution({
statusCounts,
totalCount,
}: {
statusCounts: Record<string, number>;
totalCount: number;
}) {
if (totalCount === 0) return null;
const statusColors: Record<string, string> = {
completed: "bg-green-500",
running: "bg-blue-500",
failed: "bg-red-500",
terminated: "bg-orange-500",
canceled: "bg-slate-400",
timed_out: "bg-yellow-500",
created: "bg-slate-300",
queued: "bg-slate-300",
};
return (
<div className="space-y-2">
<div className="flex h-3 w-full overflow-hidden rounded-full">
{Object.entries(statusCounts).map(([status, count]) => (
<div
key={status}
className={statusColors[status] ?? "bg-slate-300"}
style={{ width: `${(count / totalCount) * 100}%` }}
title={`${status}: ${count} (${Math.round((count / totalCount) * 100)}%)`}
/>
))}
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{Object.entries(statusCounts).map(([status, count]) => (
<div key={status} className="flex items-center gap-1.5">
<div
className={`size-2.5 rounded-full ${statusColors[status] ?? "bg-slate-300"}`}
/>
<span>
{status}: {count} ({Math.round((count / totalCount) * 100)}%)
</span>
</div>
))}
</div>
</div>
);
}
function WorkflowScriptDetailPage() {
const { workflowPermanentId, scriptId } = useParams();
const [searchParams] = useSearchParams();
const requestedVersion = searchParams.get("version");
const { data: versions, isLoading: versionsLoading } = useScriptVersionsQuery(
{ scriptId },
);
const latestVersion = versions?.versions?.[0]?.version;
// If a specific version was requested via query param, use it; otherwise latest
const activeVersion =
requestedVersion != null ? Number(requestedVersion) : latestVersion;
const isLatest = activeVersion === latestVersion;
const { data: codeData, isLoading: codeLoading } = useScriptVersionCodeQuery({
scriptId,
version: activeVersion,
});
const { data: runsData, isLoading: runsLoading } = useScriptRunsQuery({
scriptId,
pageSize: 50,
version: activeVersion,
});
const { data: scriptsData } = useWorkflowScriptsQuery({
workflowPermanentId,
});
const currentScript = scriptsData?.scripts?.find(
(s) => s.script_id === scriptId,
);
const isPinned = currentScript?.is_pinned ?? false;
const pinMutation = usePinScriptMutation({
workflowPermanentId: workflowPermanentId ?? "",
});
const mainScript = codeData?.main_script ?? "";
const activeVersionInfo = versions?.versions?.find(
(v) => v.version === activeVersion,
);
const newerCount = versions?.versions
? versions.versions.filter((v) => v.version > (activeVersion ?? 0)).length
: 0;
const runs = runsData?.runs ?? [];
const statusCounts = runsData?.status_counts ?? {};
const totalCount = runsData?.total_count ?? 0;
const successRate =
totalCount > 0 ? (statusCounts["completed"] ?? 0) / totalCount : null;
const avgFallbacks = runsData?.avg_fallbacks_per_run ?? null;
const blockCount = codeData?.blocks ? Object.keys(codeData.blocks).length : 0;
const MAX_RUNS_SHOWN = 50;
if (!workflowPermanentId || !scriptId) return null;
return (
<div className="space-y-8">
<header className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button asChild variant="ghost" size="icon">
<Link to={`/workflows/${workflowPermanentId}/scripts`}>
<ArrowLeftIcon className="size-5" />
</Link>
</Button>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Script Detail</h1>
{currentScript && isPinned && (
<Badge
variant="secondary"
className="gap-1 border-amber-500/30 bg-amber-500/10 text-amber-500"
>
<DrawingPinFilledIcon className="size-3" />
Pinned
</Badge>
)}
</div>
<p className="font-mono text-sm text-muted-foreground">
{scriptId}
</p>
</div>
</div>
{currentScript && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
className={
isPinned
? "gap-2 border-amber-500/30 text-amber-500 hover:text-amber-400"
: "gap-2"
}
disabled={pinMutation.isPending}
onClick={() =>
pinMutation.mutate({
cacheKeyValue: currentScript.cache_key_value,
pin: !isPinned,
})
}
>
{isPinned ? (
<DrawingPinFilledIcon className="size-4" />
) : (
<DrawingPinIcon className="size-4" />
)}
{isPinned ? "Unpin" : "Pin"}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isPinned
? "Unpin script to allow auto-updates"
: "Pin script to prevent auto-updates"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</header>
<div className="grid grid-cols-5 gap-4">
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Viewing Revision</p>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<p className="text-2xl font-semibold">
{versionsLoading ? (
<Skeleton className="h-8 w-12" />
) : (
`#${activeVersion ?? "?"}`
)}
</p>
{!versionsLoading && activeVersion != null && (
<Badge
variant="secondary"
className={
isLatest
? "text-xs"
: "border-amber-500/30 bg-amber-500/10 text-xs text-amber-500"
}
>
{isLatest ? "Latest" : `${newerCount} newer`}
</Badge>
)}
</div>
{!versionsLoading && activeVersionInfo && (
<p className="font-mono text-xs text-muted-foreground">
{activeVersionInfo.script_revision_id}
</p>
)}
</div>
</div>
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Revision History</p>
<p className="text-2xl font-semibold">
{versionsLoading ? (
<Skeleton className="h-8 w-12" />
) : (
<>
{versions?.versions
? versions.versions.filter(
(v) => v.version < (activeVersion ?? 0),
).length
: 0}{" "}
<span className="text-sm font-normal text-muted-foreground">
prior
</span>
</>
)}
</p>
</div>
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Runs (this revision)</p>
<p className="text-2xl font-semibold">
{runsLoading ? <Skeleton className="h-8 w-12" /> : totalCount}
</p>
</div>
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Success Rate</p>
<p className="text-2xl font-semibold">
{runsLoading ? (
<Skeleton className="h-8 w-12" />
) : successRate != null ? (
<span
className={
successRate >= 0.8
? "text-green-500"
: successRate >= 0.5
? "text-yellow-500"
: "text-red-500"
}
>
{Math.round(successRate * 100)}%
</span>
) : (
"N/A"
)}
</p>
</div>
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Avg AI Fallbacks</p>
<p className="text-2xl font-semibold">
{runsLoading ? (
<Skeleton className="h-8 w-12" />
) : avgFallbacks != null ? (
<span
className={(() => {
// Color based on fallback ratio relative to block count
const ratio =
blockCount > 0 ? avgFallbacks / blockCount : avgFallbacks;
if (ratio < 0.1) return "text-green-500";
if (ratio < 0.3) return "text-yellow-500";
return "text-red-500";
})()}
>
{avgFallbacks}
</span>
) : (
"N/A"
)}
</p>
</div>
</div>
<div className="space-y-3">
<h2 className="text-lg font-semibold">Run Status Distribution</h2>
{runsLoading ? (
<Skeleton className="h-10 w-full" />
) : (
<StatusDistribution
statusCounts={statusCounts}
totalCount={totalCount}
/>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<h2 className="whitespace-nowrap text-lg font-semibold">
Script Code
</h2>
{workflowPermanentId && mainScript && (
<ScriptFixInput
workflowPermanentId={workflowPermanentId}
workflowRunId={activeVersionInfo?.run_id ?? undefined}
/>
)}
</div>
{!codeLoading && !versionsLoading && activeVersionInfo?.run_id && (
<p className="text-sm text-muted-foreground">
Revision #{activeVersion} created on run{" "}
<Link
to={`/workflows/${workflowPermanentId}/${activeVersionInfo.run_id}/code`}
className="font-mono text-blue-400 hover:underline"
>
{activeVersionInfo.run_id}
</Link>
</p>
)}
{codeLoading || versionsLoading ? (
<Skeleton className="h-64 w-full" />
) : mainScript ? (
<div className="max-h-[500px] overflow-auto rounded-md border">
<CodeEditor value={mainScript} readOnly language="python" />
</div>
) : (
<p className="text-sm text-muted-foreground">
No code available for this script.
</p>
)}
</div>
<div className="space-y-3">
<h2 className="text-lg font-semibold">
Recent Runs{" "}
<span className="text-sm font-normal text-muted-foreground">
{totalCount > MAX_RUNS_SHOWN
? `(showing ${runs.length} of ${totalCount})`
: `(${totalCount})`}
</span>
</h2>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Run ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
<TableHead>Finished</TableHead>
<TableHead>Failure Reason</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runsLoading ? (
<TableRow>
<TableCell colSpan={5}>
<Skeleton className="h-6 w-full" />
</TableCell>
</TableRow>
) : runs.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="py-8 text-center text-muted-foreground"
>
No runs found for this script.
</TableCell>
</TableRow>
) : (
runs.map((run) => (
<TableRow key={run.workflow_run_id}>
<TableCell>
<Link
to={`/workflows/${workflowPermanentId}/${run.workflow_run_id}/overview`}
className="font-mono text-sm text-blue-400 hover:underline"
>
{run.workflow_run_id}
</Link>
</TableCell>
<TableCell>
<Badge variant={statusVariant[run.status] ?? "secondary"}>
{run.status}
</Badge>
</TableCell>
<TableCell
title={
run.started_at ? basicTimeFormat(run.started_at) : ""
}
>
{run.started_at
? basicLocalTimeFormat(run.started_at)
: "-"}
</TableCell>
<TableCell
title={
run.finished_at ? basicTimeFormat(run.finished_at) : ""
}
>
{run.finished_at
? basicLocalTimeFormat(run.finished_at)
: "-"}
</TableCell>
<TableCell
className="max-w-xs truncate"
title={run.failure_reason ?? ""}
>
{run.failure_reason ?? "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
);
}
export { WorkflowScriptDetailPage };