diff --git a/.changeset/khaki-turtles-build.md b/.changeset/khaki-turtles-build.md new file mode 100644 index 000000000..8ae605265 --- /dev/null +++ b/.changeset/khaki-turtles-build.md @@ -0,0 +1,5 @@ +--- +"@workflow/cli": patch +--- + +Fix --noBrowser option help documentation diff --git a/.changeset/thick-moons-wink.md b/.changeset/thick-moons-wink.md new file mode 100644 index 000000000..3d00c8d20 --- /dev/null +++ b/.changeset/thick-moons-wink.md @@ -0,0 +1,7 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Improve trace viewer load times and loading animation + diff --git a/packages/cli/src/lib/inspect/flags.ts b/packages/cli/src/lib/inspect/flags.ts index eb940b57b..ec9289d44 100644 --- a/packages/cli/src/lib/inspect/flags.ts +++ b/packages/cli/src/lib/inspect/flags.ts @@ -92,13 +92,22 @@ export const cliFlags = { helpGroup: 'Output', helpLabel: '-w, --web', }), + webPort: Flags.integer({ + description: 'Port to use when launching the web UI (default: 3456)', + required: false, + default: 3456, + helpGroup: 'Output', + helpLabel: '--webPort', + helpValue: 'WEB_PORT', + env: 'WORKFLOW_WEB_PORT', + }), noBrowser: Flags.boolean({ description: 'Disable automatic browser opening when launching web UI', required: false, default: false, env: 'WORKFLOW_DISABLE_BROWSER_OPEN', helpGroup: 'Output', - helpLabel: '--no-browser', + helpLabel: '--noBrowser', }), sort: Flags.string({ description: 'sort order for list commands', diff --git a/packages/cli/src/lib/inspect/web.ts b/packages/cli/src/lib/inspect/web.ts index c08eb42b2..2ffc6a515 100644 --- a/packages/cli/src/lib/inspect/web.ts +++ b/packages/cli/src/lib/inspect/web.ts @@ -9,9 +9,8 @@ import { logger } from '../config/log.js'; import { getEnvVars } from './env.js'; import { getVercelDashboardUrl } from './vercel-api.js'; -export const WEB_PORT = 3456; export const WEB_PACKAGE_NAME = '@workflow/web'; -export const HOST_URL = `http://localhost:${WEB_PORT}`; +export const getHostUrl = (webPort: number) => `http://localhost:${webPort}`; let serverProcess: ChildProcess | null = null; let isServerStarting = false; @@ -127,13 +126,13 @@ function registerCleanupHandlers(): void { * Start the web server without detaching * The server will stay attached to the CLI process and be cleaned up on exit */ -async function startWebServer(): Promise { +async function startWebServer(webPort: number): Promise { if (isServerStarting) { logger.debug('Server is already starting...'); return false; } - if (await isServerRunning(HOST_URL)) { + if (await isServerRunning(getHostUrl(webPort))) { logger.debug('Server is already running'); return true; } @@ -146,7 +145,7 @@ async function startWebServer(): Promise { try { logger.info('Starting web UI server...'); const command = 'npx'; - const args = ['next', 'start', '-p', String(WEB_PORT)]; + const args = ['next', 'start', '-p', String(webPort)]; logger.debug(`Running ${command} ${args.join(' ')} in ${packagePath}`); // Start the Next.js server WITHOUT detaching @@ -195,10 +194,8 @@ async function startWebServer(): Promise { const retryIntervalMs = 1000; for (let i = 0; i < maxRetries; i++) { await new Promise((resolve) => setTimeout(resolve, retryIntervalMs)); - if (await isServerRunning(HOST_URL)) { - logger.success( - chalk.green(`Web UI server started on port ${WEB_PORT}`) - ); + if (await isServerRunning(getHostUrl(webPort))) { + logger.success(chalk.green(`Web UI server started on port ${webPort}`)); isServerStarting = false; return true; } @@ -299,17 +296,19 @@ export async function launchWebUI( // Fall back to local web UI // Build URL with query params const queryParams = envToQueryParams(resource, id, flags, envVars); - const url = `${HOST_URL}?${queryParams.toString()}`; + const webPort = flags.webPort ?? 3456; + const hostUrl = getHostUrl(webPort); + const url = `${hostUrl}?${queryParams.toString()}`; // Check if server is already running - const alreadyRunning = await isServerRunning(HOST_URL); + const alreadyRunning = await isServerRunning(hostUrl); if (alreadyRunning) { logger.info(chalk.cyan('Web UI server is already running')); - logger.info(chalk.cyan(`Access at: ${HOST_URL}`)); + logger.info(chalk.cyan(`Access at: ${hostUrl}`)); } else { // Start the server - const started = await startWebServer(); + const started = await startWebServer(webPort); if (!started) { logger.error('Failed to start web UI server'); return; diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index fef99711b..ee1a7527b 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -25,7 +25,8 @@ import { } from './workflow-server-actions'; const MAX_ITEMS = 1000; -const LIVE_POLL_LIMIT = 5; +const LIVE_POLL_LIMIT = 10; +const LIVE_STEP_UPDATE_INTERVAL_MS = 2000; const LIVE_UPDATE_INTERVAL_MS = 5000; /** @@ -660,6 +661,7 @@ export function useWorkflowTraceViewerData( const [hooks, setHooks] = useState([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); + const [auxiliaryDataLoading, setAuxiliaryDataLoading] = useState(false); const [error, setError] = useState(null); const [stepsCursor, setStepsCursor] = useState(); @@ -677,29 +679,33 @@ export function useWorkflowTraceViewerData( isFetchingRef.current = true; setLoading(true); + setAuxiliaryDataLoading(true); setError(null); + const promises = [ + fetchRun(env, runId).then((result) => { + // The run is the most visible part - so we can start showing UI + // as soon as we have the run + setLoading(false); + setRun(unwrapServerActionResult(result)); + }), + fetchAllSteps(env, runId).then((result) => { + setSteps(result.data); + setStepsCursor(result.cursor); + }), + fetchAllHooks(env, runId).then((result) => { + setHooks(result.data); + setHooksCursor(result.cursor); + }), + fetchAllEvents(env, runId).then((result) => { + setEvents(result.data); + setEventsCursor(result.cursor); + }), + ]; + try { // Fetch run - const runServerResult = await fetchRun(env, runId); - const runData = unwrapServerActionResult(runServerResult); - setRun(runData); - - // TODO: Do these in parallel - // Fetch steps exhaustively - const stepsResult = await fetchAllSteps(env, runId); - setSteps(stepsResult.data); - setStepsCursor(stepsResult.cursor); - - // Fetch hooks exhaustively - const hooksResult = await fetchAllHooks(env, runId); - setHooks(hooksResult.data); - setHooksCursor(hooksResult.cursor); - - // Fetch events exhaustively - const eventsResult = await fetchAllEvents(env, runId); - setEvents(eventsResult.data); - setEventsCursor(eventsResult.cursor); + await Promise.all(promises); } catch (err) { const error = err instanceof WorkflowAPIError @@ -711,6 +717,7 @@ export function useWorkflowTraceViewerData( setError(error); } finally { setLoading(false); + setAuxiliaryDataLoading(false); isFetchingRef.current = false; setInitialLoadCompleted(true); } @@ -808,27 +815,31 @@ export function useWorkflowTraceViewerData( }, [env, runId, eventsCursor, mergeEvents]); // Update function for live polling - const update = useCallback(async (): Promise<{ foundNewItems: boolean }> => { - if (isFetchingRef.current || !initialLoadCompleted) { - return { foundNewItems: false }; - } + const update = useCallback( + async (stepsOnly: boolean = false): Promise<{ foundNewItems: boolean }> => { + if (isFetchingRef.current || !initialLoadCompleted) { + return { foundNewItems: false }; + } - let foundNewItems = false; + let foundNewItems = false; - try { - const [_, stepsUpdated, hooksUpdated, eventsUpdated] = await Promise.all([ - pollRun(), - pollSteps(), - pollHooks(), - pollEvents(), - ]); - foundNewItems = stepsUpdated || hooksUpdated || eventsUpdated; - } catch (err) { - console.error('Update error:', err); - } + try { + const [_, stepsUpdated, hooksUpdated, eventsUpdated] = + await Promise.all([ + stepsOnly ? Promise.resolve(false) : pollRun(), + pollSteps(), + stepsOnly ? Promise.resolve(false) : pollHooks(), + stepsOnly ? Promise.resolve(false) : pollEvents(), + ]); + foundNewItems = stepsUpdated || hooksUpdated || eventsUpdated; + } catch (err) { + console.error('Update error:', err); + } - return { foundNewItems }; - }, [pollSteps, pollHooks, pollEvents, initialLoadCompleted, pollRun]); + return { foundNewItems }; + }, + [pollSteps, pollHooks, pollEvents, initialLoadCompleted, pollRun] + ); // Initial load useEffect(() => { @@ -844,8 +855,14 @@ export function useWorkflowTraceViewerData( const interval = setInterval(() => { update(); }, LIVE_UPDATE_INTERVAL_MS); - - return () => clearInterval(interval); + const stepInterval = setInterval(() => { + update(true); + }, LIVE_STEP_UPDATE_INTERVAL_MS); + + return () => { + clearInterval(interval); + clearInterval(stepInterval); + }; }, [live, initialLoadCompleted, update, run?.completedAt]); return { @@ -854,6 +871,7 @@ export function useWorkflowTraceViewerData( hooks, events, loading, + auxiliaryDataLoading, error, update, }; diff --git a/packages/web-shared/src/components/ui/skeleton.tsx b/packages/web-shared/src/components/ui/skeleton.tsx new file mode 100644 index 000000000..980053417 --- /dev/null +++ b/packages/web-shared/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from '../../lib/utils'; + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/packages/web-shared/src/run-trace-view.tsx b/packages/web-shared/src/run-trace-view.tsx index 03fc6cfc8..f5b877678 100644 --- a/packages/web-shared/src/run-trace-view.tsx +++ b/packages/web-shared/src/run-trace-view.tsx @@ -34,23 +34,16 @@ export function RunTraceView({ env, runId }: RunTraceViewProps) { } return ( -
-
- {loading && ( -
-
-
- )} - -
+
+
); } diff --git a/packages/web-shared/src/sidebar/events-list.tsx b/packages/web-shared/src/sidebar/events-list.tsx index 5cf209732..b03f1515d 100644 --- a/packages/web-shared/src/sidebar/events-list.tsx +++ b/packages/web-shared/src/sidebar/events-list.tsx @@ -56,7 +56,7 @@ export function EventsList({ className="text-heading-16 font-medium mt-4 mb-2" style={{ color: 'var(--ds-gray-1000)' }} > - Events ({eventsLoading ? '...' : displayData.length}) + Events {!eventsLoading && `(${displayData.length})`} {/* Events section */} {eventError ? ( diff --git a/packages/web-shared/src/workflow-trace-view.tsx b/packages/web-shared/src/workflow-trace-view.tsx index 016eab0a1..4cd42ea1c 100644 --- a/packages/web-shared/src/workflow-trace-view.tsx +++ b/packages/web-shared/src/workflow-trace-view.tsx @@ -1,8 +1,8 @@ import type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; -import { Loader2 } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import type { EnvMap } from './api/workflow-server-actions'; +import { Skeleton } from './components/ui/skeleton'; import { WorkflowDetailPanel } from './sidebar/workflow-detail-panel'; import { TraceViewerContextProvider, @@ -31,7 +31,6 @@ export const WorkflowTraceViewer = ({ env, isLoading, error, - height = 800, }: { run: WorkflowRun; steps: Step[]; @@ -40,7 +39,6 @@ export const WorkflowTraceViewer = ({ env: EnvMap; isLoading?: boolean; error?: Error | null; - height?: number; }) => { const [now, setNow] = useState(() => new Date()); @@ -160,25 +158,30 @@ export const WorkflowTraceViewer = ({ } }, [error, isLoading]); - return ( -
- {isLoading && ( -
-
- -

- Loading trace data... -

-
+ if (isLoading || !trace) { + return ( +
+
+ +
+ + + +
- )} +
+ ); + } + + return ( +
} > - +
); diff --git a/packages/web/src/app/layout-client.tsx b/packages/web/src/app/layout-client.tsx index 11accb0e2..451d54745 100644 --- a/packages/web/src/app/layout-client.tsx +++ b/packages/web/src/app/layout-client.tsx @@ -119,7 +119,7 @@ export function LayoutClient({ children }: LayoutClientProps) { enableSystem disableTransitionOnChange > -
+
diff --git a/packages/web/src/components/display-utils/table-skeleton.tsx b/packages/web/src/components/display-utils/table-skeleton.tsx index 55d7d5dfc..183c41562 100644 --- a/packages/web/src/components/display-utils/table-skeleton.tsx +++ b/packages/web/src/components/display-utils/table-skeleton.tsx @@ -9,50 +9,27 @@ interface TableSkeletonProps { bodyOnly?: boolean; } -const TableSkeletonBody = ({ rows }: { rows: number }) => { - return Array.from({ length: rows }, (_, i) => ( -
- - - - - -
- )); -}; - export function TableSkeleton({ - title, rows = DEFAULT_PAGE_SIZE, - bodyOnly = false, }: TableSkeletonProps) { - if (bodyOnly) { - return ; - } return (
-
- {title ? ( - - ) : ( - - )} -
- - -
-
- {/* Table header skeleton */} -
- - - - - + +
+ {Array.from({ length: rows }, (_, i) => ( +
+ + + + + +
+ ))} +
+ +
- -
); diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index 90d48edae..dc8b228c7 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -207,11 +207,6 @@ export function HooksTable({ return {displayText}; }; - // Show skeleton for initial load - if (loading && !data?.data) { - return ; - } - return (
@@ -255,6 +250,8 @@ export function HooksTable({ Learn how to create a hook
+ ) : loading && !data?.data ? ( + ) : ( <> diff --git a/packages/web/src/components/run-detail-view.tsx b/packages/web/src/components/run-detail-view.tsx index a499320b7..d8d51061f 100644 --- a/packages/web/src/components/run-detail-view.tsx +++ b/packages/web/src/components/run-detail-view.tsx @@ -8,7 +8,7 @@ import { type WorkflowRun, WorkflowTraceViewer, } from '@workflow/web-shared'; -import { AlertCircle } from 'lucide-react'; +import { AlertCircle, Loader2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -32,6 +32,7 @@ import { LiveStatus } from './display-utils/live-status'; import { RelativeTime } from './display-utils/relative-time'; import { RerunButton } from './display-utils/rerun-button'; import { StatusBadge } from './display-utils/status-badge'; +import { Skeleton } from './ui/skeleton'; interface RunDetailViewProps { config: WorldConfig; @@ -59,6 +60,7 @@ export function RunDetailView({ hooks: allHooks, events: allEvents, loading, + auxiliaryDataLoading, error, update, } = useWorkflowTraceViewerData(env, runId, { live: true }); @@ -128,7 +130,7 @@ export function RunDetailView({ ); } - const workflowName = parseWorkflowName(run.workflowName)?.shortName || '?'; + const workflowName = parseWorkflowName(run.workflowName)?.shortName; // At this point, we've already returned if there was an error // So hasError is always false here @@ -201,81 +203,124 @@ export function RunDetailView({ -
- +
+
+ - {/* Run Overview Header */} -
- {/* Title Row */} -
-
-

{workflowName}

-
+ {/* Run Overview Header */} +
+ {/* Title Row */} +
+
+

+ {workflowName ? ( + workflowName + ) : ( + + )} +

+
-
- {/* Right side controls */} - - - +
+ {/* Right side controls */} + + + +
-
- {/* Status and Timeline Row */} -
-
-
Status
- -
-
-
Run ID
- -
{run.runId}
-
-
-
-
Queued
-
- {run.createdAt ? : '-'} + {/* Status and Timeline Row */} +
+
+
Status
+ {run.status ? ( + + ) : ( + + )}
-
-
-
Started
-
- {run.startedAt ? : '-'} +
+
Run ID
+ {run.runId ? ( + +
{run.runId}
+
+ ) : ( + + )}
-
-
-
Completed
-
- {run.completedAt ? ( - +
+
Queued
+ {run.createdAt ? ( +
+ +
) : ( - '-' + )}
+
+
Started
+
+ {run.runId ? ( + run.startedAt ? ( + + ) : ( + '-' + ) + ) : ( + + )} +
+
+
+
Completed
+
+ {run.runId ? ( + run.completedAt ? ( + + ) : ( + '-' + ) + ) : ( + + )} +
+
- +
+ + {auxiliaryDataLoading && ( +
+ + Fetching data... +
+ )} +
); diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index 1289faebc..d8e32891d 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -139,11 +139,6 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { [searchParams] ); - // Show skeleton for initial load - if (loading && !data?.data) { - return ; - } - return (
@@ -288,6 +283,8 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { Error loading runs {getErrorMessage(error)} + ) : loading && !data?.data ? ( + ) : !loading && (!data.data || data.data.length === 0) ? (
No workflow runs found.{' '}