diff --git a/.changeset/empty-yaks-follow.md b/.changeset/empty-yaks-follow.md new file mode 100644 index 000000000..66711106d --- /dev/null +++ b/.changeset/empty-yaks-follow.md @@ -0,0 +1,5 @@ +--- +"@workflow/web": patch +--- + +Invoke the endpoints health check in web o11y and render result in toolbar diff --git a/packages/web/src/app/layout-client.tsx b/packages/web/src/app/layout-client.tsx index 286923356..b4e4a75d7 100644 --- a/packages/web/src/app/layout-client.tsx +++ b/packages/web/src/app/layout-client.tsx @@ -6,6 +6,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { ThemeProvider, useTheme } from 'next-themes'; import { useEffect, useRef } from 'react'; import { ConnectionStatus } from '@/components/display-utils/connection-status'; +import { EndpointsHealthStatus } from '@/components/display-utils/endpoints-health-status'; import { SettingsDropdown } from '@/components/settings-dropdown'; import { Toaster } from '@/components/ui/sonner'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; @@ -144,7 +145,8 @@ function LayoutContent({ children }: LayoutClientProps) { -
+
+
diff --git a/packages/web/src/components/display-utils/endpoints-health-status.tsx b/packages/web/src/components/display-utils/endpoints-health-status.tsx new file mode 100644 index 000000000..bad42f398 --- /dev/null +++ b/packages/web/src/components/display-utils/endpoints-health-status.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { AlertCircleIcon, CheckCircle2Icon, LoaderIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { WorldConfig } from '@/lib/config-world'; + +interface EndpointsHealthStatusProps { + config: WorldConfig; +} + +interface HealthCheckResult { + flow: 'pending' | 'success' | 'error'; + step: 'pending' | 'success' | 'error'; + flowMessage?: string; + stepMessage?: string; + checkedAt?: string; +} + +const HEALTH_CHECK_SESSION_KEY = 'workflow-endpoints-health-check'; + +function getSessionHealthCheck(configKey: string): HealthCheckResult | null { + try { + const stored = sessionStorage.getItem( + `${HEALTH_CHECK_SESSION_KEY}-${configKey}` + ); + if (stored) { + return JSON.parse(stored); + } + } catch { + // Ignore sessionStorage errors (e.g., in SSR or private browsing) + } + return null; +} + +function setSessionHealthCheck( + configKey: string, + result: HealthCheckResult +): void { + try { + sessionStorage.setItem( + `${HEALTH_CHECK_SESSION_KEY}-${configKey}`, + JSON.stringify(result) + ); + } catch { + // Ignore sessionStorage errors + } +} + +function getConfigKey(config: WorldConfig): string { + // Create a unique key based on relevant config values + return `${config.backend || 'local'}-${config.port || '3000'}`; +} + +async function checkEndpointHealth( + baseUrl: string, + endpoint: 'flow' | 'step' +): Promise<{ success: boolean; message: string }> { + try { + const url = new URL( + `/.well-known/workflow/v1/${endpoint}?__health`, + baseUrl + ); + const response = await fetch(url.toString(), { + method: 'POST', + // Short timeout for health checks + signal: AbortSignal.timeout(5000), + }); + + if (response.ok) { + const text = await response.text(); + return { success: true, message: text }; + } + return { + success: false, + message: `HTTP ${response.status}: ${response.statusText}`, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Connection failed'; + return { success: false, message }; + } +} + +export function EndpointsHealthStatus({ config }: EndpointsHealthStatusProps) { + const [healthCheck, setHealthCheck] = useState({ + flow: 'pending', + step: 'pending', + }); + const [isChecking, setIsChecking] = useState(false); + + useEffect(() => { + const configKey = getConfigKey(config); + const cached = getSessionHealthCheck(configKey); + + // If we have a cached result from this session, use it + if (cached) { + setHealthCheck(cached); + return; + } + + // Otherwise, perform the health check + const performHealthCheck = async () => { + setIsChecking(true); + + // Determine base URL based on config + const port = config.port || '3000'; + const baseUrl = `http://localhost:${port}`; + + const [flowResult, stepResult] = await Promise.all([ + checkEndpointHealth(baseUrl, 'flow'), + checkEndpointHealth(baseUrl, 'step'), + ]); + + const result: HealthCheckResult = { + flow: flowResult.success ? 'success' : 'error', + step: stepResult.success ? 'success' : 'error', + flowMessage: flowResult.message, + stepMessage: stepResult.message, + checkedAt: new Date().toISOString(), + }; + + setHealthCheck(result); + setSessionHealthCheck(configKey, result); + setIsChecking(false); + }; + + performHealthCheck(); + }, [config]); + + const allSuccess = + healthCheck.flow === 'success' && healthCheck.step === 'success'; + const anyError = healthCheck.flow === 'error' || healthCheck.step === 'error'; + const isPending = + healthCheck.flow === 'pending' || healthCheck.step === 'pending'; + + const getStatusIcon = () => { + if (isChecking || isPending) { + return ( + + ); + } + if (allSuccess) { + return ; + } + if (anyError) { + return ; + } + return ; + }; + + const getStatusText = () => { + if (isChecking || isPending) { + return 'Checking endpoints...'; + } + if (allSuccess) { + return 'Endpoints healthy'; + } + if (anyError) { + return 'Endpoint issues'; + } + return 'Unknown status'; + }; + + const getEndpointStatus = (status: 'pending' | 'success' | 'error') => { + if (status === 'success') { + return ; + } + if (status === 'error') { + return ; + } + return ; + }; + + return ( + + +
+ {getStatusIcon()} + {getStatusText()} +
+
+ +
+
Workflow Endpoint Health
+
+
+ {getEndpointStatus(healthCheck.flow)} + /.well-known/workflow/v1/flow +
+ {healthCheck.flowMessage && ( +
+ {healthCheck.flowMessage} +
+ )} +
+
+
+ {getEndpointStatus(healthCheck.step)} + /.well-known/workflow/v1/step +
+ {healthCheck.stepMessage && ( +
+ {healthCheck.stepMessage} +
+ )} +
+ {healthCheck.checkedAt && ( +
+ Checked at {new Date(healthCheck.checkedAt).toLocaleTimeString()} +
+ )} +
+
+
+ ); +}