diff --git a/.changeset/old-hats-carry.md b/.changeset/old-hats-carry.md new file mode 100644 index 000000000..9b570c7e6 --- /dev/null +++ b/.changeset/old-hats-carry.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Add workflow graph visualization to observability UI and o11y migration to nuqs for url state management diff --git a/packages/web-shared/src/api/workflow-api-client.ts b/packages/web-shared/src/api/workflow-api-client.ts index ee1a7527b..3143c5524 100644 --- a/packages/web-shared/src/api/workflow-api-client.ts +++ b/packages/web-shared/src/api/workflow-api-client.ts @@ -64,7 +64,7 @@ export const getErrorMessage = (error: Error | WorkflowAPIError): string => { /** * Helper to handle server action results and throw WorkflowAPIError on failure */ -function unwrapServerActionResult(result: { +export function unwrapServerActionResult(result: { success: boolean; data?: T; error?: ServerActionError; diff --git a/packages/web-shared/src/api/workflow-server-actions.ts b/packages/web-shared/src/api/workflow-server-actions.ts index 66230d452..96ec65ac7 100644 --- a/packages/web-shared/src/api/workflow-server-actions.ts +++ b/packages/web-shared/src/api/workflow-server-actions.ts @@ -1,5 +1,7 @@ 'use server'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { hydrateResourceIO } from '@workflow/core/observability'; import { createWorld, start } from '@workflow/core/runtime'; import type { @@ -499,3 +501,36 @@ export async function readStreamServerAction( }; } } + +/** + * Fetch the workflows manifest from the data directory + * The manifest is generated at build time and contains static structure info about workflows + */ +export async function fetchWorkflowsManifest( + worldEnv: EnvMap +): Promise> { + try { + // Get the data directory from the world environment config + // This contains the correct absolute path passed from the client + const dataDir = + worldEnv.WORKFLOW_EMBEDDED_DATA_DIR || + process.env.WORKFLOW_EMBEDDED_DATA_DIR || + '.next/workflow-data'; + + // If dataDir is absolute, use it directly; otherwise join with cwd + const fullPath = path.isAbsolute(dataDir) + ? path.join(dataDir, 'workflows.json') + : path.join(process.cwd(), dataDir, 'workflows.json'); + + const content = await fs.readFile(fullPath, 'utf-8'); + const manifest = JSON.parse(content); + + return createResponse(manifest); + } catch (error) { + console.error('Failed to fetch workflows manifest:', error); + return { + success: false, + error: createServerActionError(error, 'fetchWorkflowsManifest', {}), + }; + } +} diff --git a/packages/web-shared/src/index.ts b/packages/web-shared/src/index.ts index 60e9d2465..250d5eac6 100644 --- a/packages/web-shared/src/index.ts +++ b/packages/web-shared/src/index.ts @@ -5,6 +5,11 @@ export { export type { Event, Hook, Step, WorkflowRun } from '@workflow/world'; export * from './api/workflow-api-client'; +export type { EnvMap } from './api/workflow-server-actions'; +export { + fetchEventsByCorrelationId, + fetchWorkflowsManifest, +} from './api/workflow-server-actions'; export { RunTraceView } from './run-trace-view'; export type { Span, SpanEvent } from './trace-viewer/types'; export { WorkflowTraceViewer } from './workflow-trace-view'; diff --git a/packages/web/package.json b/packages/web/package.json index f46a757d2..5f69bf135 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,9 @@ "format": "biome format --write" }, "dependencies": { - "next": "15.5.4" + "next": "15.5.4", + "nuqs": "^2.2.5", + "@xyflow/react": "12.9.3" }, "devDependencies": { "@biomejs/biome": "catalog:", @@ -36,6 +38,9 @@ "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-switch": "1.1.2", "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-dropdown-menu": "2.1.6", "@tailwindcss/postcss": "4", "@types/node": "catalog:", "@types/react": "19", diff --git a/packages/web/src/app/globals.css b/packages/web/src/app/globals.css index eba38b97a..452b00c9a 100644 --- a/packages/web/src/app/globals.css +++ b/packages/web/src/app/globals.css @@ -2,30 +2,83 @@ /* Scan web-shared package for Tailwind classes */ @source "../../../web-shared/src"; +@source "../components"; + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-geist-sans); + --font-mono: var(--font-geist-mono); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --radius: 0.5rem; - - /* Geist Design System Colors */ + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); + + /* Geist Design System Colors - Light Mode */ /* Backgrounds */ --ds-background-100: rgb(255 255 255); --ds-background-200: rgb(250 250 250); @@ -42,17 +95,6 @@ --ds-gray-900: rgb(23 23 23); --ds-gray-1000: rgb(0 0 0); - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(0 0 0 / 0.02); - --ds-gray-alpha-200: rgb(0 0 0 / 0.04); - --ds-gray-alpha-300: rgb(0 0 0 / 0.08); - --ds-gray-alpha-400: rgb(0 0 0 / 0.12); - --ds-gray-alpha-500: rgb(0 0 0 / 0.3); - --ds-gray-alpha-600: rgb(0 0 0 / 0.5); - --ds-gray-alpha-700: rgb(0 0 0 / 0.6); - --ds-gray-alpha-800: rgb(0 0 0 / 0.7); - --ds-gray-alpha-900: rgb(0 0 0 / 0.9); - /* Blue */ --ds-blue-100: rgb(224 242 254); --ds-blue-200: rgb(186 230 253); @@ -101,18 +143,6 @@ --ds-green-900: rgb(20 83 45); --ds-green-1000: rgb(5 46 22); - /* Teal */ - --ds-teal-100: rgb(204 251 241); - --ds-teal-200: rgb(153 246 228); - --ds-teal-300: rgb(94 234 212); - --ds-teal-400: rgb(45 212 191); - --ds-teal-500: rgb(20 184 166); - --ds-teal-600: rgb(13 148 136); - --ds-teal-700: rgb(15 118 110); - --ds-teal-800: rgb(17 94 89); - --ds-teal-900: rgb(19 78 74); - --ds-teal-1000: rgb(4 47 46); - /* Purple */ --ds-purple-100: rgb(243 232 255); --ds-purple-200: rgb(233 213 255); @@ -125,17 +155,17 @@ --ds-purple-900: rgb(88 28 135); --ds-purple-1000: rgb(59 7 100); - /* Pink */ - --ds-pink-100: rgb(252 231 243); - --ds-pink-200: rgb(251 207 232); - --ds-pink-300: rgb(249 168 212); - --ds-pink-400: rgb(244 114 182); - --ds-pink-500: rgb(236 72 153); - --ds-pink-600: rgb(219 39 119); - --ds-pink-700: rgb(190 24 93); - --ds-pink-800: rgb(157 23 77); - --ds-pink-900: rgb(131 24 67); - --ds-pink-1000: rgb(80 7 36); + /* Teal */ + --ds-teal-100: rgb(204 251 241); + --ds-teal-200: rgb(153 246 228); + --ds-teal-300: rgb(94 234 212); + --ds-teal-400: rgb(45 212 191); + --ds-teal-500: rgb(20 184 166); + --ds-teal-600: rgb(13 148 136); + --ds-teal-700: rgb(15 118 110); + --ds-teal-800: rgb(17 94 89); + --ds-teal-900: rgb(19 78 74); + --ds-teal-1000: rgb(4 47 46); /* Shadows */ --ds-shadow-small: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 2px 4px rgba(0, 0, 0, 0.05), 0 12px 24px rgba(0, 0, 0, 0.05); @@ -143,169 +173,38 @@ --ds-shadow-large: 0 0 0 1px rgba(0, 0, 0, 0.07), 0 8px 16px rgba(0, 0, 0, 0.10), 0 48px 96px rgba(0, 0, 0, 0.10); } -/* Dark mode: applies when system prefers dark (and no .light class override) */ -@media (prefers-color-scheme: dark) { - :root:not(.light) { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - - /* Geist Design System Colors - Dark Mode */ - /* Backgrounds */ - --ds-background-100: rgb(0 0 0); - --ds-background-200: rgb(10 10 10); - - /* Gray Scale */ - --ds-gray-100: rgb(17 17 17); - --ds-gray-200: rgb(23 23 23); - --ds-gray-300: rgb(41 41 41); - --ds-gray-400: rgb(64 64 64); - --ds-gray-500: rgb(115 115 115); - --ds-gray-600: rgb(163 163 163); - --ds-gray-700: rgb(212 212 212); - --ds-gray-800: rgb(229 229 229); - --ds-gray-900: rgb(245 245 245); - --ds-gray-1000: rgb(255 255 255); - - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(255 255 255 / 0.02); - --ds-gray-alpha-200: rgb(255 255 255 / 0.04); - --ds-gray-alpha-300: rgb(255 255 255 / 0.08); - --ds-gray-alpha-400: rgb(255 255 255 / 0.12); - --ds-gray-alpha-500: rgb(255 255 255 / 0.3); - --ds-gray-alpha-600: rgb(255 255 255 / 0.5); - --ds-gray-alpha-700: rgb(255 255 255 / 0.6); - --ds-gray-alpha-800: rgb(255 255 255 / 0.7); - --ds-gray-alpha-900: rgb(255 255 255 / 0.9); - - /* Blue - Dark Mode */ - --ds-blue-100: rgb(8 47 73); - --ds-blue-200: rgb(12 74 110); - --ds-blue-300: rgb(7 89 133); - --ds-blue-400: rgb(3 105 161); - --ds-blue-500: rgb(2 132 199); - --ds-blue-600: rgb(14 165 233); - --ds-blue-700: rgb(56 189 248); - --ds-blue-800: rgb(125 211 252); - --ds-blue-900: rgb(186 230 253); - --ds-blue-1000: rgb(224 242 254); - - /* Red - Dark Mode */ - --ds-red-100: rgb(69 10 10); - --ds-red-200: rgb(127 29 29); - --ds-red-300: rgb(153 27 27); - --ds-red-400: rgb(185 28 28); - --ds-red-500: rgb(220 38 38); - --ds-red-600: rgb(239 68 68); - --ds-red-700: rgb(248 113 113); - --ds-red-800: rgb(252 165 165); - --ds-red-900: rgb(254 202 202); - --ds-red-1000: rgb(254 226 226); - - /* Amber - Dark Mode */ - --ds-amber-100: rgb(69 26 3); - --ds-amber-200: rgb(120 53 15); - --ds-amber-300: rgb(146 64 14); - --ds-amber-400: rgb(180 83 9); - --ds-amber-500: rgb(217 119 6); - --ds-amber-600: rgb(245 158 11); - --ds-amber-700: rgb(251 191 36); - --ds-amber-800: rgb(252 211 77); - --ds-amber-900: rgb(253 230 138); - --ds-amber-1000: rgb(254 243 199); - - /* Green - Dark Mode */ - --ds-green-100: rgb(5 46 22); - --ds-green-200: rgb(20 83 45); - --ds-green-300: rgb(22 101 52); - --ds-green-400: rgb(21 128 61); - --ds-green-500: rgb(22 163 74); - --ds-green-600: rgb(34 197 94); - --ds-green-700: rgb(74 222 128); - --ds-green-800: rgb(134 239 172); - --ds-green-900: rgb(187 247 208); - --ds-green-1000: rgb(220 252 231); - - /* Teal - Dark Mode */ - --ds-teal-100: rgb(4 47 46); - --ds-teal-200: rgb(19 78 74); - --ds-teal-300: rgb(17 94 89); - --ds-teal-400: rgb(15 118 110); - --ds-teal-500: rgb(13 148 136); - --ds-teal-600: rgb(20 184 166); - --ds-teal-700: rgb(45 212 191); - --ds-teal-800: rgb(94 234 212); - --ds-teal-900: rgb(153 246 228); - --ds-teal-1000: rgb(204 251 241); - - /* Purple - Dark Mode */ - --ds-purple-100: rgb(59 7 100); - --ds-purple-200: rgb(88 28 135); - --ds-purple-300: rgb(107 33 168); - --ds-purple-400: rgb(126 34 206); - --ds-purple-500: rgb(147 51 234); - --ds-purple-600: rgb(168 85 247); - --ds-purple-700: rgb(192 132 252); - --ds-purple-800: rgb(216 180 254); - --ds-purple-900: rgb(233 213 255); - --ds-purple-1000: rgb(243 232 255); - - /* Pink - Dark Mode */ - --ds-pink-100: rgb(80 7 36); - --ds-pink-200: rgb(131 24 67); - --ds-pink-300: rgb(157 23 77); - --ds-pink-400: rgb(190 24 93); - --ds-pink-500: rgb(219 39 119); - --ds-pink-600: rgb(236 72 153); - --ds-pink-700: rgb(244 114 182); - --ds-pink-800: rgb(249 168 212); - --ds-pink-900: rgb(251 207 232); - --ds-pink-1000: rgb(252 231 243); - - /* Shadows - Dark Mode */ - --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); - --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); - --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); - } -} - -/* Dark mode: applies when .dark class is present (overrides system preference) */ .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); /* Geist Design System Colors - Dark Mode */ /* Backgrounds */ @@ -324,17 +223,6 @@ --ds-gray-900: rgb(245 245 245); --ds-gray-1000: rgb(255 255 255); - /* Gray Alpha (with transparency) */ - --ds-gray-alpha-100: rgb(255 255 255 / 0.02); - --ds-gray-alpha-200: rgb(255 255 255 / 0.04); - --ds-gray-alpha-300: rgb(255 255 255 / 0.08); - --ds-gray-alpha-400: rgb(255 255 255 / 0.12); - --ds-gray-alpha-500: rgb(255 255 255 / 0.3); - --ds-gray-alpha-600: rgb(255 255 255 / 0.5); - --ds-gray-alpha-700: rgb(255 255 255 / 0.6); - --ds-gray-alpha-800: rgb(255 255 255 / 0.7); - --ds-gray-alpha-900: rgb(255 255 255 / 0.9); - /* Blue - Dark Mode */ --ds-blue-100: rgb(8 47 73); --ds-blue-200: rgb(12 74 110); @@ -383,18 +271,6 @@ --ds-green-900: rgb(187 247 208); --ds-green-1000: rgb(220 252 231); - /* Teal - Dark Mode */ - --ds-teal-100: rgb(4 47 46); - --ds-teal-200: rgb(19 78 74); - --ds-teal-300: rgb(17 94 89); - --ds-teal-400: rgb(15 118 110); - --ds-teal-500: rgb(13 148 136); - --ds-teal-600: rgb(20 184 166); - --ds-teal-700: rgb(45 212 191); - --ds-teal-800: rgb(94 234 212); - --ds-teal-900: rgb(153 246 228); - --ds-teal-1000: rgb(204 251 241); - /* Purple - Dark Mode */ --ds-purple-100: rgb(59 7 100); --ds-purple-200: rgb(88 28 135); @@ -407,17 +283,17 @@ --ds-purple-900: rgb(233 213 255); --ds-purple-1000: rgb(243 232 255); - /* Pink - Dark Mode */ - --ds-pink-100: rgb(80 7 36); - --ds-pink-200: rgb(131 24 67); - --ds-pink-300: rgb(157 23 77); - --ds-pink-400: rgb(190 24 93); - --ds-pink-500: rgb(219 39 119); - --ds-pink-600: rgb(236 72 153); - --ds-pink-700: rgb(244 114 182); - --ds-pink-800: rgb(249 168 212); - --ds-pink-900: rgb(251 207 232); - --ds-pink-1000: rgb(252 231 243); + /* Teal - Dark Mode */ + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); /* Shadows - Dark Mode */ --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); @@ -425,41 +301,137 @@ --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); } -@theme { - /* Allow Tailwind's default color palette while also defining custom colors */ - --color-background: hsl(var(--background)); - --color-foreground: hsl(var(--foreground)); - --color-card: hsl(var(--card)); - --color-card-foreground: hsl(var(--card-foreground)); - --color-popover: hsl(var(--popover)); - --color-popover-foreground: hsl(var(--popover-foreground)); - --color-primary: hsl(var(--primary)); - --color-primary-foreground: hsl(var(--primary-foreground)); - --color-secondary: hsl(var(--secondary)); - --color-secondary-foreground: hsl(var(--secondary-foreground)); - --color-muted: hsl(var(--muted)); - --color-muted-foreground: hsl(var(--muted-foreground)); - --color-accent: hsl(var(--accent)); - --color-accent-foreground: hsl(var(--accent-foreground)); - --color-destructive: hsl(var(--destructive)); - --color-destructive-foreground: hsl(var(--destructive-foreground)); - --color-border: hsl(var(--border)); - --color-input: hsl(var(--input)); - --color-ring: hsl(var(--ring)); - - --radius-sm: 0.375rem; - --radius: calc(var(--radius)); - --radius-md: calc(var(--radius)); - --radius-lg: 0.75rem; - --radius-xl: 1rem; +/* System dark mode preference (when no explicit light/dark class is set) */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); + + /* Geist Design System Colors - Dark Mode (same as .dark) */ + --ds-background-100: rgb(0 0 0); + --ds-background-200: rgb(10 10 10); + --ds-gray-100: rgb(17 17 17); + --ds-gray-200: rgb(23 23 23); + --ds-gray-300: rgb(41 41 41); + --ds-gray-400: rgb(64 64 64); + --ds-gray-500: rgb(115 115 115); + --ds-gray-600: rgb(163 163 163); + --ds-gray-700: rgb(212 212 212); + --ds-gray-800: rgb(229 229 229); + --ds-gray-900: rgb(245 245 245); + --ds-gray-1000: rgb(255 255 255); + --ds-blue-100: rgb(8 47 73); + --ds-blue-200: rgb(12 74 110); + --ds-blue-300: rgb(7 89 133); + --ds-blue-400: rgb(3 105 161); + --ds-blue-500: rgb(2 132 199); + --ds-blue-600: rgb(14 165 233); + --ds-blue-700: rgb(56 189 248); + --ds-blue-800: rgb(125 211 252); + --ds-blue-900: rgb(186 230 253); + --ds-blue-1000: rgb(224 242 254); + --ds-red-100: rgb(69 10 10); + --ds-red-200: rgb(127 29 29); + --ds-red-300: rgb(153 27 27); + --ds-red-400: rgb(185 28 28); + --ds-red-500: rgb(220 38 38); + --ds-red-600: rgb(239 68 68); + --ds-red-700: rgb(248 113 113); + --ds-red-800: rgb(252 165 165); + --ds-red-900: rgb(254 202 202); + --ds-red-1000: rgb(254 226 226); + --ds-amber-100: rgb(69 26 3); + --ds-amber-200: rgb(120 53 15); + --ds-amber-300: rgb(146 64 14); + --ds-amber-400: rgb(180 83 9); + --ds-amber-500: rgb(217 119 6); + --ds-amber-600: rgb(245 158 11); + --ds-amber-700: rgb(251 191 36); + --ds-amber-800: rgb(252 211 77); + --ds-amber-900: rgb(253 230 138); + --ds-amber-1000: rgb(254 243 199); + --ds-green-100: rgb(5 46 22); + --ds-green-200: rgb(20 83 45); + --ds-green-300: rgb(22 101 52); + --ds-green-400: rgb(21 128 61); + --ds-green-500: rgb(22 163 74); + --ds-green-600: rgb(34 197 94); + --ds-green-700: rgb(74 222 128); + --ds-green-800: rgb(134 239 172); + --ds-green-900: rgb(187 247 208); + --ds-green-1000: rgb(220 252 231); + --ds-purple-100: rgb(59 7 100); + --ds-purple-200: rgb(88 28 135); + --ds-purple-300: rgb(107 33 168); + --ds-purple-400: rgb(126 34 206); + --ds-purple-500: rgb(147 51 234); + --ds-purple-600: rgb(168 85 247); + --ds-purple-700: rgb(192 132 252); + --ds-purple-800: rgb(216 180 254); + --ds-purple-900: rgb(233 213 255); + --ds-purple-1000: rgb(243 232 255); + --ds-teal-100: rgb(4 47 46); + --ds-teal-200: rgb(19 78 74); + --ds-teal-300: rgb(17 94 89); + --ds-teal-400: rgb(15 118 110); + --ds-teal-500: rgb(13 148 136); + --ds-teal-600: rgb(20 184 166); + --ds-teal-700: rgb(45 212 191); + --ds-teal-800: rgb(94 234 212); + --ds-teal-900: rgb(153 246 228); + --ds-teal-1000: rgb(204 251 241); + --ds-shadow-small: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 2px 4px rgba(0, 0, 0, 0.3), 0 12px 24px rgba(0, 0, 0, 0.3); + --ds-shadow-medium: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 4px 8px rgba(0, 0, 0, 0.4), 0 24px 48px rgba(0, 0, 0, 0.4); + --ds-shadow-large: 0 0 0 1px rgba(255, 255, 255, 0.07), 0 8px 16px rgba(0, 0, 0, 0.5), 0 48px 96px rgba(0, 0, 0, 0.5); + } } -* { - border-color: hsl(var(--border)); +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground overscroll-none; + } + html { + @apply overscroll-none; + } } -body { - background: hsl(var(--background)); - color: hsl(var(--foreground)); - font-feature-settings: "rlig" 1, "calt" 1; -} +@keyframes gradient { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } +} \ No newline at end of file diff --git a/packages/web/src/app/layout-client.tsx b/packages/web/src/app/layout-client.tsx index 451d54745..286923356 100644 --- a/packages/web/src/app/layout-client.tsx +++ b/packages/web/src/app/layout-client.tsx @@ -2,11 +2,11 @@ import { TooltipProvider } from '@radix-ui/react-tooltip'; import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -import { ThemeProvider } from 'next-themes'; -import { useEffect } from 'react'; +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 { SettingsSidebar } from '@/components/settings-sidebar'; +import { SettingsDropdown } from '@/components/settings-dropdown'; import { Toaster } from '@/components/ui/sonner'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; import { Logo } from '../icons/logo'; @@ -15,33 +15,49 @@ interface LayoutClientProps { children: React.ReactNode; } -export function LayoutClient({ children }: LayoutClientProps) { +function LayoutContent({ children }: LayoutClientProps) { const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const config = useQueryParamConfig(); + 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 theme = searchParams.get('theme') || 'system'; - - // Apply theme class to document - useEffect(() => { - const html = document.documentElement; + const themeParam = searchParams.get('theme'); - // Remove existing theme classes - html.classList.remove('light', 'dark'); + // Track if we've already handled the initial navigation + const hasNavigatedRef = useRef(false); - // Apply theme class (system will use CSS media query) - if (theme === 'light' || theme === 'dark') { - html.classList.add(theme); + // Sync theme from URL param to next-themes (one-time or when explicitly changed) + useEffect(() => { + if ( + themeParam && + (themeParam === 'light' || + themeParam === 'dark' || + themeParam === 'system') + ) { + setTheme(themeParam); } - }, [theme]); + }, [themeParam, setTheme]); // If initialized with a resource/id or direct ID params, we navigate to the appropriate page + // Only run this logic once on mount or when we're on the root path with special params useEffect(() => { + // Skip if we're not on the root path and we've already navigated + if (pathname !== '/' && hasNavigatedRef.current) { + return; + } + + // Skip if we're already on a run page (prevents interference with back navigation) + if (pathname.startsWith('/run/')) { + hasNavigatedRef.current = true; + return; + } + // Handle direct ID parameters (runId, stepId, hookId) without resource if (!resource) { if (runId) { @@ -63,6 +79,7 @@ export function LayoutClient({ children }: LayoutClientProps) { // Just open the run targetUrl = buildUrlWithConfig(`/run/${runId}`, config); } + hasNavigatedRef.current = true; router.push(targetUrl); return; } @@ -109,41 +126,49 @@ export function LayoutClient({ children }: LayoutClientProps) { return; } + hasNavigatedRef.current = true; router.push(targetUrl); - }, [resource, id, runId, stepId, hookId, router, config]); + }, [resource, id, runId, stepId, hookId, router, config, pathname]); + return ( +
+ + {/* Sticky Header */} +
+
+ +

+ +

+ +
+ + +
+
+
+ + {/* Scrollable Content */} +
{children}
+
+ +
+ ); +} + +export function LayoutClient({ children }: LayoutClientProps) { return ( -
- -
-
-
- -

- -

- -
- - -
-
-
- - {children} -
-
- -
+ {children}
); } diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 9f31ae308..fe198b9e1 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import { connection } from 'next/server'; +import { NuqsAdapter } from 'nuqs/adapters/next/app'; import { LayoutClient } from './layout-client'; const geistSans = Geist({ @@ -34,7 +35,9 @@ export default async function RootLayout({ - {children} + + {children} + ); diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx index 088d59ca2..bd454f598 100644 --- a/packages/web/src/app/page.tsx +++ b/packages/web/src/app/page.tsx @@ -1,20 +1,38 @@ 'use client'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { AlertCircle } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { ErrorBoundary } from '@/components/error-boundary'; import { HooksTable } from '@/components/hooks-table'; import { RunsTable } from '@/components/runs-table'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { WorkflowsList } from '@/components/workflows-list'; import { buildUrlWithConfig, useQueryParamConfig } from '@/lib/config'; +import { + useHookIdState, + useSidebarState, + useTabState, + useWorkflowIdState, +} from '@/lib/url-state'; +import { useWorkflowGraphManifest } from '@/lib/use-workflow-graph'; export default function Home() { const router = useRouter(); - const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const [sidebar] = useSidebarState(); + const [hookId] = useHookIdState(); + const [tab, setTab] = useTabState(); - const sidebar = searchParams.get('sidebar'); - const hookId = searchParams.get('hookId') || searchParams.get('hook'); const selectedHookId = sidebar === 'hook' && hookId ? hookId : undefined; + // Fetch workflow graph manifest + const { + manifest: graphManifest, + loading: graphLoading, + error: graphError, + } = useWorkflowGraphManifest(config); + const handleRunClick = (runId: string, streamId?: string) => { if (!streamId) { router.push(buildUrlWithConfig(`/run/${runId}`, config)); @@ -38,25 +56,58 @@ export default function Home() { } }; + const workflows = graphManifest ? Object.values(graphManifest.workflows) : []; + return ( -
- - - - - - - +
+ + + Runs + Hooks + Workflows + + + + + + + + + + + + + +
+ {graphError && ( + + + Error Loading Workflows + {graphError.message} + + )} + {}} + loading={graphLoading} + /> +
+
+
+
); } diff --git a/packages/web/src/app/run/[runId]/page.tsx b/packages/web/src/app/run/[runId]/page.tsx index 803589fb8..debf597d3 100644 --- a/packages/web/src/app/run/[runId]/page.tsx +++ b/packages/web/src/app/run/[runId]/page.tsx @@ -1,26 +1,24 @@ 'use client'; -import { useParams, useSearchParams } from 'next/navigation'; +import { useParams } from 'next/navigation'; import { ErrorBoundary } from '@/components/error-boundary'; import { RunDetailView } from '@/components/run-detail-view'; import { useQueryParamConfig } from '@/lib/config'; +import { + useEventIdState, + useHookIdState, + useStepIdState, +} from '@/lib/url-state'; export default function RunDetailPage() { const params = useParams(); - const searchParams = useSearchParams(); const config = useQueryParamConfig(); + const [stepId] = useStepIdState(); + const [eventId] = useEventIdState(); + const [hookId] = useHookIdState(); const runId = params.runId as string; - const stepId = searchParams.get('stepId') || searchParams.get('step'); - const eventId = searchParams.get('eventId') || searchParams.get('event'); - const hookId = searchParams.get('hookId') || searchParams.get('hook'); - const selectedId = stepId - ? stepId - : eventId - ? eventId - : hookId - ? hookId - : undefined; + const selectedId = stepId || eventId || hookId || undefined; return ( - - {label} - - ); -} diff --git a/packages/web/src/components/display-utils/copyable-text.tsx b/packages/web/src/components/display-utils/copyable-text.tsx index ea03022da..609d23c66 100644 --- a/packages/web/src/components/display-utils/copyable-text.tsx +++ b/packages/web/src/components/display-utils/copyable-text.tsx @@ -13,12 +13,20 @@ interface CopyableTextProps { text: string; children: React.ReactNode; className?: string; + /** If true, the copy button overlaps the text on the right */ + overlay?: boolean; } -export function CopyableText({ text, children, className }: CopyableTextProps) { +export function CopyableText({ + text, + children, + className, + overlay, +}: CopyableTextProps) { const [copied, setCopied] = useState(false); - const handleCopy = async () => { + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); try { await navigator.clipboard.writeText(text); setCopied(true); @@ -28,6 +36,33 @@ export function CopyableText({ text, children, className }: CopyableTextProps) { } }; + if (overlay) { + return ( + + {children} + + + + + +

{copied ? 'Copied!' : 'Copy to clipboard'}

+
+
+
+ ); + } + return (
{children} diff --git a/packages/web/src/components/display-utils/status-badge.tsx b/packages/web/src/components/display-utils/status-badge.tsx index 09fe7fe99..845e4e293 100644 --- a/packages/web/src/components/display-utils/status-badge.tsx +++ b/packages/web/src/components/display-utils/status-badge.tsx @@ -1,64 +1,142 @@ 'use client'; import type { Step, WorkflowRun } from '@workflow/world'; +import { Check, Copy } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; interface StatusBadgeProps { status: WorkflowRun['status'] | Step['status']; context?: { error?: unknown }; className?: string; + /** Duration in milliseconds to display below status */ + durationMs?: number; } -export function StatusBadge({ status, context, className }: StatusBadgeProps) { - const getStatusClasses = () => { +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) { + return remainingSeconds > 0 + ? `${minutes}m ${remainingSeconds}s` + : `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +export function StatusBadge({ + status, + context, + className, + durationMs, +}: StatusBadgeProps) { + const getCircleColor = () => { switch (status) { case 'running': - return 'text-blue-600 dark:text-blue-400'; + return 'bg-blue-500'; case 'completed': - return 'text-green-600 dark:text-green-400'; + return 'bg-emerald-500'; case 'failed': - return 'text-red-600 dark:text-red-400'; + return 'bg-red-500'; case 'cancelled': - return 'text-yellow-600 dark:text-yellow-400'; + return 'bg-yellow-500'; case 'pending': - return 'text-gray-600 dark:text-gray-400'; + return 'bg-gray-400'; case 'paused': - return 'text-orange-600 dark:text-orange-400'; + return 'bg-orange-500'; default: - return 'text-gray-500 dark:text-gray-400'; + return 'bg-gray-400'; } }; + const content = ( + + + + + {status} + + + {durationMs !== undefined && ( + + {formatDuration(durationMs)} + + )} + + ); + // Show error tooltip if status is failed and error exists if (status === 'failed' && context?.error) { - const errorMessage = - typeof context.error === 'string' - ? context.error - : context.error instanceof Error - ? context.error.message - : JSON.stringify(context.error); + return ; + } + + return content; +} + +function ErrorStatusBadge({ + content, + error, +}: { + content: React.ReactNode; + error: unknown; +}) { + const [copied, setCopied] = useState(false); - return ( - - - { + e.stopPropagation(); + await navigator.clipboard.writeText(errorMessage); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( + + + {content} + + +
+ Error Details + +
+
+

{errorMessage}

- - - ); - } - - return {status}; +
+
+
+ ); } diff --git a/packages/web/src/components/display-utils/table-skeleton.tsx b/packages/web/src/components/display-utils/table-skeleton.tsx index 183c41562..fd6268f00 100644 --- a/packages/web/src/components/display-utils/table-skeleton.tsx +++ b/packages/web/src/components/display-utils/table-skeleton.tsx @@ -1,36 +1,113 @@ 'use client'; +import { Card, CardContent } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; import { DEFAULT_PAGE_SIZE } from '@/lib/utils'; interface TableSkeletonProps { - title?: string; rows?: number; - bodyOnly?: boolean; + variant?: 'runs' | 'hooks' | 'workflows'; } export function TableSkeleton({ rows = DEFAULT_PAGE_SIZE, + variant = 'runs', }: TableSkeletonProps) { - return ( -
-
- -
- {Array.from({ length: rows }, (_, i) => ( -
- - - - - + const renderRow = (i: number) => { + switch (variant) { + case 'runs': + // Workflow, Run ID, Status (with duration), Started, Completed, Actions + return ( +
+ + +
+ + +
+ + + +
+ ); + case 'hooks': + // Hook ID, Run ID, Token, Created, Invocations, Actions + return ( +
+ + + + + + +
+ ); + case 'workflows': + // Workflow, File, Steps + return ( +
+ + + +
+ ); + default: + return null; + } + }; + + const renderHeader = () => { + switch (variant) { + case 'runs': + return ( +
+ + + + + +
- ))} -
- - -
-
-
+ ); + case 'hooks': + return ( +
+ + + + + +
+
+ ); + case 'workflows': + return ( +
+ + + +
+ ); + default: + return null; + } + }; + + return ( + + + {renderHeader()} + {Array.from({ length: rows }, (_, i) => renderRow(i))} + + ); } diff --git a/packages/web/src/components/hooks-table.tsx b/packages/web/src/components/hooks-table.tsx index dc8b228c7..6c9761fb4 100644 --- a/packages/web/src/components/hooks-table.tsx +++ b/packages/web/src/components/hooks-table.tsx @@ -1,18 +1,34 @@ 'use client'; -import { getErrorMessage, useWorkflowHooks } from '@workflow/web-shared'; -import { fetchEventsByCorrelationId } from '@workflow/web-shared/server'; +import { + cancelRun, + fetchEventsByCorrelationId, + getErrorMessage, + recreateRun, + useWorkflowHooks, +} from '@workflow/web-shared'; import type { Event, Hook } from '@workflow/world'; import { AlertCircle, ChevronLeft, ChevronRight, + MoreHorizontal, RefreshCw, + RotateCw, + XCircle, } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; +import { toast } from 'sonner'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; import { DocsLink } from '@/components/ui/docs-link'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Table, TableBody, @@ -28,6 +44,7 @@ import { } from '@/components/ui/tooltip'; import { worldConfigToEnvMap } from '@/lib/config'; import type { WorldConfig } from '@/lib/config-world'; +import { CopyableText } from './display-utils/copyable-text'; import { RelativeTime } from './display-utils/relative-time'; import { TableSkeleton } from './display-utils/table-skeleton'; @@ -209,11 +226,9 @@ export function HooksTable({ return (
-
-

- Hooks -

-
+
+
+

Last refreshed

{lastRefreshTime && ( )} +
+
+ + + {sortOrder === 'desc' + ? 'Showing newest first' + : 'Showing oldest first'} + + + + + + + Note that this resets pages + +
+
+ ); +} + /** * RunsTable - Displays workflow runs with server-side pagination. * Uses the PaginatingTable pattern: fetches data for each page as needed from the server. * The table and fetching behavior are intertwined - pagination controls trigger new API calls. */ export function RunsTable({ config, onRunClick }: RunsTableProps) { - const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); + const handleWorkflowFilter = useWorkflowFilter(); + const handleStatusFilter = useStatusFilter(); + // Validate status parameter - only allow known valid statuses or 'all' const rawStatus = searchParams.get('status'); const validStatuses = Object.keys(statusMap) as WorkflowRunStatus[]; @@ -82,7 +282,7 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { // TODO: World-vercel doesn't support filtering by status without a workflow name filter const statusFilterRequiresWorkflowNameFilter = - config.backend?.includes('vercel'); + config.backend?.includes('vercel') || false; // TODO: This is a workaround. We should be getting a list of valid workflow names // from the manifest, which we need to put on the World interface. const [seenWorkflowNames, setSeenWorkflowNames] = useState>( @@ -129,154 +329,23 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc')); }; - const createQueryString = useCallback( - (name: string, value: string) => { - const params = new URLSearchParams(searchParams.toString()); - params.set(name, value); - - return params.toString(); - }, - [searchParams] - ); - return (
-
-

- Runs - {lastRefreshTime && ( - - )} -

-
- { - <> - - - -
- -
-
- - {statusFilterRequiresWorkflowNameFilter && - workflowNameFilter === 'all' - ? 'Select a workflow first to filter by status' - : 'Filter runs by status'} - -
- - - - - - {sortOrder === 'desc' - ? 'Showing newest first' - : 'Showing oldest first'} - - - - - - - Note that this resets pages - - - } -
-
+ {error ? ( @@ -287,57 +356,157 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { ) : !loading && (!data.data || data.data.length === 0) ? (
- No workflow runs found.{' '} + No workflow runs found.
Learn how to create a workflow
) : ( <> - - - - Workflow - Run ID - Status - Started - Completed - - - - {data.data?.map((run) => ( - onRunClick(run.runId)} - > - - {parseWorkflowName(run.workflowName)?.shortName || '?'} - - - {run.runId} - - - - - - {run.startedAt ? ( - - ) : ( - '-' - )} - - - {run.completedAt ? ( - - ) : ( - '-' - )} - - - ))} - -
+ + + + + + + Workflow + + + Run ID + + + Status + + + Started + + + Completed + + + + + + {data.data?.map((run) => ( + onRunClick(run.runId)} + > + + + {parseWorkflowName(run.workflowName)?.shortName || + '?'} + + + + + {run.runId} + + + + + + + {run.startedAt ? ( + + ) : ( + '-' + )} + + + {run.completedAt ? ( + + ) : ( + '-' + )} + + + + + + + + { + e.stopPropagation(); + try { + const newRunId = await recreateRun( + env, + run.runId + ); + toast.success('New run started', { + description: `Run ID: ${newRunId}`, + }); + reload(); + } catch (err) { + toast.error('Failed to re-run', { + description: + err instanceof Error + ? err.message + : 'Unknown error', + }); + } + }} + > + + Re-run + + { + e.stopPropagation(); + if (run.status !== 'pending') { + toast.error('Cannot cancel', { + description: + 'Only pending runs can be cancelled', + }); + return; + } + try { + await cancelRun(env, run.runId); + toast.success('Run cancelled'); + reload(); + } catch (err) { + toast.error('Failed to cancel', { + description: + err instanceof Error + ? err.message + : 'Unknown error', + }); + } + }} + disabled={run.status !== 'pending'} + > + + Cancel + + + + + + ))} + +
+
+
{pageInfo}
diff --git a/packages/web/src/components/settings-dropdown.tsx b/packages/web/src/components/settings-dropdown.tsx new file mode 100644 index 000000000..e587c2d2a --- /dev/null +++ b/packages/web/src/components/settings-dropdown.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { ExternalLink, Monitor, Moon, Settings, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { SegmentedControl } from '@/components/ui/segmented-control'; +import { SettingsSidebar } from './settings-sidebar'; + +// Controlled version that doesn't show the trigger button +function SettingsSidebarDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ; +} + +export function SettingsDropdown() { + const [settingsOpen, setSettingsOpen] = useState(false); + const { theme, setTheme } = useTheme(); + const currentTheme = theme || 'system'; + + return ( + <> + + + + + +
+ Theme + , + }, + { + value: 'light', + icon: , + }, + { + value: 'dark', + icon: , + }, + ]} + /> +
+ + setSettingsOpen(true)}> + Configuration + + + + + Docs + + + +
+
+ + + ); +} diff --git a/packages/web/src/components/settings-sidebar.tsx b/packages/web/src/components/settings-sidebar.tsx index f5affa3ba..a6363fa09 100644 --- a/packages/web/src/components/settings-sidebar.tsx +++ b/packages/web/src/components/settings-sidebar.tsx @@ -21,11 +21,21 @@ import { type WorldConfig, } from '@/lib/config-world'; -export function SettingsSidebar() { +interface SettingsSidebarProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function SettingsSidebar({ + open: controlledOpen, + onOpenChange, +}: SettingsSidebarProps = {}) { const config = useQueryParamConfig(); const updateConfig = useUpdateConfigQueryParams(); - const [isOpen, setIsOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setIsOpen = onOpenChange || setInternalOpen; const [localConfig, setLocalConfig] = useState(config); const [errors, setErrors] = useState([]); const [isValidating, setIsValidating] = useState(false); @@ -76,14 +86,16 @@ export function SettingsSidebar() { return ( <> - + {controlledOpen === undefined && ( + + )} {isOpen && ( <> {/* Backdrop */} @@ -99,13 +111,14 @@ export function SettingsSidebar() {

Configuration

- +
@@ -143,7 +156,7 @@ export function SettingsSidebar() { } /> {getFieldError('port') && ( -

+

{getFieldError('port')}

)} @@ -162,11 +175,6 @@ export function SettingsSidebar() { getFieldError('dataDir') ? 'border-destructive' : '' } /> - {getFieldError('dataDir') && ( -

- {getFieldError('dataDir')} -

- )}

Path to the workflow data directory. Can be relative or absolute. @@ -229,13 +237,16 @@ export function SettingsSidebar() { )} {errors.length > 0 && ( - + Configuration Error

    {errors.map((error, idx) => ( -
  • +
  • {error.field !== 'general' && ( {error.field}: )}{' '} diff --git a/packages/web/src/components/ui/badge.tsx b/packages/web/src/components/ui/badge.tsx new file mode 100644 index 000000000..797ce1561 --- /dev/null +++ b/packages/web/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
    + ); +} + +export { Badge, badgeVariants }; diff --git a/packages/web/src/components/ui/breadcrumb.tsx b/packages/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..c9a42cd17 --- /dev/null +++ b/packages/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,111 @@ +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return