Files
vercel__workflow/packages/web/app/components/hooks-table.tsx
Karthik Kalyan ae51f45166 web: list hooks from analytics, fetch token on demand (#2652)
* 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>
2026-07-07 12:06:41 -07:00

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>
);
}