mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
ae51f45166
* Add workflow analytics world APIs * web: read observability list views from world.analytics when available Route the runs/steps/events/hooks list server actions through the optional world.analytics namespace when the backend provides one, falling back to the runtime storage APIs otherwise. Events listing only uses the analytics path when no payload data is requested. Detail/get actions, streams, and mutations are unchanged. * web: keep events and hooks list reads on the runtime storage API The Events tab and trace viewer derive step names and wait resumeAt from resolved event payloads, and the hooks table needs the secret token and ownerId for its resume/copy-token actions. The metadata-only analytics rows do not carry these, so only the runs and steps list views use world.analytics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * web: read events list from world.analytics with a runtime-shape remap The events list/trace consumers read only top-level eventType, correlationId, and createdAt; event payloads are loaded lazily per event via fetchEvent(..., 'all') on the runtime path. Map the flat analytics event rows into the runtime Event shape (reconstructing eventData.stepName) so fetchEvents and fetchEventsByCorrelationId can use the analytics read path when available. Hooks remain on the runtime path (they need the secret token + ownerId). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * web: list hooks from world.analytics, fetch token on demand The hooks list now reads from the metadata-only world.analytics namespace when the backend provides one (falling back to the runtime storage APIs otherwise). A hook's secret token is no longer shipped in list rows — it is fetched one hook at a time via world.hooks.get only when the user copies the token or resumes the hook, keeping the secret out of bulk list responses. Adds a fetchHookToken server action + RPC, a HookListItem type (Hook without token), and a lazy HookTokenCell for the copy-token affordance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Handle analytics access metadata in web --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import {
|
|
HookResolveModalWrapper,
|
|
ResolveHookDropdownItem,
|
|
useHookActions,
|
|
} from '@workflow/web-shared';
|
|
import type { Hook } from '@workflow/world';
|
|
import {
|
|
AlertCircle,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
MoreHorizontal,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
|
|
import { Button } from '~/components/ui/button';
|
|
import { Card, CardContent } from '~/components/ui/card';
|
|
import { DocsLink } from '~/components/ui/docs-link';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '~/components/ui/dropdown-menu';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '~/components/ui/table';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '~/components/ui/tooltip';
|
|
import { fetchEvents } from '~/lib/rpc-client';
|
|
import type { EnvMap, HookListItem } from '~/lib/types';
|
|
import {
|
|
fetchHookToken,
|
|
getErrorMessage,
|
|
getErrorTitle,
|
|
resumeHook,
|
|
useWorkflowHooks,
|
|
} from '~/lib/workflow-api-client';
|
|
import { CopyableText } from './display-utils/copyable-text';
|
|
import { HookTokenCell } from './display-utils/hook-token-cell';
|
|
import { RelativeTime } from './display-utils/relative-time';
|
|
import { TableSkeleton } from './display-utils/table-skeleton';
|
|
|
|
interface HooksTableProps {
|
|
runId?: string;
|
|
onHookClick: (hookId: string, runId?: string) => void;
|
|
selectedHookId?: string;
|
|
}
|
|
|
|
interface InvocationData {
|
|
count: number | Error;
|
|
hasMore: boolean;
|
|
loading: boolean;
|
|
}
|
|
|
|
/**
|
|
* HooksTable - Displays hooks with server-side pagination.
|
|
* Uses the PaginatingTable pattern similar to RunsTable.
|
|
* Fetches invocation counts in the background for each hook.
|
|
*
|
|
* World configuration is read from server-side environment variables.
|
|
* The env object passed to server actions is empty - the server uses process.env.
|
|
*/
|
|
export function HooksTable({
|
|
runId,
|
|
onHookClick,
|
|
selectedHookId,
|
|
}: HooksTableProps) {
|
|
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(
|
|
() => new Date()
|
|
);
|
|
// Empty env object - server actions read from process.env
|
|
const env: EnvMap = useMemo(() => ({}), []);
|
|
|
|
const {
|
|
data,
|
|
error,
|
|
nextPage,
|
|
previousPage,
|
|
hasNextPage,
|
|
hasPreviousPage,
|
|
refresh,
|
|
pageInfo,
|
|
} = useWorkflowHooks(env, {
|
|
runId,
|
|
sortOrder: 'desc',
|
|
});
|
|
|
|
// Hook actions for resolve functionality
|
|
const hookActions = useHookActions({
|
|
onResolve: async (hook, payload) => {
|
|
// List rows are metadata-only; fetch the secret token on demand just
|
|
// before resuming, keyed by the hook's run/hook id.
|
|
const token = await fetchHookToken(env, hook.runId, hook.hookId);
|
|
await resumeHook(env, token, payload);
|
|
},
|
|
callbacks: {
|
|
onSuccess: refresh,
|
|
},
|
|
});
|
|
|
|
const loading = data.isLoading;
|
|
const hooks = data.data ?? [];
|
|
|
|
// Refresh current page without resetting state (prevents layout shift)
|
|
const onRefresh = () => {
|
|
setLastRefreshTime(() => new Date());
|
|
refresh();
|
|
};
|
|
|
|
// Track invocation counts per hook (fetched in background)
|
|
const [invocationData, setInvocationData] = useState<
|
|
Map<string, InvocationData>
|
|
>(new Map());
|
|
|
|
// Fetch invocation counts for hooks by loading all events for the run
|
|
useEffect(() => {
|
|
if (!hooks.length || !runId) return;
|
|
|
|
const fetchInvocations = async () => {
|
|
// Initialize all hooks as loading
|
|
const initialData = new Map<string, InvocationData>();
|
|
for (const hook of hooks) {
|
|
initialData.set(hook.hookId, {
|
|
count: 0,
|
|
hasMore: false,
|
|
loading: true,
|
|
});
|
|
}
|
|
setInvocationData(initialData);
|
|
|
|
try {
|
|
const serverResult = await fetchEvents(env, runId, {
|
|
sortOrder: 'asc',
|
|
limit: 1000,
|
|
});
|
|
|
|
if (!serverResult.success) {
|
|
// Mark all as not loading with 0 counts
|
|
setInvocationData((prev) => {
|
|
const updated = new Map(prev);
|
|
for (const hook of hooks) {
|
|
updated.set(hook.hookId, {
|
|
count: 0,
|
|
hasMore: false,
|
|
loading: false,
|
|
});
|
|
}
|
|
return updated;
|
|
});
|
|
return;
|
|
}
|
|
|
|
const allEvents = serverResult.data.data;
|
|
const hookIds = new Set(hooks.map((h) => h.hookId));
|
|
|
|
// Count hook_received events per hook
|
|
const counts = new Map<string, number>();
|
|
for (const event of allEvents) {
|
|
if (
|
|
event.eventType === 'hook_received' &&
|
|
event.correlationId &&
|
|
hookIds.has(event.correlationId)
|
|
) {
|
|
counts.set(
|
|
event.correlationId,
|
|
(counts.get(event.correlationId) ?? 0) + 1
|
|
);
|
|
}
|
|
}
|
|
|
|
setInvocationData((prev) => {
|
|
const updated = new Map(prev);
|
|
for (const hook of hooks) {
|
|
updated.set(hook.hookId, {
|
|
count: counts.get(hook.hookId) ?? 0,
|
|
hasMore: serverResult.data.hasMore,
|
|
loading: false,
|
|
});
|
|
}
|
|
return updated;
|
|
});
|
|
} catch {
|
|
setInvocationData((prev) => {
|
|
const updated = new Map(prev);
|
|
for (const hook of hooks) {
|
|
updated.set(hook.hookId, {
|
|
count: 0,
|
|
hasMore: false,
|
|
loading: false,
|
|
});
|
|
}
|
|
return updated;
|
|
});
|
|
}
|
|
};
|
|
|
|
fetchInvocations();
|
|
}, [hooks, env, runId]);
|
|
|
|
// Render invocation count for a hook
|
|
const renderInvocationCount = (hook: HookListItem) => {
|
|
const data = invocationData.get(hook.hookId);
|
|
|
|
if (!data || data.loading) {
|
|
return <span className="text-muted-foreground text-xs">...</span>;
|
|
}
|
|
|
|
if (data.count instanceof Error) {
|
|
return <span className="text-muted-foreground">Error</span>;
|
|
}
|
|
|
|
if (data.count === 0) {
|
|
return <span className="text-muted-foreground">0</span>;
|
|
}
|
|
|
|
const displayText = data.hasMore ? `${data.count}+` : `${data.count}`;
|
|
|
|
if (data.hasMore) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span className="font-semibold cursor-help">{displayText}</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs">
|
|
<div className="text-xs">
|
|
Showing first 100 invocations. There may be more.
|
|
</div>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return <span className="font-semibold">{displayText}</span>;
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Modal for resolving hooks - rendered at top level */}
|
|
<HookResolveModalWrapper hookActions={hookActions} />
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-end gap-2">
|
|
<p className="text-sm text-muted-foreground">Last refreshed</p>
|
|
{lastRefreshTime && (
|
|
<RelativeTime
|
|
date={lastRefreshTime}
|
|
className="text-sm text-muted-foreground"
|
|
type="distance"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onRefresh}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
|
Refresh
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>Note that this resets pages</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
{error ? (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>{getErrorTitle(error, 'Error loading hooks')}</AlertTitle>
|
|
<AlertDescription>{getErrorMessage(error)}</AlertDescription>
|
|
</Alert>
|
|
) : !loading && (!hooks || hooks.length === 0) ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No active hooks found. <br />
|
|
<DocsLink href="https://workflow-sdk.dev/docs/api-reference/workflow/create-hook">
|
|
Learn how to create a hook
|
|
</DocsLink>
|
|
</div>
|
|
) : loading && !data?.data ? (
|
|
<TableSkeleton variant="hooks" />
|
|
) : (
|
|
<>
|
|
<Card className="overflow-hidden mt-4 bg-background">
|
|
<CardContent className="p-0 max-h-[calc(100vh-280px)] overflow-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
|
|
Hook ID
|
|
</TableHead>
|
|
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
|
|
Run ID
|
|
</TableHead>
|
|
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
|
|
Token
|
|
</TableHead>
|
|
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
|
|
Created
|
|
</TableHead>
|
|
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10">
|
|
Invocations
|
|
</TableHead>
|
|
<TableHead className="sticky top-0 bg-background z-10 border-b shadow-sm h-10 w-10"></TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{hooks.map((hook) => (
|
|
<TableRow
|
|
key={hook.hookId}
|
|
className="cursor-pointer group relative"
|
|
onClick={() => onHookClick(hook.hookId, hook.runId)}
|
|
data-selected={hook.hookId === selectedHookId}
|
|
>
|
|
<TableCell className="font-mono text-xs py-2">
|
|
<CopyableText text={hook.hookId} overlay>
|
|
{hook.hookId}
|
|
</CopyableText>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs py-2">
|
|
<CopyableText text={hook.runId} overlay>
|
|
{hook.runId}
|
|
</CopyableText>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs py-2">
|
|
<HookTokenCell
|
|
env={env}
|
|
runId={hook.runId}
|
|
hookId={hook.hookId}
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="py-2 text-muted-foreground text-xs">
|
|
{hook.createdAt ? (
|
|
<RelativeTime date={hook.createdAt} />
|
|
) : (
|
|
'-'
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="py-2">
|
|
{renderInvocationCount(hook)}
|
|
</TableCell>
|
|
<TableCell className="py-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<ResolveHookDropdownItem
|
|
// List rows omit the secret token; the resolve
|
|
// flow fetches it on demand. The shared component
|
|
// only forwards the hook back to onResolve, which
|
|
// reads its run/hook id, so this cast is safe.
|
|
hook={hook as Hook}
|
|
stopPropagation
|
|
onResolveClick={hookActions.openResolveModal}
|
|
DropdownMenuItem={DropdownMenuItem}
|
|
/>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div className="text-sm text-muted-foreground">{pageInfo}</div>
|
|
<div className="flex gap-2 items-center">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={previousPage}
|
|
disabled={!hasPreviousPage}
|
|
>
|
|
<ChevronLeft />
|
|
Previous
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={nextPage}
|
|
disabled={!hasNextPage}
|
|
>
|
|
Next
|
|
<ChevronRight />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|