mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
685528259d
* feat(web): add trace step shortcut helper Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(web): compact trace shortcut helper Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(web): reuse kbd for shortcut helper Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(web): add kbd variants Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(web): animate shortcut helper copy Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): smooth shortcut helper transition Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): space shortcut helper keys Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): polish shortcut helper animation Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(web): move shortcut helper into trace viewer Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(web-shared): extract trace shortcut helper into own module Move the rotating shortcut helper out of trace-viewer.tsx into a dedicated component with a CSS-driven crossfade, center it over the whole viewer, and always rotate both hints. Co-authored-by: Cursor <cursoragent@cursor.com> * ship * deslop * Update trace step shortcut helper text Add helper text to indicate keyboard shortcuts for trace step. Signed-off-by: Mitul Shah <mitulxshah@gmail.com> --------- Signed-off-by: Mitul Shah <mitulxshah@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
964 lines
33 KiB
TypeScript
964 lines
33 KiB
TypeScript
import { parseWorkflowName } from '@workflow/utils/parse-name';
|
|
import type { SpanSelectionInfo } from '@workflow/web-shared';
|
|
import {
|
|
DecryptButton,
|
|
ErrorBoundary,
|
|
EventListView,
|
|
hydrateResourceIO,
|
|
hydrateResourceIOWithKey,
|
|
NewTraceViewer,
|
|
type SidebarDataContextValue,
|
|
StreamViewer,
|
|
stepEventsToStepEntity,
|
|
} from '@workflow/web-shared';
|
|
import type { Event, WorkflowRun } from '@workflow/world';
|
|
import {
|
|
AlertCircle,
|
|
GitBranch,
|
|
HelpCircle,
|
|
List,
|
|
Loader2,
|
|
Lock,
|
|
} from 'lucide-react';
|
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
import { Link, useNavigate, useSearchParams } from 'react-router';
|
|
import { toast } from 'sonner';
|
|
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '~/components/ui/alert-dialog';
|
|
import {
|
|
Breadcrumb,
|
|
BreadcrumbItem,
|
|
BreadcrumbLink,
|
|
BreadcrumbList,
|
|
BreadcrumbPage,
|
|
BreadcrumbSeparator,
|
|
} from '~/components/ui/breadcrumb';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '~/components/ui/tooltip';
|
|
import { useEventsListData } from '~/lib/client/hooks/use-events-list-data';
|
|
import { mapRunToExecution } from '~/lib/flow-graph/graph-execution-mapper';
|
|
import { useWorkflowGraphManifest } from '~/lib/flow-graph/use-workflow-graph';
|
|
import { useStreamReader } from '~/lib/hooks/use-stream-reader';
|
|
import { fetchEvent, getEncryptionKeyForRun } from '~/lib/rpc-client';
|
|
import type { EnvMap } from '~/lib/types';
|
|
import {
|
|
cancelRun,
|
|
recreateRun,
|
|
resumeHook,
|
|
unwrapServerActionResult,
|
|
useWorkflowResourceData,
|
|
useWorkflowStreams,
|
|
useWorkflowTraceViewerData,
|
|
wakeUpRun,
|
|
} from '~/lib/workflow-api-client';
|
|
import { useServerConfig } from '~/lib/world-config-context';
|
|
|
|
import { CopyableText } from './display-utils/copyable-text';
|
|
import { LiveStatus } from './display-utils/live-status';
|
|
import { RelativeTime } from './display-utils/relative-time';
|
|
import { StatusBadge } from './display-utils/status-badge';
|
|
import { WorkflowGraphExecutionViewer } from './flow-graph/workflow-graph-execution-viewer';
|
|
import { RunActionsButtons } from './run-actions';
|
|
import { Skeleton } from './ui/skeleton';
|
|
|
|
/**
|
|
* Graph tab content component that fetches the manifest internally
|
|
* This ensures the manifest is only fetched when the Graph tab is mounted
|
|
*/
|
|
function GraphTabContent({
|
|
run,
|
|
allEvents,
|
|
env,
|
|
}: {
|
|
run: WorkflowRun;
|
|
allEvents: Event[] | null;
|
|
env: EnvMap;
|
|
}) {
|
|
// Fetch workflow graph manifest only when this tab is mounted
|
|
const {
|
|
manifest: graphManifest,
|
|
loading: graphLoading,
|
|
error: graphError,
|
|
} = useWorkflowGraphManifest();
|
|
|
|
// Find the workflow graph for this run
|
|
const workflowGraph = useMemo(() => {
|
|
if (!graphManifest || !run.workflowName) return null;
|
|
const runWorkflowName = String(run.workflowName).trim();
|
|
|
|
// Primary lookup: manifest key match.
|
|
const direct = graphManifest.workflows[runWorkflowName];
|
|
if (direct) return direct;
|
|
|
|
const runShortName = parseWorkflowName(runWorkflowName)?.shortName;
|
|
const workflows = Object.values(graphManifest.workflows);
|
|
|
|
// Fallbacks: workflowId/workflowName/shortName match.
|
|
return (
|
|
workflows.find((wf) => {
|
|
if (wf.workflowId === runWorkflowName) return true;
|
|
if (wf.workflowName === runWorkflowName) return true;
|
|
if (!runShortName) return false;
|
|
return parseWorkflowName(wf.workflowName)?.shortName === runShortName;
|
|
}) ?? null
|
|
);
|
|
}, [graphManifest, run.workflowName]);
|
|
|
|
// Reconstruct step entities from events for the graph mapper
|
|
const stepsFromEvents = useMemo(() => {
|
|
if (!allEvents) return [];
|
|
const stepEventsMap = new Map<string, Event[]>();
|
|
for (const event of allEvents) {
|
|
if (event.eventType.startsWith('step_') && event.correlationId) {
|
|
const existing = stepEventsMap.get(event.correlationId);
|
|
if (existing) {
|
|
existing.push(event);
|
|
} else {
|
|
stepEventsMap.set(event.correlationId, [event]);
|
|
}
|
|
}
|
|
}
|
|
return Array.from(stepEventsMap.values())
|
|
.map(stepEventsToStepEntity)
|
|
.filter((s): s is NonNullable<typeof s> => s !== null);
|
|
}, [allEvents]);
|
|
|
|
// Map run data to execution overlay
|
|
const execution = useMemo(() => {
|
|
if (!workflowGraph || !run.runId) return null;
|
|
|
|
return mapRunToExecution(
|
|
run,
|
|
stepsFromEvents as any,
|
|
allEvents || [],
|
|
workflowGraph
|
|
);
|
|
}, [workflowGraph, run, stepsFromEvents, allEvents]);
|
|
|
|
if (graphLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center w-full h-full">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
<span className="ml-4 text-muted-foreground">
|
|
Loading workflow graph…
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (graphError) {
|
|
return (
|
|
<div className="flex items-center justify-center w-full h-full p-4">
|
|
<Alert variant="destructive" className="max-w-lg">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error Loading Workflow Graph</AlertTitle>
|
|
<AlertDescription>{graphError.message}</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!workflowGraph) {
|
|
return (
|
|
<div className="flex items-center justify-center w-full h-full">
|
|
<Alert className="max-w-lg">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Workflow Graph Not Found</AlertTitle>
|
|
<AlertDescription>
|
|
Could not find the workflow graph for this run. The workflow may
|
|
have been deleted or the graph manifest may need to be regenerated.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<WorkflowGraphExecutionViewer
|
|
workflow={workflowGraph}
|
|
execution={execution || undefined}
|
|
env={env}
|
|
/>
|
|
);
|
|
}
|
|
|
|
interface RunDetailViewProps {
|
|
runId: string;
|
|
selectedId?: string;
|
|
}
|
|
|
|
type Tab = 'trace' | 'graph' | 'streams' | 'events';
|
|
|
|
export function RunDetailView({
|
|
runId,
|
|
// TODO: This should open the right sidebar within the trace viewer
|
|
selectedId: _selectedId,
|
|
}: RunDetailViewProps) {
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const { serverConfig } = useServerConfig();
|
|
const [cancelling, setCancelling] = useState(false);
|
|
const [rerunning, setRerunning] = useState(false);
|
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
|
const [showRerunDialog, setShowRerunDialog] = useState(false);
|
|
// Empty env object - server actions read from process.env
|
|
const env: EnvMap = useMemo(() => ({}), []);
|
|
|
|
// Read tab and streamId from URL search params (graph tab is hidden for now)
|
|
const rawTab = (searchParams.get('tab') as Tab) || 'trace';
|
|
const activeTab = rawTab === 'graph' ? 'trace' : rawTab;
|
|
const selectedStreamId = searchParams.get('streamId');
|
|
|
|
// Helper to update URL search params
|
|
const updateSearchParams = useCallback(
|
|
(updates: Record<string, string | null>) => {
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
for (const [key, value] of Object.entries(updates)) {
|
|
if (value === null) {
|
|
params.delete(key);
|
|
} else {
|
|
params.set(key, value);
|
|
}
|
|
}
|
|
navigate(`?${params.toString()}`, { preventScrollReset: true });
|
|
},
|
|
[navigate, searchParams]
|
|
);
|
|
|
|
const setActiveTab = useCallback(
|
|
(tab: Tab) => {
|
|
// When switching away from streams tab, clear streamId so
|
|
// useStreamReader stops fetching/polling in the background.
|
|
if (tab !== 'streams') {
|
|
updateSearchParams({ tab, streamId: null });
|
|
} else {
|
|
updateSearchParams({ tab });
|
|
}
|
|
},
|
|
[updateSearchParams]
|
|
);
|
|
|
|
const setSelectedStreamId = useCallback(
|
|
(streamId: string | null) => {
|
|
updateSearchParams({ streamId });
|
|
},
|
|
[updateSearchParams]
|
|
);
|
|
|
|
// Handler for clicking on stream refs in the trace viewer
|
|
const handleStreamClick = useCallback(
|
|
(streamId: string) => {
|
|
updateSearchParams({ tab: 'streams', streamId });
|
|
},
|
|
[updateSearchParams]
|
|
);
|
|
|
|
const handleRunRefClick = useCallback(
|
|
(targetRunId: string) => {
|
|
// Navigate to the target run with a clean URL (no search params)
|
|
// so the sidebar panel resets
|
|
navigate(`/run/${encodeURIComponent(targetRunId)}`);
|
|
},
|
|
[navigate]
|
|
);
|
|
|
|
const handleWakeUpSleep = useCallback(
|
|
async (runId: string, correlationId: string) => {
|
|
return wakeUpRun(env, runId, { correlationIds: [correlationId] });
|
|
},
|
|
[env]
|
|
);
|
|
|
|
// Ref for encryption key so event loading callbacks can access it
|
|
// without being recreated when the key changes
|
|
const encryptionKeyRef = useRef<Uint8Array | null>(null);
|
|
|
|
const handleResolveHook = useCallback(
|
|
async (hookToken: string, payload: unknown) => {
|
|
await resumeHook(env, hookToken, payload);
|
|
},
|
|
[env]
|
|
);
|
|
|
|
const handleLoadEventData = useCallback(
|
|
async (event: Event) => {
|
|
const { error, result } = await unwrapServerActionResult(
|
|
fetchEvent(env, event.runId, event.eventId, 'all')
|
|
);
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
const fullEvent = encryptionKeyRef.current
|
|
? await hydrateResourceIOWithKey(result, encryptionKeyRef.current)
|
|
: hydrateResourceIO(result);
|
|
if ('eventData' in fullEvent) {
|
|
return fullEvent.eventData;
|
|
}
|
|
return null;
|
|
},
|
|
[env]
|
|
);
|
|
|
|
// Callback for sidebar EventsList — takes (correlationId, eventId)
|
|
const handleLoadSidebarEventData = useCallback(
|
|
async (_correlationId: string, eventId: string) => {
|
|
const { error, result } = await unwrapServerActionResult(
|
|
fetchEvent(env, runId, eventId, 'all')
|
|
);
|
|
if (error) {
|
|
throw error;
|
|
}
|
|
const fullEvent = encryptionKeyRef.current
|
|
? await hydrateResourceIOWithKey(result, encryptionKeyRef.current)
|
|
: hydrateResourceIO(result);
|
|
if ('eventData' in fullEvent) {
|
|
return fullEvent.eventData;
|
|
}
|
|
return null;
|
|
},
|
|
[env, runId]
|
|
);
|
|
|
|
// Only show graph tab for local backend
|
|
const isLocalBackend =
|
|
serverConfig.backendId === 'local' ||
|
|
serverConfig.backendId === '@workflow/world-local';
|
|
|
|
// Fetch run + events for the trace viewer (always asc)
|
|
const {
|
|
run: runData,
|
|
events: allEvents,
|
|
loading,
|
|
error,
|
|
update,
|
|
hasEncryptedData,
|
|
loadMoreTraceData,
|
|
hasMoreTraceData,
|
|
isLoadingMoreTraceData,
|
|
} = useWorkflowTraceViewerData(env, runId, { live: true });
|
|
|
|
const run = runData ?? ({} as WorkflowRun);
|
|
|
|
// Encryption key persisted for the lifetime of this run page.
|
|
// Once fetched (via Decrypt button), it's used automatically for all
|
|
// subsequent resource + event hydration, even across span selection changes.
|
|
const [encryptionKey, setEncryptionKey] = useState<Uint8Array | null>(null);
|
|
encryptionKeyRef.current = encryptionKey;
|
|
|
|
// Separate event fetching for the Events tab with user-controlled sort order
|
|
const [eventsSortOrder, setEventsSortOrder] = useState<'asc' | 'desc'>('asc');
|
|
const {
|
|
events: eventsListData,
|
|
loading: eventsListLoading,
|
|
hasMore: hasMoreEventsTab,
|
|
loadingMore: loadingMoreEventsTab,
|
|
loadMore: loadMoreEventsTab,
|
|
searchByExactId,
|
|
} = useEventsListData(env, runId, {
|
|
sortOrder: eventsSortOrder,
|
|
encryptionKey: encryptionKey ?? undefined,
|
|
enabled: activeTab === 'events',
|
|
});
|
|
|
|
const [spanSelection, setSpanSelection] = useState<SpanSelectionInfo | null>(
|
|
null
|
|
);
|
|
const {
|
|
data: spanDetailData,
|
|
loading: spanDetailLoading,
|
|
error: spanDetailError,
|
|
refresh: refreshSpanDetail,
|
|
} = useWorkflowResourceData(
|
|
env,
|
|
spanSelection?.resource ?? 'run',
|
|
spanSelection?.resourceId ?? '',
|
|
{
|
|
runId: spanSelection?.runId,
|
|
enabled: Boolean(
|
|
spanSelection?.resource &&
|
|
spanSelection?.resourceId &&
|
|
spanSelection.resource !== 'hook'
|
|
),
|
|
encryptionKey: encryptionKey ?? undefined,
|
|
}
|
|
);
|
|
|
|
const [isDecrypting, setIsDecrypting] = useState(false);
|
|
|
|
const handleDecrypt = useCallback(async () => {
|
|
if (encryptionKey) {
|
|
refreshSpanDetail();
|
|
return;
|
|
}
|
|
setIsDecrypting(true);
|
|
try {
|
|
const { error: keyError, result: keyResult } =
|
|
await unwrapServerActionResult(getEncryptionKeyForRun(env, runId));
|
|
if (keyError) {
|
|
toast.error(`Failed to fetch encryption key: ${keyError.message}`);
|
|
return;
|
|
}
|
|
if (!keyResult) {
|
|
toast.error('Encryption is not configured for this deployment.');
|
|
return;
|
|
}
|
|
setEncryptionKey(keyResult);
|
|
} finally {
|
|
setIsDecrypting(false);
|
|
}
|
|
}, [encryptionKey, env, runId, refreshSpanDetail]);
|
|
|
|
const handleSpanSelect = useCallback((info: SpanSelectionInfo) => {
|
|
setSpanSelection(info);
|
|
}, []);
|
|
|
|
const sidebarData = useMemo<SidebarDataContextValue>(
|
|
() => ({
|
|
run,
|
|
events: allEvents ?? [],
|
|
spanDetailData: spanDetailData ?? null,
|
|
spanDetailError,
|
|
spanDetailLoading,
|
|
onSpanSelect: handleSpanSelect,
|
|
onStreamClick: handleStreamClick,
|
|
onRunClick: handleRunRefClick,
|
|
onWakeUpSleep: handleWakeUpSleep,
|
|
onLoadEventData: handleLoadSidebarEventData,
|
|
onResolveHook: handleResolveHook,
|
|
encryptionKey: encryptionKey ?? undefined,
|
|
onDecrypt: handleDecrypt,
|
|
isDecrypting,
|
|
hasEncryptedData,
|
|
}),
|
|
[
|
|
run,
|
|
allEvents,
|
|
spanDetailData,
|
|
spanDetailError,
|
|
spanDetailLoading,
|
|
handleSpanSelect,
|
|
handleStreamClick,
|
|
handleRunRefClick,
|
|
handleWakeUpSleep,
|
|
handleLoadSidebarEventData,
|
|
handleResolveHook,
|
|
encryptionKey,
|
|
handleDecrypt,
|
|
isDecrypting,
|
|
hasEncryptedData,
|
|
]
|
|
);
|
|
|
|
// Fetch streams for this run
|
|
const {
|
|
streams,
|
|
loading: streamsLoading,
|
|
error: streamsError,
|
|
} = useWorkflowStreams(env, runId);
|
|
|
|
const {
|
|
chunks: streamChunks,
|
|
isLive: streamIsLive,
|
|
error: streamError,
|
|
} = useStreamReader(env, selectedStreamId, runId, encryptionKey, run.status);
|
|
|
|
const handleCancelClick = () => {
|
|
setShowCancelDialog(true);
|
|
};
|
|
|
|
const handleConfirmCancel = async () => {
|
|
if (cancelling) return;
|
|
|
|
try {
|
|
setCancelling(true);
|
|
setShowCancelDialog(false);
|
|
await cancelRun(env, runId);
|
|
// Trigger a refresh of the data
|
|
await update();
|
|
toast.success('Run cancelled');
|
|
} catch (err) {
|
|
console.error('Failed to cancel run:', err);
|
|
toast.error('Failed to cancel run', {
|
|
description:
|
|
err instanceof Error ? err.message : 'An unknown error occurred',
|
|
});
|
|
} finally {
|
|
setCancelling(false);
|
|
}
|
|
};
|
|
|
|
const handleRerunClick = () => {
|
|
setShowRerunDialog(true);
|
|
};
|
|
|
|
const handleConfirmRerun = async () => {
|
|
if (rerunning) return;
|
|
|
|
try {
|
|
setRerunning(true);
|
|
setShowRerunDialog(false);
|
|
// Start a new run with the same workflow and input arguments
|
|
const newRunId = await recreateRun(env, run.runId);
|
|
toast.success('New run started', {
|
|
description: `Run ID: ${newRunId}`,
|
|
});
|
|
// Navigate to the new run
|
|
navigate(`/run/${newRunId}`);
|
|
} catch (err) {
|
|
console.error('Failed to re-run workflow:', err);
|
|
toast.error('Failed to start new run', {
|
|
description:
|
|
err instanceof Error ? err.message : 'An unknown error occurred',
|
|
});
|
|
} finally {
|
|
setRerunning(false);
|
|
setShowRerunDialog(false);
|
|
}
|
|
};
|
|
|
|
if (error && !runData) {
|
|
return (
|
|
<Alert variant="destructive" className="m-4">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Error loading workflow run</AlertTitle>
|
|
<AlertDescription>{error.message}</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
const workflowName = parseWorkflowName(run.workflowName)?.shortName;
|
|
|
|
// At this point, we've already returned if there was an error
|
|
// So hasError is always false here
|
|
const hasError = false;
|
|
const errorMessage = '';
|
|
|
|
return (
|
|
<>
|
|
{/* Cancel Confirmation Dialog */}
|
|
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Cancel Workflow Run?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will stop the workflow execution immediately, and no further
|
|
steps will be executed. Partial workflow execution may occur. Are
|
|
you sure you want to cancel the run?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Keep Running</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={handleConfirmCancel}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
Cancel Run
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Replay Run Confirmation Dialog */}
|
|
<AlertDialog open={showRerunDialog} onOpenChange={setShowRerunDialog}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Replay Run?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This can potentially re-run code that is meant to only execute
|
|
once. Are you sure you want to replay the workflow run?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleConfirmRerun}>
|
|
Replay Run
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
<div className="flex flex-col h-[calc(100vh-97px)]">
|
|
<div className="flex-none space-y-4">
|
|
<Breadcrumb>
|
|
<BreadcrumbList>
|
|
<BreadcrumbItem>
|
|
<BreadcrumbLink asChild>
|
|
<Link to="/">Runs</Link>
|
|
</BreadcrumbLink>
|
|
</BreadcrumbItem>
|
|
<BreadcrumbSeparator />
|
|
<BreadcrumbItem>
|
|
<BreadcrumbPage className="font-mono text-xs">
|
|
{runId}
|
|
</BreadcrumbPage>
|
|
</BreadcrumbItem>
|
|
</BreadcrumbList>
|
|
</Breadcrumb>
|
|
|
|
{/* Run Overview Header */}
|
|
<div className="space-y-4 p-6 border rounded-lg">
|
|
{/* Title Row */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="mb-4">
|
|
<h1 className="text-xl font-semibold">
|
|
{workflowName ? (
|
|
workflowName
|
|
) : (
|
|
<Skeleton className="w-[260px] h-[28px]" />
|
|
)}
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
<LiveStatus hasError={hasError} errorMessage={errorMessage} />
|
|
<RunActionsButtons
|
|
env={env}
|
|
runId={runId}
|
|
runStatus={run.status}
|
|
events={allEvents}
|
|
eventsLoading={loading}
|
|
loading={loading}
|
|
onRerunClick={handleRerunClick}
|
|
onCancelClick={handleCancelClick}
|
|
callbacks={{ onSuccess: update }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status and Timeline Row */}
|
|
<div className="flex items-start gap-8">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground">Status</div>
|
|
{run.status ? (
|
|
<StatusBadge status={run.status} context={run} />
|
|
) : (
|
|
<Skeleton className="w-[55px] h-[24px]" />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground">Duration</div>
|
|
<div className="text-xs">
|
|
{run.runId ? (
|
|
run.startedAt ? (
|
|
(() => {
|
|
const ms =
|
|
(run.completedAt
|
|
? new Date(run.completedAt).getTime()
|
|
: Date.now()) - new Date(run.startedAt).getTime();
|
|
const seconds = Math.floor(ms / 1000);
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
if (minutes < 60) {
|
|
return remainingSeconds > 0
|
|
? `${minutes}m ${remainingSeconds}s`
|
|
: `${minutes}m`;
|
|
}
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
return remainingMinutes > 0
|
|
? `${hours}h ${remainingMinutes}m`
|
|
: `${hours}h`;
|
|
})()
|
|
) : (
|
|
'-'
|
|
)
|
|
) : (
|
|
<Skeleton className="w-[60px] h-[20px]" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground">Run ID</div>
|
|
{run.runId ? (
|
|
<CopyableText text={run.runId}>
|
|
<div className="text-xs mt-0.5 font-mono">{run.runId}</div>
|
|
</CopyableText>
|
|
) : (
|
|
<Skeleton className="w-[280px] h-[20px]" />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground">Queued</div>
|
|
{run.createdAt ? (
|
|
<div className="text-xs">
|
|
<RelativeTime date={run.createdAt} />
|
|
</div>
|
|
) : (
|
|
<Skeleton className="w-[110px] h-[20px]" />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground">Started</div>
|
|
<div className="text-xs">
|
|
{run.runId ? (
|
|
run.startedAt ? (
|
|
<RelativeTime date={run.startedAt} />
|
|
) : (
|
|
'-'
|
|
)
|
|
) : (
|
|
<Skeleton className="w-[110px] h-[20px]" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="text-xs text-muted-foreground">Completed</div>
|
|
<div className="text-xs">
|
|
{run.runId ? (
|
|
run.completedAt ? (
|
|
<RelativeTime date={run.completedAt} />
|
|
) : (
|
|
'-'
|
|
)
|
|
) : (
|
|
<Skeleton className="w-[110px] h-[20px]" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
{run.expiredAt != null && (
|
|
<div className="flex flex-col gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="text-xs text-muted-foreground cursor-help flex items-center gap-1">
|
|
Expired
|
|
<HelpCircle className="w-3 h-3" />
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>
|
|
The storage data for this run has expired and is no
|
|
longer available.
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<div className="text-xs">
|
|
<RelativeTime date={run.expiredAt} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex-1 flex flex-col min-h-0">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(v) => setActiveTab(v as Tab)}
|
|
className="flex-1 flex flex-col min-h-0"
|
|
>
|
|
<TabsList className="mb-4 flex-none">
|
|
<TabsTrigger value="trace" className="gap-2">
|
|
<List className="h-4 w-4" />
|
|
Trace
|
|
</TabsTrigger>
|
|
{/* Graph tab hidden for now */}
|
|
{false && isLocalBackend && (
|
|
<TabsTrigger value="graph" className="gap-2">
|
|
<GitBranch className="h-4 w-4" />
|
|
Graph
|
|
</TabsTrigger>
|
|
)}
|
|
<TabsTrigger value="events" className="gap-2">
|
|
<List className="h-4 w-4" />
|
|
Events
|
|
</TabsTrigger>
|
|
<TabsTrigger value="streams" className="gap-2">
|
|
<List className="h-4 w-4" />
|
|
Streams
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="trace" className="mt-0 flex-1 min-h-0">
|
|
<ErrorBoundary title="Failed to load trace viewer">
|
|
<div className="relative h-full -mx-6 bg-background-100 border-t border-gray-alpha-400 overflow-hidden">
|
|
<NewTraceViewer
|
|
run={run}
|
|
events={allEvents ?? []}
|
|
loading={loading}
|
|
sidebarData={sidebarData}
|
|
onLoadMore={loadMoreTraceData}
|
|
hasMore={hasMoreTraceData}
|
|
isLoadingMore={isLoadingMoreTraceData}
|
|
/>
|
|
</div>
|
|
</ErrorBoundary>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="events" className="mt-0 flex-1 min-h-0">
|
|
<ErrorBoundary title="Failed to load events list">
|
|
<div className="h-full">
|
|
<EventListView
|
|
events={eventsListData}
|
|
run={run}
|
|
onLoadEventData={handleLoadEventData}
|
|
hasMoreEvents={hasMoreEventsTab}
|
|
isLoadingMoreEvents={loadingMoreEventsTab}
|
|
onLoadMoreEvents={loadMoreEventsTab}
|
|
encryptionKey={encryptionKey ?? undefined}
|
|
isLoading={eventsListLoading}
|
|
sortOrder={eventsSortOrder}
|
|
onSortOrderChange={setEventsSortOrder}
|
|
onDecrypt={handleDecrypt}
|
|
isDecrypting={isDecrypting}
|
|
hasEncryptedData={hasEncryptedData}
|
|
onExactIdSearch={searchByExactId}
|
|
/>
|
|
</div>
|
|
</ErrorBoundary>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="streams" className="mt-0 flex-1 min-h-0">
|
|
<ErrorBoundary title="Failed to load stream data">
|
|
<div className="h-full flex gap-4">
|
|
{/* Stream list sidebar */}
|
|
<div
|
|
className="w-64 flex-shrink-0 border rounded-lg overflow-hidden"
|
|
style={{
|
|
borderColor: 'var(--ds-gray-300)',
|
|
backgroundColor: 'var(--ds-background-100)',
|
|
}}
|
|
>
|
|
<div
|
|
className="px-3 py-2 border-b text-xs font-medium"
|
|
style={{
|
|
borderColor: 'var(--ds-gray-300)',
|
|
color: 'var(--ds-gray-900)',
|
|
}}
|
|
>
|
|
Streams ({streams.length})
|
|
</div>
|
|
<div className="overflow-auto max-h-[calc(100vh-400px)]">
|
|
{streamsLoading ? (
|
|
<div className="p-4 flex items-center justify-center">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : streamsError ? (
|
|
<div className="p-4 text-xs text-destructive">
|
|
{streamsError.message}
|
|
</div>
|
|
) : streams.length === 0 ? (
|
|
<div
|
|
className="p-4 text-xs"
|
|
style={{ color: 'var(--ds-gray-600)' }}
|
|
>
|
|
No streams found for this run
|
|
</div>
|
|
) : (
|
|
streams.map((streamId) => (
|
|
<button
|
|
key={streamId}
|
|
type="button"
|
|
onClick={() => setSelectedStreamId(streamId)}
|
|
className="w-full text-left px-3 py-2 text-xs font-mono truncate hover:bg-accent transition-colors"
|
|
style={{
|
|
backgroundColor:
|
|
selectedStreamId === streamId
|
|
? 'var(--ds-gray-200)'
|
|
: 'transparent',
|
|
color: 'var(--ds-gray-1000)',
|
|
}}
|
|
title={streamId}
|
|
>
|
|
{streamId}
|
|
</button>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stream viewer */}
|
|
<div className="flex-1 min-w-0">
|
|
{selectedStreamId ? (
|
|
streamError?.includes('encrypted') && !encryptionKey ? (
|
|
<div
|
|
className="h-full flex flex-col items-center justify-center gap-3 rounded-lg border p-4"
|
|
style={{
|
|
borderColor: 'var(--ds-gray-300)',
|
|
backgroundColor: 'var(--ds-gray-100)',
|
|
}}
|
|
>
|
|
<Lock
|
|
className="h-6 w-6"
|
|
style={{ color: 'var(--ds-gray-700)' }}
|
|
/>
|
|
<div
|
|
className="text-sm"
|
|
style={{ color: 'var(--ds-gray-900)' }}
|
|
>
|
|
This stream is encrypted.
|
|
</div>
|
|
<DecryptButton
|
|
onClick={handleDecrypt}
|
|
loading={isDecrypting}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<StreamViewer
|
|
streamId={selectedStreamId}
|
|
chunks={streamChunks}
|
|
isLive={streamIsLive}
|
|
error={
|
|
streamError?.includes('encrypted')
|
|
? null
|
|
: streamError
|
|
}
|
|
/>
|
|
)
|
|
) : (
|
|
<div
|
|
className="h-full flex items-center justify-center rounded-lg border"
|
|
style={{
|
|
borderColor: 'var(--ds-gray-300)',
|
|
backgroundColor: 'var(--ds-gray-100)',
|
|
}}
|
|
>
|
|
<div
|
|
className="text-sm"
|
|
style={{ color: 'var(--ds-gray-600)' }}
|
|
>
|
|
{streams.length > 0
|
|
? 'Select a stream to view its data'
|
|
: 'No streams available'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ErrorBoundary>
|
|
</TabsContent>
|
|
|
|
{/* Graph tab hidden for now */}
|
|
{false && isLocalBackend && (
|
|
<TabsContent value="graph" className="mt-0 flex-1 min-h-0">
|
|
<ErrorBoundary title="Failed to load execution graph">
|
|
<div className="h-full min-h-[500px]">
|
|
<GraphTabContent
|
|
run={run}
|
|
allEvents={allEvents}
|
|
env={env}
|
|
/>
|
|
</div>
|
|
</ErrorBoundary>
|
|
</TabsContent>
|
|
)}
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|