Files
vercel__workflow/packages/web/app/root.tsx
Pranay Prakash 25715d4521 [RFC] feat(nitro): embed observability dashboard in-process at /_workflow (#2548)
* feat(nitro): embed observability dashboard in-process at /_workflow

Serve the @workflow/web observability UI inside the Nitro process at a
configurable route (default /_workflow) instead of spawning a separate
web server and 302-redirecting to it. Enabled in dev, omitted from
production builds by default (so prod bundles carry no @workflow/web
import). Never mounted on Vercel deploys (use the hosted dashboard).

- @workflow/web: add a framework-neutral `@workflow/web/handler`
  (createWorkflowWebHandler) that serves SSR + static client assets +
  RPC as one Web Request->Response handler under a runtime basename
  (asset manifest URLs + publicPath are reprefixed so the dashboard is
  self-contained under its mount). Add `@workflow/web/registry` for
  embedded-dashboard discovery; make the RPC/stream client basename-aware.
- @workflow/nitro: mount the handler in-process (Nitro v2 h3 + v3 native
  paths), gated by a new `dashboard` option (default = dev).
- @workflow/cli: `workflow web` / `inspect --web` defer to a running
  embedded dashboard instead of starting a redundant server; pass
  `--standalone` to force the standalone UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(nitro): normalize dashboard path once, use isNitroV2() helper

Address review feedback on the embedded dashboard:

- Normalize the dashboard mount path in one place before it feeds both
  the Nitro route registration (`[path, path + '/**']`) and the handler
  `basename`. Force a single leading slash, strip trailing slashes, and
  reject the root mount, so a custom `path` can't make the route and the
  handler's internal `normalizeBasename` disagree.
- Replace the handler-level `!nitro.routing` v2 checks with the existing
  `isNitroV2()` helper for consistent v2/v3 detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Peter Wielander <mittgfu@gmail.com>
2026-07-29 13:58:59 -07:00

252 lines
7.5 KiB
TypeScript

import { TooltipProvider } from '@radix-ui/react-tooltip';
import { ThemeProvider, useTheme } from 'next-themes';
import { useEffect, useRef } from 'react';
import {
isRouteErrorResponse,
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigate,
useRouteError,
useSearchParams,
} from 'react-router';
import { ConnectionStatus } from '~/components/display-utils/connection-status';
import { HealthCheckButton } from '~/components/display-utils/health-check-button';
import { ThemePicker } from '~/components/theme-dropdown';
import { DocsLink } from '~/components/top-nav/docs-link';
import { Toaster } from '~/components/ui/sonner';
import { Logo } from '~/icons/logo';
import { ServerConfigProvider } from '~/lib/world-config-context';
import { getPublicServerConfig } from '~/server/workflow-server-actions.server';
import type { Route } from './+types/root';
import './globals.css';
// Server-side loader: provides config data on initial render and navigation.
// `basename` is the embed mount path (e.g. "/_workflow"), threaded through the
// load context by the embedded handler; "" when served standalone at the root.
export async function loader({ context }: Route.LoaderArgs) {
const serverConfig = await getPublicServerConfig();
return { serverConfig, basename: context?.basename ?? '' };
}
// Catch-all action: handles stray POST requests (e.g. from Radix UI dialogs
// that render internal <form method="dialog"> elements).
export async function action({ request }: { request: Request }) {
const url = new URL(request.url);
const contentType = request.headers.get('content-type') || '(none)';
const body = await request.text().catch(() => '(unreadable)');
console.warn(
`[root action] Unexpected POST to ${url.pathname}`,
`\n Content-Type: ${contentType}`,
`\n Referer: ${request.headers.get('referer') || '(none)'}`,
`\n Body: ${body.slice(0, 500) || '(empty)'}`
);
return null;
}
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<title>Workflow Observability UI</title>
<meta
name="description"
content="Web interface for inspecting flow runs, steps, streams, and events"
/>
<Meta />
<Links />
</head>
<body className="antialiased">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// --- Deep-link navigation helpers (migrated from layout-client.tsx) ---
function computeDirectRunUrl(params: {
runId: string;
stepId: string | null;
hookId: string | null;
}): string {
const { runId, stepId, hookId } = params;
if (stepId) {
return `/run/${runId}?sidebar=step&stepId=${stepId}`;
}
if (hookId) {
return `/run/${runId}?sidebar=hook&hookId=${hookId}`;
}
return `/run/${runId}`;
}
function computeResourceUrl(params: {
resource: string;
id: string;
runId: string | null;
}): string | null {
const { resource, id, runId } = params;
switch (resource) {
case 'run':
return `/run/${id}`;
case 'step':
return runId ? `/run/${runId}?sidebar=step&stepId=${id}` : null;
case 'stream':
return runId ? `/run/${runId}?sidebar=stream&streamId=${id}` : null;
case 'event':
return runId ? `/run/${runId}?sidebar=event&eventId=${id}` : null;
case 'hook':
return runId
? `/run/${runId}?sidebar=hook&hookId=${id}`
: `/?sidebar=hook&hookId=${id}`;
default:
return null;
}
}
function LayoutContent({ children }: { children: React.ReactNode }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { setTheme } = useTheme();
const id = searchParams.get('id');
const runId = searchParams.get('runId');
const stepId = searchParams.get('stepId');
const hookId = searchParams.get('hookId');
const resource = searchParams.get('resource');
const themeParam = searchParams.get('theme');
const hasNavigatedRef = useRef(false);
// Sync theme from URL param to next-themes
useEffect(() => {
if (
themeParam &&
(themeParam === 'light' ||
themeParam === 'dark' ||
themeParam === 'system')
) {
setTheme(themeParam);
}
}, [themeParam, setTheme]);
// Deep-link navigation on mount
useEffect(() => {
const pathname = window.location.pathname;
if (pathname.startsWith('/run/')) {
hasNavigatedRef.current = true;
return;
}
if (pathname !== '/' && hasNavigatedRef.current) {
return;
}
if (resource) {
if (!id) return;
const url = computeResourceUrl({ resource, id, runId });
if (url) {
hasNavigatedRef.current = true;
navigate(url);
} else {
console.warn(`Can't deep-link to ${resource} ${id}.`);
}
return;
}
if (runId) {
hasNavigatedRef.current = true;
navigate(computeDirectRunUrl({ runId, stepId, hookId }));
return;
}
}, [resource, id, runId, stepId, hookId, navigate]);
return (
<div className="min-h-screen flex flex-col">
<TooltipProvider delayDuration={0}>
{/* Sticky Header */}
<div className="sticky top-0 z-50 bg-background border-b px-6 py-4">
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-4">
<Link to="/">
<h1
className="flex items-center gap-2"
title="Workflow Observability"
>
<Logo />
</h1>
</Link>
<div className="h-6 w-px bg-border" aria-hidden="true" />
<ConnectionStatus />
</div>
<div className="ml-auto flex items-center gap-2">
<HealthCheckButton />
<div className="h-6 w-px bg-border" aria-hidden="true" />
<ThemePicker />
<DocsLink />
</div>
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 px-6 pt-6">{children}</div>
</TooltipProvider>
<Toaster />
</div>
);
}
export default function App({ loaderData }: Route.ComponentProps) {
// Surface the embed mount path to client-only fetches (RPC + stream reads).
// Set during the first (hydration) render, before any post-mount data fetch.
if (typeof window !== 'undefined') {
window.__WORKFLOW_BASENAME__ = loaderData.basename ?? '';
}
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
storageKey="workflow-theme"
>
<ServerConfigProvider serverConfig={loaderData.serverConfig}>
<LayoutContent>
<Outlet />
</LayoutContent>
</ServerConfigProvider>
</ThemeProvider>
);
}
export function ErrorBoundary() {
const error = useRouteError();
const isNotFound = isRouteErrorResponse(error) && error.status === 404;
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">
{isNotFound ? '404' : 'Error'}
</h1>
<p className="text-muted-foreground mb-6">
{isNotFound ? 'This page does not exist.' : 'Something went wrong.'}
</p>
<Link to="/" className="text-primary underline">
Go to dashboard
</Link>
</div>
</div>
);
}