Skip to content

Commit 00efdfb

Browse files
Web: Improve trace viewer load times and loading animation (#270)
1 parent aac1b6c commit 00efdfb

File tree

14 files changed

+264
-199
lines changed

14 files changed

+264
-199
lines changed

.changeset/khaki-turtles-build.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/cli": patch
3+
---
4+
5+
Fix --noBrowser option help documentation

.changeset/thick-moons-wink.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@workflow/web-shared": patch
3+
"@workflow/web": patch
4+
---
5+
6+
Improve trace viewer load times and loading animation
7+

packages/cli/src/lib/inspect/flags.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,22 @@ export const cliFlags = {
9292
helpGroup: 'Output',
9393
helpLabel: '-w, --web',
9494
}),
95+
webPort: Flags.integer({
96+
description: 'Port to use when launching the web UI (default: 3456)',
97+
required: false,
98+
default: 3456,
99+
helpGroup: 'Output',
100+
helpLabel: '--webPort',
101+
helpValue: 'WEB_PORT',
102+
env: 'WORKFLOW_WEB_PORT',
103+
}),
95104
noBrowser: Flags.boolean({
96105
description: 'Disable automatic browser opening when launching web UI',
97106
required: false,
98107
default: false,
99108
env: 'WORKFLOW_DISABLE_BROWSER_OPEN',
100109
helpGroup: 'Output',
101-
helpLabel: '--no-browser',
110+
helpLabel: '--noBrowser',
102111
}),
103112
sort: Flags.string({
104113
description: 'sort order for list commands',

packages/cli/src/lib/inspect/web.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import { logger } from '../config/log.js';
99
import { getEnvVars } from './env.js';
1010
import { getVercelDashboardUrl } from './vercel-api.js';
1111

12-
export const WEB_PORT = 3456;
1312
export const WEB_PACKAGE_NAME = '@workflow/web';
14-
export const HOST_URL = `http://localhost:${WEB_PORT}`;
13+
export const getHostUrl = (webPort: number) => `http://localhost:${webPort}`;
1514

1615
let serverProcess: ChildProcess | null = null;
1716
let isServerStarting = false;
@@ -127,13 +126,13 @@ function registerCleanupHandlers(): void {
127126
* Start the web server without detaching
128127
* The server will stay attached to the CLI process and be cleaned up on exit
129128
*/
130-
async function startWebServer(): Promise<boolean> {
129+
async function startWebServer(webPort: number): Promise<boolean> {
131130
if (isServerStarting) {
132131
logger.debug('Server is already starting...');
133132
return false;
134133
}
135134

136-
if (await isServerRunning(HOST_URL)) {
135+
if (await isServerRunning(getHostUrl(webPort))) {
137136
logger.debug('Server is already running');
138137
return true;
139138
}
@@ -146,7 +145,7 @@ async function startWebServer(): Promise<boolean> {
146145
try {
147146
logger.info('Starting web UI server...');
148147
const command = 'npx';
149-
const args = ['next', 'start', '-p', String(WEB_PORT)];
148+
const args = ['next', 'start', '-p', String(webPort)];
150149
logger.debug(`Running ${command} ${args.join(' ')} in ${packagePath}`);
151150

152151
// Start the Next.js server WITHOUT detaching
@@ -195,10 +194,8 @@ async function startWebServer(): Promise<boolean> {
195194
const retryIntervalMs = 1000;
196195
for (let i = 0; i < maxRetries; i++) {
197196
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
198-
if (await isServerRunning(HOST_URL)) {
199-
logger.success(
200-
chalk.green(`Web UI server started on port ${WEB_PORT}`)
201-
);
197+
if (await isServerRunning(getHostUrl(webPort))) {
198+
logger.success(chalk.green(`Web UI server started on port ${webPort}`));
202199
isServerStarting = false;
203200
return true;
204201
}
@@ -299,17 +296,19 @@ export async function launchWebUI(
299296
// Fall back to local web UI
300297
// Build URL with query params
301298
const queryParams = envToQueryParams(resource, id, flags, envVars);
302-
const url = `${HOST_URL}?${queryParams.toString()}`;
299+
const webPort = flags.webPort ?? 3456;
300+
const hostUrl = getHostUrl(webPort);
301+
const url = `${hostUrl}?${queryParams.toString()}`;
303302

304303
// Check if server is already running
305-
const alreadyRunning = await isServerRunning(HOST_URL);
304+
const alreadyRunning = await isServerRunning(hostUrl);
306305

307306
if (alreadyRunning) {
308307
logger.info(chalk.cyan('Web UI server is already running'));
309-
logger.info(chalk.cyan(`Access at: ${HOST_URL}`));
308+
logger.info(chalk.cyan(`Access at: ${hostUrl}`));
310309
} else {
311310
// Start the server
312-
const started = await startWebServer();
311+
const started = await startWebServer(webPort);
313312
if (!started) {
314313
logger.error('Failed to start web UI server');
315314
return;

packages/web-shared/src/api/workflow-api-client.ts

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
} from './workflow-server-actions';
2626

2727
const MAX_ITEMS = 1000;
28-
const LIVE_POLL_LIMIT = 5;
28+
const LIVE_POLL_LIMIT = 10;
29+
const LIVE_STEP_UPDATE_INTERVAL_MS = 2000;
2930
const LIVE_UPDATE_INTERVAL_MS = 5000;
3031

3132
/**
@@ -660,6 +661,7 @@ export function useWorkflowTraceViewerData(
660661
const [hooks, setHooks] = useState<Hook[]>([]);
661662
const [events, setEvents] = useState<Event[]>([]);
662663
const [loading, setLoading] = useState(true);
664+
const [auxiliaryDataLoading, setAuxiliaryDataLoading] = useState(false);
663665
const [error, setError] = useState<Error | null>(null);
664666

665667
const [stepsCursor, setStepsCursor] = useState<string | undefined>();
@@ -677,29 +679,33 @@ export function useWorkflowTraceViewerData(
677679

678680
isFetchingRef.current = true;
679681
setLoading(true);
682+
setAuxiliaryDataLoading(true);
680683
setError(null);
681684

685+
const promises = [
686+
fetchRun(env, runId).then((result) => {
687+
// The run is the most visible part - so we can start showing UI
688+
// as soon as we have the run
689+
setLoading(false);
690+
setRun(unwrapServerActionResult(result));
691+
}),
692+
fetchAllSteps(env, runId).then((result) => {
693+
setSteps(result.data);
694+
setStepsCursor(result.cursor);
695+
}),
696+
fetchAllHooks(env, runId).then((result) => {
697+
setHooks(result.data);
698+
setHooksCursor(result.cursor);
699+
}),
700+
fetchAllEvents(env, runId).then((result) => {
701+
setEvents(result.data);
702+
setEventsCursor(result.cursor);
703+
}),
704+
];
705+
682706
try {
683707
// Fetch run
684-
const runServerResult = await fetchRun(env, runId);
685-
const runData = unwrapServerActionResult(runServerResult);
686-
setRun(runData);
687-
688-
// TODO: Do these in parallel
689-
// Fetch steps exhaustively
690-
const stepsResult = await fetchAllSteps(env, runId);
691-
setSteps(stepsResult.data);
692-
setStepsCursor(stepsResult.cursor);
693-
694-
// Fetch hooks exhaustively
695-
const hooksResult = await fetchAllHooks(env, runId);
696-
setHooks(hooksResult.data);
697-
setHooksCursor(hooksResult.cursor);
698-
699-
// Fetch events exhaustively
700-
const eventsResult = await fetchAllEvents(env, runId);
701-
setEvents(eventsResult.data);
702-
setEventsCursor(eventsResult.cursor);
708+
await Promise.all(promises);
703709
} catch (err) {
704710
const error =
705711
err instanceof WorkflowAPIError
@@ -711,6 +717,7 @@ export function useWorkflowTraceViewerData(
711717
setError(error);
712718
} finally {
713719
setLoading(false);
720+
setAuxiliaryDataLoading(false);
714721
isFetchingRef.current = false;
715722
setInitialLoadCompleted(true);
716723
}
@@ -808,27 +815,31 @@ export function useWorkflowTraceViewerData(
808815
}, [env, runId, eventsCursor, mergeEvents]);
809816

810817
// Update function for live polling
811-
const update = useCallback(async (): Promise<{ foundNewItems: boolean }> => {
812-
if (isFetchingRef.current || !initialLoadCompleted) {
813-
return { foundNewItems: false };
814-
}
818+
const update = useCallback(
819+
async (stepsOnly: boolean = false): Promise<{ foundNewItems: boolean }> => {
820+
if (isFetchingRef.current || !initialLoadCompleted) {
821+
return { foundNewItems: false };
822+
}
815823

816-
let foundNewItems = false;
824+
let foundNewItems = false;
817825

818-
try {
819-
const [_, stepsUpdated, hooksUpdated, eventsUpdated] = await Promise.all([
820-
pollRun(),
821-
pollSteps(),
822-
pollHooks(),
823-
pollEvents(),
824-
]);
825-
foundNewItems = stepsUpdated || hooksUpdated || eventsUpdated;
826-
} catch (err) {
827-
console.error('Update error:', err);
828-
}
826+
try {
827+
const [_, stepsUpdated, hooksUpdated, eventsUpdated] =
828+
await Promise.all([
829+
stepsOnly ? Promise.resolve(false) : pollRun(),
830+
pollSteps(),
831+
stepsOnly ? Promise.resolve(false) : pollHooks(),
832+
stepsOnly ? Promise.resolve(false) : pollEvents(),
833+
]);
834+
foundNewItems = stepsUpdated || hooksUpdated || eventsUpdated;
835+
} catch (err) {
836+
console.error('Update error:', err);
837+
}
829838

830-
return { foundNewItems };
831-
}, [pollSteps, pollHooks, pollEvents, initialLoadCompleted, pollRun]);
839+
return { foundNewItems };
840+
},
841+
[pollSteps, pollHooks, pollEvents, initialLoadCompleted, pollRun]
842+
);
832843

833844
// Initial load
834845
useEffect(() => {
@@ -844,8 +855,14 @@ export function useWorkflowTraceViewerData(
844855
const interval = setInterval(() => {
845856
update();
846857
}, LIVE_UPDATE_INTERVAL_MS);
847-
848-
return () => clearInterval(interval);
858+
const stepInterval = setInterval(() => {
859+
update(true);
860+
}, LIVE_STEP_UPDATE_INTERVAL_MS);
861+
862+
return () => {
863+
clearInterval(interval);
864+
clearInterval(stepInterval);
865+
};
849866
}, [live, initialLoadCompleted, update, run?.completedAt]);
850867

851868
return {
@@ -854,6 +871,7 @@ export function useWorkflowTraceViewerData(
854871
hooks,
855872
events,
856873
loading,
874+
auxiliaryDataLoading,
857875
error,
858876
update,
859877
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { cn } from '../../lib/utils';
2+
3+
function Skeleton({
4+
className,
5+
...props
6+
}: React.HTMLAttributes<HTMLDivElement>) {
7+
return (
8+
<div
9+
className={cn('animate-pulse rounded-md bg-muted', className)}
10+
{...props}
11+
/>
12+
);
13+
}
14+
15+
export { Skeleton };

packages/web-shared/src/run-trace-view.tsx

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,23 +34,16 @@ export function RunTraceView({ env, runId }: RunTraceViewProps) {
3434
}
3535

3636
return (
37-
<div className="space-y-6">
38-
<div className="relative">
39-
{loading && (
40-
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
41-
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-white"></div>
42-
</div>
43-
)}
44-
<WorkflowTraceViewer
45-
error={error}
46-
steps={allSteps}
47-
events={allEvents}
48-
hooks={allHooks}
49-
env={env}
50-
run={run}
51-
isLoading={loading}
52-
/>
53-
</div>
37+
<div className="w-full h-full relative">
38+
<WorkflowTraceViewer
39+
error={error}
40+
steps={allSteps}
41+
events={allEvents}
42+
hooks={allHooks}
43+
env={env}
44+
run={run}
45+
isLoading={loading}
46+
/>
5447
</div>
5548
);
5649
}

packages/web-shared/src/sidebar/events-list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function EventsList({
5656
className="text-heading-16 font-medium mt-4 mb-2"
5757
style={{ color: 'var(--ds-gray-1000)' }}
5858
>
59-
Events ({eventsLoading ? '...' : displayData.length})
59+
Events {!eventsLoading && `(${displayData.length})`}
6060
</h3>
6161
{/* Events section */}
6262
{eventError ? (

packages/web-shared/src/workflow-trace-view.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { Event, Hook, Step, WorkflowRun } from '@workflow/world';
2-
import { Loader2 } from 'lucide-react';
32
import { useEffect, useMemo, useState } from 'react';
43
import { toast } from 'sonner';
54
import type { EnvMap } from './api/workflow-server-actions';
5+
import { Skeleton } from './components/ui/skeleton';
66
import { WorkflowDetailPanel } from './sidebar/workflow-detail-panel';
77
import {
88
TraceViewerContextProvider,
@@ -31,7 +31,6 @@ export const WorkflowTraceViewer = ({
3131
env,
3232
isLoading,
3333
error,
34-
height = 800,
3534
}: {
3635
run: WorkflowRun;
3736
steps: Step[];
@@ -40,7 +39,6 @@ export const WorkflowTraceViewer = ({
4039
env: EnvMap;
4140
isLoading?: boolean;
4241
error?: Error | null;
43-
height?: number;
4442
}) => {
4543
const [now, setNow] = useState(() => new Date());
4644

@@ -160,25 +158,30 @@ export const WorkflowTraceViewer = ({
160158
}
161159
}, [error, isLoading]);
162160

163-
return (
164-
<div className="relative">
165-
{isLoading && (
166-
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
167-
<div className="flex flex-col items-center gap-3">
168-
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
169-
<p className="text-sm text-muted-foreground">
170-
Loading trace data...
171-
</p>
172-
</div>
161+
if (isLoading || !trace) {
162+
return (
163+
<div className="relative w-full h-full">
164+
<div className="border-b border-gray-alpha-400 w-full" />
165+
<Skeleton className="w-full ml-2 mt-1 mb-1 h-[56px]" />
166+
<div className="p-2 relative w-full">
167+
<Skeleton className="w-full mt-6 h-[20px]" />
168+
<Skeleton className="w-[10%] mt-2 ml-6 h-[20px]" />
169+
<Skeleton className="w-[10%] mt-2 ml-12 h-[20px]" />
170+
<Skeleton className="w-[20%] mt-2 ml-16 h-[20px]" />
173171
</div>
174-
)}
172+
</div>
173+
);
174+
}
175+
176+
return (
177+
<div className="relative w-full h-full">
175178
<TraceViewerContextProvider
176179
withPanel
177180
customSpanClassNameFunc={getCustomSpanClassName}
178181
customSpanEventClassNameFunc={getCustomSpanEventClassName}
179182
customPanelComponent={<WorkflowDetailPanel env={env} />}
180183
>
181-
<TraceViewerTimeline height={height} trace={trace} withPanel />
184+
<TraceViewerTimeline height="100%" trace={trace} withPanel />
182185
</TraceViewerContextProvider>
183186
</div>
184187
);

0 commit comments

Comments
 (0)