Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/khaki-turtles-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/cli": patch
---

Fix --noBrowser option help documentation
7 changes: 7 additions & 0 deletions .changeset/thick-moons-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@workflow/web-shared": patch
"@workflow/web": patch
---

Improve trace viewer load times and loading animation

11 changes: 10 additions & 1 deletion packages/cli/src/lib/inspect/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have a new group for web?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to my TODO list

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',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if all the cli related env vars should be prefixed with WORKFLOW_CLI_ (so we have consistency here with expectations)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to my TODO list

helpGroup: 'Output',
helpLabel: '--no-browser',
helpLabel: '--noBrowser',
}),
sort: Flags.string({
description: 'sort order for list commands',
Expand Down
25 changes: 12 additions & 13 deletions packages/cli/src/lib/inspect/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> {
async function startWebServer(webPort: number): Promise<boolean> {
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;
}
Expand All @@ -146,7 +145,7 @@ async function startWebServer(): Promise<boolean> {
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
Expand Down Expand Up @@ -195,10 +194,8 @@ async function startWebServer(): Promise<boolean> {
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;
}
Expand Down Expand Up @@ -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;
Expand Down
98 changes: 58 additions & 40 deletions packages/web-shared/src/api/workflow-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

const [stepsCursor, setStepsCursor] = useState<string | undefined>();
Expand All @@ -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
Expand All @@ -711,6 +717,7 @@ export function useWorkflowTraceViewerData(
setError(error);
} finally {
setLoading(false);
setAuxiliaryDataLoading(false);
isFetchingRef.current = false;
setInitialLoadCompleted(true);
}
Expand Down Expand Up @@ -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([
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all this data fetching logic and reactivity is making me think of RxJS 🥲

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this needs an overhaul eventually

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(() => {
Expand All @@ -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 {
Expand All @@ -854,6 +871,7 @@ export function useWorkflowTraceViewerData(
hooks,
events,
loading,
auxiliaryDataLoading,
error,
update,
};
Expand Down
15 changes: 15 additions & 0 deletions packages/web-shared/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { cn } from '../../lib/utils';

function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}

export { Skeleton };
27 changes: 10 additions & 17 deletions packages/web-shared/src/run-trace-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,16 @@ export function RunTraceView({ env, runId }: RunTraceViewProps) {
}

return (
<div className="space-y-6">
<div className="relative">
{loading && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-white"></div>
</div>
)}
<WorkflowTraceViewer
error={error}
steps={allSteps}
events={allEvents}
hooks={allHooks}
env={env}
run={run}
isLoading={loading}
/>
</div>
<div className="w-full h-full relative">
<WorkflowTraceViewer
error={error}
steps={allSteps}
events={allEvents}
hooks={allHooks}
env={env}
run={run}
isLoading={loading}
/>
</div>
);
}
2 changes: 1 addition & 1 deletion packages/web-shared/src/sidebar/events-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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})`}
</h3>
{/* Events section */}
{eventError ? (
Expand Down
33 changes: 18 additions & 15 deletions packages/web-shared/src/workflow-trace-view.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -31,7 +31,6 @@ export const WorkflowTraceViewer = ({
env,
isLoading,
error,
height = 800,
}: {
run: WorkflowRun;
steps: Step[];
Expand All @@ -40,7 +39,6 @@ export const WorkflowTraceViewer = ({
env: EnvMap;
isLoading?: boolean;
error?: Error | null;
height?: number;
}) => {
const [now, setNow] = useState(() => new Date());

Expand Down Expand Up @@ -160,25 +158,30 @@ export const WorkflowTraceViewer = ({
}
}, [error, isLoading]);

return (
<div className="relative">
{isLoading && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Loading trace data...
</p>
</div>
if (isLoading || !trace) {
return (
<div className="relative w-full h-full">
<div className="border-b border-gray-alpha-400 w-full" />
<Skeleton className="w-full ml-2 mt-1 mb-1 h-[56px]" />
<div className="p-2 relative w-full">
<Skeleton className="w-full mt-6 h-[20px]" />
<Skeleton className="w-[10%] mt-2 ml-6 h-[20px]" />
<Skeleton className="w-[10%] mt-2 ml-12 h-[20px]" />
<Skeleton className="w-[20%] mt-2 ml-16 h-[20px]" />
</div>
)}
</div>
);
}

return (
<div className="relative w-full h-full">
<TraceViewerContextProvider
withPanel
customSpanClassNameFunc={getCustomSpanClassName}
customSpanEventClassNameFunc={getCustomSpanEventClassName}
customPanelComponent={<WorkflowDetailPanel env={env} />}
>
<TraceViewerTimeline height={height} trace={trace} withPanel />
<TraceViewerTimeline height="100%" trace={trace} withPanel />
</TraceViewerContextProvider>
</div>
);
Expand Down
Loading
Loading