diff --git a/.claude/skills/performance-review/SKILL.md b/.claude/skills/performance-review/SKILL.md new file mode 100644 index 00000000..fd78dc2d --- /dev/null +++ b/.claude/skills/performance-review/SKILL.md @@ -0,0 +1,91 @@ +--- +name: performance-review +description: Review frontend code changes for performance anti-patterns and guideline compliance. Checks query hooks, stale times, bundle impact, rendering patterns, and Zustand selectors. +user_invocable: true +--- + +# Frontend Performance Review + +**Reference:** `frontend/docs/performance.md` + +--- + +## When to Invoke + +- Before submitting a PR that adds or modifies query hooks, page components, Zustand stores, or real-time features +- When the user asks for a "performance review" or "perf check" +- As part of the component-development workflow for new pages + +## Agent Instructions + +When invoked, perform the following checks in order. Report PASS/FAIL/WARN for each. Group failures by severity: **CRITICAL** (must fix before merge), **WARNING** (should fix), **INFO** (consider). + +### Step 1: Identify Scope + +Read the diff or the files specified by the user. If no files specified, use `git diff --name-only` to find changed frontend files. Identify which categories apply: query hooks, page routes, stores, real-time, bundle. + +### Step 2: Query Hook Checks + +1. Search for `useState` + `useEffect` + `api.` within 20 lines of each other in changed files. If found: **CRITICAL** — should be a TanStack Query hook. +2. Check that new `useQuery` calls reference a key from `queryKeys.ts`, not an inline array. If inline: **CRITICAL**. +3. Check that new query hook files are named `useQueries.ts` and placed in `src/hooks/queries/`. If misplaced: **WARNING**. +4. Check that `useMutation` calls include `onSuccess` with `queryClient.invalidateQueries()`. If missing: **WARNING**. +5. Check for `skipToken` usage on conditional queries vs. `enabled: false`. Flag `enabled: false` without `skipToken` as **WARNING**. + +### Step 3: Stale Time Audit + +1. For each new `useQuery`, check if `staleTime` is set explicitly or inherits the 30s default appropriately. +2. Cross-reference the data type: if the query fetches components, templates, or providers — flag missing `staleTime: Infinity` as **CRITICAL**. +3. For execution queries, verify they use `executionQueryOptions.ts` factories rather than inline values. Flag inconsistency as **WARNING**. +4. For queries on terminal runs, verify `terminalStaleTime()` is used rather than a hardcoded number. + +### Step 4: Bundle Impact + +1. For each new page added to `App.tsx`: verify it uses `React.lazy(() => import(...))`. Static imports are **CRITICAL**. +2. Verify new sidebar pages are added to `routePrefetchMap` in `src/lib/prefetch-routes.ts`. Missing entry is **WARNING**. +3. If a new conditionally-visible component imports a library > 100KB (check for `@xterm`, `posthog-js`, `lucide-react` barrel imports): flag as **WARNING** — consider deferred load pattern. + +### Step 5: Rendering Checks + +1. Search for `const [*, set*] = useState` followed by `set*` inside a `useEffect` that references query data — **CRITICAL** (should use `useMemo`). +2. Look for derived data calculations inline in JSX without `useMemo` involving array operations (filter, sort, reduce) — **WARNING**. +3. Check for `React.memo` usage on new components in `timeline/` or `workflow/` directories that re-render frequently — **INFO** if missing. + +### Step 6: Zustand Store Checks + +1. Search for `const store = useStore()` or destructuring from `useStore()` without a selector — full store subscription. **WARNING**. +2. For new stores using `persist`, verify `partialize` is present. Missing is **WARNING**. +3. For new `persist` stores, verify action functions are excluded from `partialize`. + +### Step 7: Generate Report + +Format the output as follows: + +``` +## Performance Review: [description of scope] + +### Critical (must fix before merge) +- [file:line] Description of issue. See: performance.md § [section] + +### Warnings (should fix) +- [file:line] Description of issue. See: performance.md § [section] + +### Info (consider) +- [file:line] Description. See: performance.md § [section] + +### Passed Checks +- [n] query hooks using TanStack Query correctly +- [n] page routes using React.lazy +- [n] Zustand selectors properly scoped +- staleTime: appropriate tiers applied + +### Summary +[1-2 sentences on overall performance hygiene of the change] +``` + +## Rules + +- This is a **review only** — do NOT make code changes unless the user explicitly asks +- Always cite the file and line number for each finding +- Always reference `frontend/docs/performance.md` for the relevant pattern section +- If no frontend files are in the diff, report "No frontend changes detected" and exit diff --git a/.claude/skills/load-audit/SKILL.md b/.claude/skills/stress-test-frontend/SKILL.md similarity index 95% rename from .claude/skills/load-audit/SKILL.md rename to .claude/skills/stress-test-frontend/SKILL.md index 01b60f27..8b42cbfa 100644 --- a/.claude/skills/load-audit/SKILL.md +++ b/.claude/skills/stress-test-frontend/SKILL.md @@ -1,5 +1,5 @@ --- -name: load-audit +name: stress-test-frontend description: Run a frontend load testing audit. Seeds data, tests all pages via Chrome DevTools MCP, records network calls, TanStack queries, DOM sizes, and generates a timestamped report. user_invocable: true --- @@ -13,7 +13,7 @@ user_invocable: true ## Agent Instructions -When the user invokes `/load-audit`, perform a full frontend load testing audit: +When the user invokes `/stress-test-frontend`, perform a full frontend load testing audit: ### 1. Setup diff --git a/AGENTS.md b/AGENTS.md index 4363dbc1..efcfeec4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,35 @@ bun --cwd backend run db:studio # View data 4. **E2E Tests**: Mandatory for significant features. Place in `e2e-tests/` folder. 5. **GitHub CLI**: Use `gh` for all GitHub operations (issues, PRs, actions, releases). Never use browser automation for GitHub tasks. +### Frontend: Read Before Writing Code + +Before writing ANY frontend code that fetches data or adds a page, you MUST read these files first: + +1. `frontend/docs/state.md` — Decision guide: TanStack Query vs Zustand, hook patterns, anti-patterns +2. `frontend/docs/performance.md` — Stale time tiers, bundle splitting, prefetch patterns, query key architecture +3. `frontend/src/lib/queryKeys.ts` — Existing query key factories (add new keys here, never inline) +4. Browse `frontend/src/hooks/queries/` — Follow existing hook naming conventions (`useQueries.ts`) + +### Frontend Data Fetching (Mandatory) + +6. **All API data must use TanStack Query hooks** in `frontend/src/hooks/queries/`. Never use `useState` + `useEffect` to fetch backend data — this is the single most important frontend rule. +7. **Query keys** go in `frontend/src/lib/queryKeys.ts` (org-scoped, factory functions). +8. **After mutations**, invalidate the relevant query cache via `queryClient.invalidateQueries()` — do not manually update local state. +9. **Derive data** from query results using `useMemo`, not by copying into separate `useState`. +10. **Zustand stores** are for client-only UI state (canvas, timeline, auth). Never store API data in Zustand. + +See `frontend/docs/state.md` for patterns, anti-patterns, and the full decision guide. + +### Frontend Performance (Mandatory) + +See `frontend/docs/performance.md` for the complete reference with code examples. + +11. **Every new page must use `React.lazy()`** in `App.tsx`. Add the route to `routePrefetchMap` in `src/lib/prefetch-routes.ts`. +12. **Set `staleTime: Infinity` for static/reference data** (components, templates, providers). The 30s default is wrong for them. +13. **Use `skipToken` for conditional queries** instead of `enabled: false` alone. See `useRunQueries.ts`. +14. **Granular Zustand selectors**: `useStore((s) => s.field)`, never `const store = useStore()`. +15. **No N+1 queries**: never call a query hook inside `.map()`. Use a batched endpoint (see `useMcpGroupsWithServers`). + --- ## Architecture @@ -140,6 +169,16 @@ When tasks match a skill, load it: `cat .claude/skills//SKILL.md` Creating components (inline/docker). Dynamic ports, retry policies, PTY patterns, IsolatedContainerVolume. project + +performance-review +Review code changes for frontend performance anti-patterns. Checks stale times, bundle splitting, Zustand selectors, N+1 queries, and React rendering. +project + + +stress-test-frontend +Run a frontend load testing audit. Seeds data, tests all pages via Chrome DevTools MCP, records network calls, TanStack queries, DOM sizes, and generates a timestamped report. +project + diff --git a/backend/src/mcp-groups/mcp-groups.controller.ts b/backend/src/mcp-groups/mcp-groups.controller.ts index 63bc1f6d..2af6e26d 100644 --- a/backend/src/mcp-groups/mcp-groups.controller.ts +++ b/backend/src/mcp-groups/mcp-groups.controller.ts @@ -45,9 +45,16 @@ export class McpGroupsController { @Get() @ApiOperation({ summary: 'List all MCP groups' }) @ApiQuery({ name: 'enabled', required: false, type: Boolean }) + @ApiQuery({ name: 'includeServers', required: false, type: Boolean }) @ApiOkResponse({ type: [McpGroupResponse] }) - async listGroups(@Query('enabled') enabled?: string): Promise { + async listGroups( + @Query('enabled') enabled?: string, + @Query('includeServers') includeServers?: string, + ): Promise { const enabledOnly = enabled === 'true'; + if (includeServers === 'true') { + return this.mcpGroupsService.listGroupsWithServers(enabledOnly); + } return this.mcpGroupsService.listGroups(enabledOnly); } diff --git a/backend/src/mcp-groups/mcp-groups.repository.ts b/backend/src/mcp-groups/mcp-groups.repository.ts index 59602f21..3b2a4352 100644 --- a/backend/src/mcp-groups/mcp-groups.repository.ts +++ b/backend/src/mcp-groups/mcp-groups.repository.ts @@ -123,6 +123,60 @@ export class McpGroupsRepository { // Group-Server relationship methods + async findAllServersGrouped(): Promise< + Map< + string, + (McpServerRecord & { recommended: boolean; defaultSelected: boolean; toolCount: number })[] + > + > { + const query = sql` + SELECT + gs.group_id, + s.id, + s.name, + s.description, + s.transport_type, + s.endpoint, + s.command, + s.args, + s.headers, + s.enabled, + s.health_check_url, + s.last_health_check, + s.last_health_status, + s.group_id AS server_group_id, + s.organization_id, + s.created_by, + s.created_at, + s.updated_at, + gs.recommended, + gs.default_selected, + COALESCE(tc.tool_count, 0) as tool_count + FROM mcp_group_servers gs + INNER JOIN mcp_servers s ON gs.server_id = s.id + LEFT JOIN ( + SELECT server_id, COUNT(id) as tool_count + FROM mcp_server_tools + GROUP BY server_id + ) tc ON tc.server_id = s.id + ORDER BY gs.group_id, CASE WHEN gs.recommended THEN 0 ELSE 1 END ASC, s.name + `; + + const result = await this.db.execute(query); + const grouped = new Map< + string, + (McpServerRecord & { recommended: boolean; defaultSelected: boolean; toolCount: number })[] + >(); + for (const row of result.rows as any[]) { + const groupId = row.group_id; + if (!grouped.has(groupId)) { + grouped.set(groupId, []); + } + grouped.get(groupId)!.push(row); + } + return grouped; + } + async findServersByGroup( groupId: string, ): Promise< diff --git a/backend/src/mcp-groups/mcp-groups.service.ts b/backend/src/mcp-groups/mcp-groups.service.ts index 3d9b2f5e..20132ba1 100644 --- a/backend/src/mcp-groups/mcp-groups.service.ts +++ b/backend/src/mcp-groups/mcp-groups.service.ts @@ -106,6 +106,19 @@ export class McpGroupsService implements OnModuleInit { return groups.map((g) => this.mapGroupToResponse(g)); } + async listGroupsWithServers( + enabledOnly = false, + ): Promise<(McpGroupResponse & { servers: McpGroupServerResponse[] })[]> { + const [groups, serversMap] = await Promise.all([ + this.repository.findAll(enabledOnly ? { enabled: true } : {}), + this.repository.findAllServersGrouped(), + ]); + return groups.map((g) => ({ + ...this.mapGroupToResponse(g), + servers: (serversMap.get(g.id) ?? []).map((s) => this.mapGroupServerToResponse(s)), + })); + } + listTemplates(): GroupTemplateDto[] { return this.seedingService.getAllTemplates(); } diff --git a/backend/src/workflows/repository/workflow.repository.ts b/backend/src/workflows/repository/workflow.repository.ts index 264c3f92..387ca000 100644 --- a/backend/src/workflows/repository/workflow.repository.ts +++ b/backend/src/workflows/repository/workflow.repository.ts @@ -16,6 +16,7 @@ export interface WorkflowSummaryRecord { description: string | null; organizationId: string | null; lastRun: Date | null; + latestRunStatus: string | null; runCount: number; nodeCount: number; createdAt: Date; @@ -152,6 +153,12 @@ export class WorkflowRepository { description: workflowsTable.description, organizationId: workflowsTable.organizationId, lastRun: workflowsTable.lastRun, + latestRunStatus: sql`( + SELECT wr.status FROM workflow_runs wr + WHERE wr.workflow_id = ${workflowsTable.id} + ORDER BY wr.created_at DESC + LIMIT 1 + )`.as('latest_run_status'), runCount: workflowsTable.runCount, nodeCount: sql`coalesce(jsonb_array_length(${workflowsTable.graph}->'nodes'), 0)`.as( 'node_count', diff --git a/backend/src/workflows/workflows.service.ts b/backend/src/workflows/workflows.service.ts index cfd922a5..3f461595 100644 --- a/backend/src/workflows/workflows.service.ts +++ b/backend/src/workflows/workflows.service.ts @@ -54,6 +54,7 @@ export interface WorkflowSummaryResponse { description: string | null; organizationId: string | null; lastRun: string | null; + latestRunStatus: string | null; runCount: number; nodeCount: number; createdAt: string; @@ -560,6 +561,7 @@ export class WorkflowsService { return records.map((record) => ({ ...record, lastRun: record.lastRun?.toISOString() ?? null, + latestRunStatus: record.latestRunStatus ?? null, createdAt: record.createdAt.toISOString(), updatedAt: record.updatedAt.toISOString(), })); diff --git a/bun.lock b/bun.lock index b440ecb4..93d63984 100644 --- a/bun.lock +++ b/bun.lock @@ -128,6 +128,8 @@ "@radix-ui/react-tooltip": "^1.2.8", "@shipsec/backend-client": "workspace:*", "@shipsec/shared": "workspace:*", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", "@uiw/react-markdown-preview": "^5.1.5", "ai": "^5.0.76", "ansi_up": "^6.0.6", @@ -1054,6 +1056,14 @@ "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], + "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="], + + "@tanstack/query-devtools": ["@tanstack/query-devtools@5.93.0", "", {}, "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="], + + "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.3", "", { "dependencies": { "@tanstack/query-devtools": "5.93.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA=="], + "@temporalio/activity": ["@temporalio/activity@1.14.1", "", { "dependencies": { "@temporalio/client": "1.14.1", "@temporalio/common": "1.14.1", "abort-controller": "^3.0.0" } }, "sha512-wG2fTNgomhcKOzPY7mqhKqe8scawm4BvUYdgX1HJouHmVNRgtZurf2xQWJZQOTxWrsXfdoYqzohZLzxlNtcC5A=="], "@temporalio/client": ["@temporalio/client@1.14.1", "", { "dependencies": { "@grpc/grpc-js": "^1.12.4", "@temporalio/common": "1.14.1", "@temporalio/proto": "1.14.1", "abort-controller": "^3.0.0", "long": "^5.2.3", "uuid": "^11.1.0" } }, "sha512-AfWSA0LYzBvDLFiFgrPWqTGGq1NGnF3d4xKnxf0PGxSmv5SLb/aqQ9lzHg4DJ5UNkHO4M/NwzdxzzoaR1J5F8Q=="], diff --git a/frontend/.env.example b/frontend/.env.example index 8089b93e..d9951e87 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -26,3 +26,7 @@ VITE_LOGO_DEV_PUBLIC_KEY= # Leave empty to hide Dashboards navigation link # For dev/prod: http://localhost/analytics (nginx in dev/prod) VITE_OPENSEARCH_DASHBOARDS_URL=http://localhost/analytics + +# Dev Tools +# Set to 'true' to hide TanStack Query devtools in development mode +# VITE_DISABLE_DEVTOOLS=true diff --git a/frontend/README.md b/frontend/README.md index 9da70932..d1315fe2 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -3,10 +3,12 @@ React 19 + Vite UI for building and monitoring security workflows with real-time execution visibility. ## Prerequisites + - Bun latest (see root `README.md` for install instructions) - Infrastructure services running (`just dev` from repo root) ## Development Commands + ```bash # Install workspace dependencies (run once from repo root) bun install @@ -18,23 +20,25 @@ bun dev bun run typecheck bun run lint -# Run component/unit tests (Vitest + Testing Library) -bun run test +# Run component/unit tests (Bun test runner + Testing Library) +bun test ``` ## Architecture Overview ### Core Technologies + - **React 19** with TypeScript for component development - **Vite** for fast development and building - **ReactFlow** for visual workflow editor - **xterm.js** for real-time terminal display - **Clerk** for authentication and user management -- **Zustand** for execution timeline state management -- **React Query** for server state management +- **TanStack Query** (`@tanstack/react-query`) for all server state — strict stale-time conventions; see `docs/performance.md` +- **Zustand** for client-only UI state (canvas, timeline, auth) - **Tailwind CSS** with shadcn/ui components ### Key Features + - **Visual Workflow Builder**: Drag-and-drop workflow canvas with ReactFlow - **Real-time Terminal Display**: xterm.js integration for live terminal output - **Execution Timeline**: Interactive timeline with playback controls and seeking @@ -46,21 +50,26 @@ bun run test ## Key Concepts ### State Management -- **Timeline Store**: `src/store/executionTimelineStore.ts` - Zustand-based execution timeline state -- **API State**: React Query for server state synchronization -- **Component State**: Local React state with hooks for UI interactions + +- **Server State**: TanStack Query hooks in `src/hooks/queries/` — all API data fetching, caching, and mutations. See `docs/state.md` for patterns. +- **Client UI State**: Zustand stores in `src/store/` — canvas, timeline playback, auth session +- **Component State**: Local `useState` for UI-only state scoped to one component +- **Derived State**: `useMemo` to filter/transform data from query hooks ### Real-time Features + - **Terminal Streaming**: `src/hooks/useTerminalStream.ts` - Real-time terminal output via SSE - **Timeline Synchronization**: `src/hooks/useTimelineTerminalStream.ts` - Terminal playback synchronized to timeline position - **Live Updates**: WebSocket integration for workflow status changes ### API Integration + - **Generated Client**: `@shipsec/backend-client` - Auto-generated TypeScript API client - **Service Layer**: `src/services/` - Centralized API communication and error handling - **Authentication**: Clerk-based auth with organization scoping ## Project Structure + ``` src/ ├── components/ @@ -77,30 +86,51 @@ src/ ├── store/ │ └── executionTimelineStore.ts # Timeline state management ├── hooks/ -│ ├── useTerminalStream.ts # Terminal streaming +│ ├── queries/ # TanStack Query hooks (all API data) +│ │ ├── useWorkflowQueries.ts # Workflow list/summary +│ │ ├── useScheduleQueries.ts # Schedule CRUD +│ │ ├── useComponentQueries.ts # Component catalogue +│ │ └── ... # One file per domain +│ ├── usePrefetchOnIdle.ts # Idle-time cache warming +│ ├── useTerminalStream.ts # Terminal streaming │ ├── useTimelineTerminalStream.ts # Timeline synchronization -│ └── useWorkflowStream.ts # Workflow status updates +│ └── useWorkflowStream.ts # Workflow status updates ├── services/ │ └── api.ts # API service layer -└── lib/ # Utilities and helpers +├── lib/ +│ ├── queryClient.ts # TanStack Query client config +│ ├── queryKeys.ts # Org-scoped query key factories +│ └── utils.ts # Utilities and helpers ``` ## Component Development ### UI Components + - Use shadcn/ui components from `src/components/ui/` - Follow Tailwind CSS conventions for styling - Implement proper TypeScript interfaces for props -- Use React.memo for performance optimization +- Use `React.memo` for hot-path components (timeline, canvas). See `docs/performance.md` for when to memoize ### Workflow Components + - Extend ReactFlow node types for custom workflow nodes - Implement proper data flow between nodes - Use Zod schemas for input/output validation - Support both design-time and execution-time rendering +## Agent Skills + +The following Claude Code skills are available for frontend work: + +- `/performance-review` — Audit code changes for performance anti-patterns (stale times, bundle splitting, Zustand selectors, N+1 queries) +- `/stress-test-frontend` — Run a full load testing audit: seed data, test all pages via Chrome DevTools, measure network calls and DOM sizes +- `/component-development` — Guide for creating inline/docker components with dynamic ports, retry policies, PTY patterns + ## Where To Read More + - **[Architecture Overview](../docs/architecture.md)** - Complete system design and component interactions - **[Component Development](../docs/component-development.md)** - Building custom security components - **[Getting Started](../docs/getting-started.md)** - Development setup and first workflow -- **[Analytics](../docs/analytics.md)** - PostHog integration and privacy \ No newline at end of file +- **[Performance Guidelines](docs/performance.md)** - Query caching, bundle splitting, rendering, Zustand selectors +- **[Analytics](../docs/analytics.md)** - PostHog integration and privacy diff --git a/frontend/docs/audits/load-audit-2026-02-17T21-11.md b/frontend/docs/audits/load-audit-2026-02-17T21-11.md new file mode 100644 index 00000000..f1edafdc --- /dev/null +++ b/frontend/docs/audits/load-audit-2026-02-17T21-11.md @@ -0,0 +1,519 @@ +# Frontend Load Testing Audit Report + +**Date:** 2026-02-17 +**Branch:** `LuD1161/tanstack-query-migrate` +**Seed Tier:** Medium (50 workflows, ~2000 runs, ~45K traces, ~17K node I/O, ~45K agent events) +**Environment:** Nginx reverse proxy (localhost:80 → Vite dev server), no CPU/network throttling +**Seed Duration:** 15.4s +**Testing Plan:** See `frontend/docs/load-testing-plan.md` +**Previous Report:** `load-audit-2026-02-18T00-00.md` +**Browser Automation:** Playwright MCP (Chrome DevTools MCP unavailable due to browser conflict) + +--- + +## 1. Workflow List Page (`/`) + +### 1.1 Performance Metrics + +| Metric | Value | Previous | Rating | +| ---------------------- | -------- | -------- | ------ | +| **TTFB** | 227 ms | 54 ms | GOOD | +| **FCP** | 5,736 ms | N/A | POOR | +| **CLS** | 0.00 | 0.00 | GOOD | +| **DOM Interactive** | 236 ms | N/A | GOOD | +| **DOM Content Loaded** | 5,657 ms | N/A | — | + +> **Note:** LCP was not captured via Playwright (PerformanceObserver lost on cross-origin navigation). Previous audit measured LCP at 4,893ms via Chrome DevTools trace. TTFB difference (227ms vs 54ms) is attributable to Playwright automation overhead. CLS remains excellent at 0.00 — no layout shifts. + +### 1.2 API Requests + +| # | Endpoint | Status | +| --- | --------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | + +**Observations:** + +- Only **2 API calls** on the list page — efficient (unchanged from previous) +- All **50 workflows** loaded in a single request — no pagination +- Both responses use gzip compression via nginx + +### 1.3 TanStack Query Cache + +| Query Key | Status | Stale Time | Is Stale | Data | +| ---------------------------------- | ------- | ---------- | -------- | -------- | +| `["components","local-dev"]` | success | null | false | object | +| `["workflowsSummary","local-dev"]` | success | 60s | false | 50 items | + +**Observations:** + +- `components` staleTime now shows `null` (was 600s in previous audit) — likely using `queryClient` default or `Infinity` +- Workflows summary remains at 60s — appropriate +- **Clean cache — no ghost queries on this page** + +### 1.4 DOM Size: **1,529 elements** (unchanged from previous) + +--- + +## 2. Workflow Detail Page — Design Tab (`/workflows/:id`) + +### 2.1 API Requests on SPA Navigation + +| # | Endpoint | Notes | +| --- | ---------------------------------- | ----------------------------------------------- | +| 1 | `/api/v1/workflows/summary` | **Served from TanStack cache** (not re-fetched) | +| 2 | `/api/v1/components` | **Served from TanStack cache** (not re-fetched) | +| 3 | `/api/v1/secrets` | New fetch — 0 items | +| 4 | `/api/v1/schedules` | New fetch — 30 items | +| 5 | `/api/v1/workflows/:id` | Full workflow with graph — large payload | +| 6 | `/api/v1/schedules?workflowId=:id` | Workflow-specific schedules — 0 items | + +**Observations:** + +- **6 API calls** — same as previous (4 new + 2 cached from prefetch) +- Cache reuse for `workflows/summary` and `components` is working correctly + +### 2.2 TanStack Query Cache — 7 queries + +| Query Key | Status | Stale Time | Is Stale | Data | Issue? | +| --------------------------------------------- | ----------- | ---------- | -------- | --------- | --------------- | +| `["components","local-dev"]` | success | null | false | object | | +| `["workflowsSummary","local-dev"]` | success | 60s | false | 50 items | | +| `["secrets","local-dev"]` | success | 300s | false | 0 items | | +| `["schedules","local-dev",{}]` | success | 60s | false | 30 items | | +| `["schedules","local-dev",{workflowId}]` | success | 60s | false | 0 items | | +| **`["runs","local-dev","__disabled__"]`** | **PENDING** | 30s | false | undefined | **Ghost query** | +| **`["workflow","local-dev","__disabled__"]`** | **PENDING** | 60s | false | undefined | **Ghost query** | + +### FINDINGS — Ghost Queries (PERSIST from previous audit): + +1. **`["runs","local-dev","__disabled__"]`** — status=pending + fetchStatus=idle. Disabled sentinel key sitting in cache. +2. **`["workflow","local-dev","__disabled__"]`** — Same pattern. + +### 2.3 DOM Size: **1,667 elements** (unchanged from previous) + +--- + +## 3. Workflow Detail Page — Execute Tab (`/workflows/:id/runs/:runId`) + +### 3.1 API Requests on Execute Tab + +| # | Endpoint | Notes | +| --- | ----------------------------------------------- | ---------------- | +| 1 | `/api/v1/workflows/runs?workflowId=:id&limit=5` | Run selector | +| 2 | `/api/v1/workflows/runs/:runId/events` | Event timeline | +| 3 | `/api/v1/workflows/runs/:runId/dataflows` | Node I/O data | +| 4 | `/api/v1/workflows/runs/:runId/status` | Run status | +| 5 | `/api/v1/workflows/:id/versions/:versionId` | Workflow version | +| 6 | `/api/v1/workflows/runs/:runId/stream` | Live SSE stream | +| 7 | `/api/v1/workflows/runs/:runId/events` | **DUPLICATE** | +| 8 | `/api/v1/workflows/runs/:runId/logs?limit=500` | Logs | + +### FINDING: API calls reduced from 10 → 8 (IMPROVEMENT) + +Previous audit showed 10 API calls with 3 pairs of duplicates (`events`, `dataflows`, `status`). This audit shows **8 calls** with only **1 duplicate** (`events` called 2x). The duplicate calls for `dataflows` and `status` have been eliminated. + +### 3.2 TanStack Query Cache — 7 queries + +| Query Key | Status | Stale Time | Is Stale | Notes | +| ---------------------------------------- | ------- | ---------- | -------- | ----------------- | +| `["components","local-dev"]` | success | null | false | | +| `["workflowsSummary","local-dev"]` | success | 60s | false | | +| `["secrets","local-dev"]` | success | 300s | false | | +| `["schedules","local-dev",{}]` | success | 60s | false | | +| `["schedules","local-dev",{workflowId}]` | success | 60s | false | | +| `["runs","local-dev","__disabled__"]` | PENDING | 30s | false | Ghost | +| `["runs","local-dev",":workflowId"]` | success | 30s | false | Actively fetching | + +- Ghost query `["runs","__disabled__"]` persists +- Ghost `["workflow","__disabled__"]` gone from execute tab (was present in previous audit) + +### 3.3 DOM Size: **1,444 elements** — down from 2,091 (MAJOR IMPROVEMENT, -31%) + +### 3.4 Console Warnings + +- **186+ React Flow warnings** about edges referencing nodes not found — cosmetic but noisy + +--- + +## 4. Mobile & Tablet Responsiveness + +### 4.1 iPhone (375x667) + +- Sidebar collapses correctly to hamburger menu ("Open menu" button) +- Table shows only **Name** and **Nodes** columns — Status, Last Run, Last Updated hidden +- Long workflow names truncated with ellipsis (e.g., "Content Publisher – User He...") +- "New" button visible in top bar +- **DOM Size: 1,524 elements** (was 1,529 desktop — minimal difference) +- **All 50 workflows rendered** — no virtual scrolling on mobile + +### 4.2 Tablet (768x1024) + +- Sidebar visible with full navigation links +- Table shows **Name**, **Nodes**, and **Status** columns +- Status badges slightly clipped at right edge — minor layout issue +- **DOM Size: 1,529 elements** (same as desktop) + +### 4.3 Comparison with Previous Audit + +- Mobile layout unchanged — same column hiding behavior +- Sidebar collapse works correctly at both breakpoints +- No new responsive regressions + +--- + +## 5. Schedules Page (`/schedules`) + +### 5.1 API Requests + +| # | Endpoint | Status | +| --- | --------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/schedules` | 200 OK | + +- **3 API calls total** — clean, no duplicates +- 30 schedules loaded in a single request + +### 5.2 TanStack Query Cache + +| Query Key | Status | Stale Time | Is Stale | Data | +| --------------------------------------------- | ----------- | ---------- | -------- | --------- | +| `["components","local-dev"]` | success | null | false | object | +| `["workflowsSummary","local-dev"]` | success | 60s | false | 50 items | +| `["schedules","local-dev",{}]` | success | 60s | false | 30 items | +| **`["workflow","local-dev","__disabled__"]`** | **PENDING** | 60s | false | undefined | + +- Ghost query `["workflow","__disabled__"]` persists across page navigations — confirmed global issue + +### 5.3 DOM Size: **1,455 elements** (was 1,452 — negligible change) + +--- + +## 6. Webhooks Page (`/webhooks`) + +### 6.1 API Requests + +| # | Endpoint | Status | +| --- | --------------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/webhooks/configurations` | 200 OK | + +- **3 API calls total** — all fast +- 25 webhooks loaded — no pagination + +### 6.2 TanStack Query Cache + +| Query Key | Status | Stale Time | Is Stale | Data | +| ---------------------------------- | ------- | ---------- | -------- | -------- | +| `["components","local-dev"]` | success | null | false | object | +| `["workflowsSummary","local-dev"]` | success | 60s | false | 50 items | +| `["webhooks","local-dev",null]` | success | 60s | false | 25 items | + +- **Clean cache — no ghost queries** + +### 6.3 DOM Size: **1,181 elements** (was 1,178 — negligible change) + +--- + +## 7. Action Center Page (`/action-center`) + +### 7.1 API Requests + +| # | Endpoint | Status | +| --- | ------------------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/human-inputs?status=pending` | 200 OK | + +- **3 API calls total** +- 28 pending human input items loaded + +### 7.2 TanStack Query Cache + +| Query Key | Status | Stale Time | Is Stale | Data | +| -------------------------------------------------- | ------- | ---------- | -------- | -------- | +| `["components","local-dev"]` | success | null | false | object | +| `["workflowsSummary","local-dev"]` | success | 60s | false | 50 items | +| `["humanInputs","local-dev",{"status":"pending"}]` | success | 30s | false | 28 items | + +- Clean cache, no issues + +### 7.3 DOM Size: **1,026 elements** (was 1,023 — negligible change) + +--- + +## 8. Artifact Library Page (`/artifacts`) + +### 8.1 API Requests + +| # | Endpoint | Status | +| --- | --------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/artifacts` | 200 OK | + +- **3 API calls total** +- 50 artifacts loaded — no pagination + +### 8.2 TanStack Query Cache + +| Query Key | Status | Stale Time | Is Stale | Data | +| -------------------------------------- | ------- | ---------- | -------- | -------- | +| `["components","local-dev"]` | success | null | false | object | +| `["workflowsSummary","local-dev"]` | success | 60s | false | 50 items | +| `["artifactLibrary","local-dev",null]` | success | 30s | false | 50 items | + +- Clean cache, no issues + +### 8.3 DOM Size: **1,523 elements** (was 1,520 — negligible change) + +--- + +## 9. Manage Section + +### 9.1 Secrets Page (`/secrets`) + +| # | Endpoint | Status | +| --- | --------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/secrets` | 200 OK | + +- **3 API calls**, 0 secrets returned +- **Query:** `["secrets","local-dev"]` — success, 300s stale time (appropriate) +- **DOM Size:** **225 elements** (was 222) + +### 9.2 API Keys Page (`/api-keys`) + +| # | Endpoint | Status | +| --- | --------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/api-keys` | 200 OK | + +- **3 API calls**, 0 API keys returned +- **Query:** `["apiKeys","local-dev"]` — success, 60s stale time +- **DOM Size:** **215 elements** (was 212) + +### 9.3 MCP Library Page (`/mcp-library`) + +| # | Endpoint | Status | +| --- | ---------------------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/mcp-groups/templates` | 200 OK | +| 4 | `/api/v1/mcp-servers` | 200 OK | +| 5 | `/api/v1/mcp-servers/tools` | 200 OK | +| 6 | `/api/v1/mcp-groups?includeServers=true` | 200 OK | + +- **6 API calls** — most of any non-detail page +- `mcp-servers/tools` returns **268 items** — largest item count from any single endpoint + +**TanStack Query Cache:** + +| Query Key | Status | Stale Time | Is Stale | Data | +| ----------------------------------------- | ------- | ---------- | -------- | --------- | +| `["mcpGroupTemplates","local-dev"]` | success | 300s | false | 1 item | +| `["mcpServers","local-dev"]` | success | 120s | false | 30 items | +| `["mcpServers","local-dev","tools"]` | success | 120s | false | 268 items | +| `["mcpGroups","local-dev","withServers"]` | success | 30s | false | 10 items | + +- **DOM Size:** **524 elements** (was 521) + +### 9.4 Analytics Settings Page (`/analytics-settings`) + +| # | Endpoint | Status | +| --- | --------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | + +- **2 API calls** — no page-specific API calls +- Only `components` and `workflowsSummary` (both prefetched) +- **No page-specific TanStack queries** +- **DOM Size:** **252 elements** (was 249) + +--- + +## 10. Integrations Page (`/integrations`) — NEW PAGE + +> This page was not included in the previous audit. + +### 10.1 API Requests + +| # | Endpoint | Status | +| --- | ------------------------------- | ------ | +| 1 | `/api/v1/workflows/summary` | 200 OK | +| 2 | `/api/v1/components` | 200 OK | +| 3 | `/api/v1/integrations` | 200 OK | +| 4 | `/api/v1/integrations/provider` | 200 OK | + +- **4 API calls** — 2 page-specific + +### 10.2 TanStack Query Cache — 5 entries + +| Query Key | Status | Stale Time | Is Stale | Data | Issue? | +| --------------------------------------------------- | ----------- | ---------- | -------- | --------- | --------------- | +| `["components","local-dev"]` | success | null | false | object | | +| `["workflowsSummary","local-dev"]` | success | 60s | false | 50 items | | +| `["integrations","local-dev"]` | success | 60s | false | object | | +| `["integrations","local-dev","providers"]` | success | 300s | false | object | | +| **`["providerConfig","local-dev","__disabled__"]`** | **PENDING** | 300s | false | undefined | **Ghost query** | + +### FINDING — New Ghost Query: + +- **`["providerConfig","local-dev","__disabled__"]`** — Same `__disabled__` sentinel pattern as other ghost queries. Created when no specific provider is selected. + +### 10.3 DOM Size: **279 elements** + +--- + +## 11. Cross-Page Analysis + +### 11.1 DOM Element Count Comparison + +| Page | DOM Elements | Previous | Change | Rating | +| ------------------------------------------ | ------------ | --------- | -------- | ------------ | +| Workflow Detail Design | 1,667 | 1,667 | — | Heavy | +| Workflow List (`/`) | 1,529 | 1,529 | — | Heavy | +| Artifact Library (`/artifacts`) | 1,523 | 1,520 | +3 | Heavy | +| Schedules (`/schedules`) | 1,455 | 1,452 | +3 | Heavy | +| **Workflow Execute Tab** | **1,444** | **2,091** | **-647** | **IMPROVED** | +| Webhooks (`/webhooks`) | 1,181 | 1,178 | +3 | Moderate | +| Action Center (`/action-center`) | 1,026 | 1,023 | +3 | Moderate | +| MCP Library (`/mcp-library`) | 524 | 521 | +3 | Light | +| Integrations (`/integrations`) — NEW | 279 | — | NEW | Light | +| Analytics Settings (`/analytics-settings`) | 252 | 249 | +3 | Minimal | +| Secrets (`/secrets`) | 225 | 222 | +3 | Minimal | +| API Keys (`/api-keys`) | 215 | 212 | +3 | Minimal | + +> The +3 DOM element increase across all pages likely reflects a minor template/layout change (possibly a new wrapper element in AppLayout). + +### 11.2 API Call Count per Page + +| Page | API Calls | Previous | Change | +| ---------------------- | --------- | -------- | ------ | +| Workflow Execute Tab | 8 | 10 | **-2** | +| MCP Library | 6 | 6 | — | +| Workflow Detail Design | 6 | 6 | — | +| Integrations (NEW) | 4 | — | NEW | +| Schedules | 3 | 3 | — | +| Webhooks | 3 | 3 | — | +| Action Center | 3 | 3 | — | +| Artifact Library | 3 | 3 | — | +| Secrets | 3 | 3 | — | +| API Keys | 3 | 3 | — | +| Workflow List | 2 | 2 | — | +| Analytics Settings | 2 | 2 | — | + +### 11.3 Shared Queries Across All Pages + +Every page loads `["components","local-dev"]` and `["workflowsSummary","local-dev"]` via `usePrefetchOnIdle`. This is consistent and efficient — the prefetch works correctly across all navigation paths. + +### 11.4 Pages Without Pagination + +| Page | Items Loaded | Pagination? | +| ---------------- | --------------- | ----------- | +| Workflow List | 50 workflows | **No** | +| Schedules | 30 schedules | **No** | +| Webhooks | 25 webhooks | **No** | +| Action Center | 28 human inputs | **No** | +| Artifact Library | 50 artifacts | **No** | +| MCP Servers | 30 servers | **No** | +| MCP Tools | 268 tools | **No** | + +> No changes — still no server-side pagination on any list page. + +### 11.5 Ghost Query Summary + +| Ghost Query Key | Pages Where Found | Status | +| ----------------------------------------------- | ----------------------- | ------- | +| `["runs","local-dev","__disabled__"]` | Design tab, Execute tab | PERSIST | +| `["workflow","local-dev","__disabled__"]` | Design tab, Schedules | PERSIST | +| `["providerConfig","local-dev","__disabled__"]` | Integrations (NEW) | NEW | + +--- + +## 12. Comparison with Previous Audit (2026-02-18T00-00) + +### Improvements + +| # | Area | Previous | Current | Change | +| --- | -------------------------- | -------------------------- | -------------- | -------------------------------- | +| 1 | **Execute Tab DOM** | 2,091 elements | 1,444 elements | **-31% (647 fewer elements)** | +| 2 | **Execute Tab API calls** | 10 (3 dups) | 8 (1 dup) | **2 fewer duplicate calls** | +| 3 | **Execute Tab duplicates** | events/dataflows/status x2 | events x2 only | `dataflows` and `status` deduped | +| 4 | **CLS** | 0.00 | 0.00 | Maintained excellent | + +### Regressions + +None observed. + +### Unchanged Issues + +| # | Issue | Severity | +| --- | ------------------------------------------------ | -------- | +| 1 | Ghost queries (`__disabled__` sentinel) | MEDIUM | +| 2 | No pagination on any list page | MEDIUM | +| 3 | Remaining duplicate `events` call on Execute tab | MEDIUM | +| 4 | No virtual scrolling for heavy pages | LOW | +| 5 | React Flow edge warnings (186+) | LOW | + +### New Findings + +| # | Finding | Severity | +| --- | ----------------------------------------------------------------------------------------- | -------- | +| 1 | New ghost query on Integrations page: `providerConfig/__disabled__` | MEDIUM | +| 2 | `components` staleTime now shows `null` (was 600s) — possibly using `Infinity` or default | INFO | +| 3 | Integrations page audited for first time — 4 API calls, clean except ghost query | INFO | + +--- + +## 13. Summary of Findings + +### What's Working Well + +1. **TanStack Query caching** — `components` and `workflowsSummary` properly cached and reused across SPA navigations +2. **Prefetch on idle** — `usePrefetchOnIdle` correctly pre-fetches on every page +3. **Execute tab improvements** — DOM elements reduced by 31%, duplicate API calls reduced from 3 pairs to 1 +4. **CLS = 0.00** — no layout shifts, excellent UX +5. **Mobile responsive layout** — sidebar collapses, table columns adapt appropriately +6. **Gzip compression** active on all API responses via nginx +7. **Run selector pagination** — uses `limit=5`, efficient +8. **No unnecessary refetches** on SPA navigation + +### Issues Found + +| # | Severity | Finding | Details | +| --- | ---------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| 1 | **MEDIUM** | Ghost queries persist across navigations | `runs/__disabled__`, `workflow/__disabled__` still present. New: `providerConfig/__disabled__` on Integrations page. | +| 2 | **MEDIUM** | No pagination on ANY list page | Workflows (50), schedules (30), webhooks (25), artifacts (50), human inputs (28), MCP servers (30), MCP tools (268). | +| 3 | **MEDIUM** | Remaining duplicate `events` API call on Execute tab | Down from 3 duplicate pairs to 1 — `events` still called 2x on tab switch. | +| 4 | **LOW** | No virtual scrolling for heavy pages | Execute tab 1,444 DOM elements (improved), Workflow List 1,529, Design tab 1,667. Could cause scroll jank on mobile. | +| 5 | **LOW** | React Flow edge warnings (186+) | Edges reference non-existent nodes — cosmetic but pollutes console. | +| 6 | **LOW** | `components` staleTime shows null | Was 600s (10 min) in previous audit. Behavior may be unchanged if using `Infinity` or a default, but worth verifying. | +| 7 | **INFO** | MCP tools payload: 268 items | Largest item count. Well-cached at 120s stale time. | +| 8 | **INFO** | Analytics Settings has no page-specific queries | Only prefetched queries load. | +| 9 | **INFO** | Tablet layout: Status badges slightly clipped at 768px | Minor layout issue with status column at tablet breakpoint. | + +### Stale Time Configuration Summary (Current) + +| Query | Stale Time | Previous | Assessment | +| ------------------- | ----------------- | -------- | ---------------------------------- | +| `components` | null (default/∞?) | 600s | Changed — verify intended behavior | +| `workflowsSummary` | 60s | 60s | Good | +| `workflow` (detail) | 60s | 60s | Good | +| `secrets` | 300s | 300s | Appropriate | +| `mcpGroupTemplates` | 300s | 300s | Appropriate | +| `schedules` | 60s | 60s | Good | +| `mcpServers` | 120s | 120s | Good | +| `mcpServers/tools` | 120s | 120s | Good | +| `mcpGroups` | 30s | 30s | Acceptable | +| `runs` | 30s | 30s | Good for live data | +| `webhooks` | 60s | 60s | Good | +| `artifactLibrary` | 30s | 30s | Acceptable | +| `humanInputs` | 30s | 30s | Good | +| `apiKeys` | 60s | 60s | Good | +| `providerConfig` | 300s | — | New — appropriate | +| `integrations` | 60s | — | New — good | diff --git a/frontend/docs/performance.md b/frontend/docs/performance.md new file mode 100644 index 00000000..ec2a3977 --- /dev/null +++ b/frontend/docs/performance.md @@ -0,0 +1,666 @@ +# Frontend Performance Guidelines + +Authoritative reference for all performance patterns in the ShipSec Studio frontend. Every new feature, page, and query hook must follow these guidelines. + +**Enforcement:** Rules 11-15 in `AGENTS.md` are mandatory. This document provides the detail and rationale. + +**Agent skills:** Use `/performance-review` to audit code changes against these guidelines. Use `/stress-test-frontend` to run a full load testing audit with Chrome DevTools. + +--- + +## TL;DR: Mandatory Checklist + +Scan this before shipping any frontend change. + +### Data Fetching + +- **DO** use TanStack Query hooks in `src/hooks/queries/` for all API data +- **DON'T** use `useState` + `useEffect` to fetch backend data +- **DO** add query keys to `src/lib/queryKeys.ts` using factory functions +- **DON'T** construct query keys inline in components +- **DO** set `staleTime: Infinity` for static reference data (components, templates, providers) +- **DO** use `skipToken` for conditional queries instead of `enabled: false` with `undefined` queryFn +- **DO** use `select` transforms for sorting/filtering instead of `useMemo` in the component +- **DON'T** fetch a list then loop to fetch each item — use a batched endpoint +- **DO** add frequently-used queries to `usePrefetchOnIdle.ts` +- **DO** use `queryOptions()` factories when a query is shared between hooks and imperative code + +### Bundle & Loading + +- **DO** add new page routes as `React.lazy()` imports in `App.tsx` +- **DON'T** statically import page-level components in `App.tsx` +- **DO** add new sidebar pages to `routePrefetchMap` in `src/lib/prefetch-routes.ts` +- **DO** use deferred load pattern for rarely-visible components that import large libraries + +### Rendering + +- **DO** derive data from query results using `useMemo`, not by copying into `useState` +- **DO** use granular Zustand selectors: `useStore((s) => s.field)` +- **DON'T** destructure from a full store call: `const store = useStore()` +- **DO** consider `React.memo` for components on hot render paths (timeline, canvas edges) +- **DON'T** memoize every component — only hot paths with stable, comparable props + +### Cache + +- **DO** invalidate query cache after mutations via `queryClient.invalidateQueries()` +- **DON'T** manually update local state after mutations + +--- + +## Pattern Catalog + +### 1. TanStack Query Configuration + +**Source:** `src/lib/queryClient.ts` + +```typescript +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, // One retry on failure, prevents retry thrashing + refetchOnWindowFocus: false, // App uses SSE/polling for live data + staleTime: 30_000, // 30s default — cached data served fresh for 30s + }, + }, +}); +``` + +**Rules:** + +- Do NOT create a second QueryClient. One instance in `queryClient.ts`. +- `refetchOnWindowFocus` is off because the app uses real-time SSE/polling for live data. Tab-focus refetch would create redundant requests during execution monitoring. +- Override stale time per-query only when you have a specific reason (see Section 3). + +--- + +### 2. Query Key Architecture + +#### 2.1 Org-scoped factory functions + +**Source:** `src/lib/queryKeys.ts` + +Every key includes `getOrgScope()` which reads from `authStore` imperatively (outside React). This provides multi-tenant cache isolation without threading org IDs as props. + +```typescript +const getOrgScope = () => useAuthStore.getState().organizationId || '__no-org__'; + +export const queryKeys = { + components: { all: () => ['components', getOrgScope()] as const }, + workflows: { + list: () => ['workflows', getOrgScope()] as const, + summary: () => ['workflowsSummary', getOrgScope()] as const, + detail: (id: string) => ['workflow', getOrgScope(), id] as const, + }, + runs: { + byWorkflow: (workflowId: string) => ['runs', getOrgScope(), workflowId] as const, + global: () => ['runs', getOrgScope(), '__global__'] as const, + detail: (runId: string) => ['runs', getOrgScope(), 'detail', runId] as const, + }, + // ... see queryKeys.ts for the full list +}; +``` + +**Hierarchy enables bulk invalidation:** invalidating `['workflows']` prefix invalidates all workflow queries simultaneously. + +**Rule:** Never construct query keys inline in a component. Always import from `queryKeys.ts`. + +#### 2.2 queryOptions() shared factories + +**Source:** `src/lib/executionQueryOptions.ts` + +`queryOptions()` from TanStack Query wraps key + queryFn + staleTime into a single object consumable by both `useQuery()` (React) and `queryClient.fetchQuery()` (imperative). This ensures a single in-flight promise — no duplicate requests. + +```typescript +// Shared options — used by both useExecutionNodeIO hook AND executionStore actions +export const executionNodeIOOptions = (runId: string, isTerminal?: boolean) => + queryOptions({ + queryKey: queryKeys.executions.nodeIO(runId), + queryFn: () => api.executions.listNodeIO(runId), + staleTime: isTerminal ? Infinity : 10_000, + gcTime: isTerminal ? 10 * 60_000 : 30_000, + }); +``` + +**When:** Use for queries accessed from both React components AND store actions/callbacks. + +**Anti-pattern:** Duplicating the queryKey in two places causes cache misses. + +--- + +### 3. Stale Time Strategy + +Four tiers of staleness, from coldest to hottest: + +#### Tier A — Static reference data (`staleTime: Infinity, gcTime: Infinity`) + +Data that only changes on admin action, not user action. Fetched once, cached forever. + +```typescript +// src/hooks/queries/useComponentQueries.ts +export function useComponents() { + return useQuery({ + queryKey: queryKeys.components.all(), + queryFn: fetchComponentIndex, + staleTime: Infinity, + gcTime: Infinity, + }); +} +``` + +**Applies to:** `useComponents`, `useIntegrationProviders`, `useMcpGroupTemplates` + +#### Tier B — Default (30s, inherits global) + +User-owned entities that change occasionally. No override needed. + +**Applies to:** schedules, webhooks, secrets, workflow runs list, artifacts + +#### Tier C — Terminal-run Infinity (`terminalStaleTime` pattern) + +Runs in terminal state (COMPLETED, FAILED, CANCELLED) will never change. Once detected, staleTime becomes Infinity. + +```typescript +// src/hooks/queries/useRunQueries.ts +export function terminalStaleTime(runId: string | null | undefined, defaultMs: number): number { + if (!runId) return defaultMs; + const cached = getRunByIdFromCache(runId); + if (cached && TERMINAL_STATUSES.includes(cached.status)) return Infinity; + return defaultMs; +} +``` + +**Used by:** `executionNodeIOOptions`, `executionResultOptions`, `executionRunOptions` + +#### Tier D — Polling-backed (`staleTime: 0`) + +Execution status and trace during live runs — must always be fresh. Polling refetches on every interval. + +```typescript +// src/lib/executionQueryOptions.ts +export const executionStatusOptions = (runId: string) => + queryOptions({ + queryKey: queryKeys.executions.status(runId), + queryFn: () => api.executions.getStatus(runId), + staleTime: 0, // Always stale — poll backing + gcTime: 30_000, + retry: false, + }); +``` + +**Applies to:** `executionStatusOptions`, `executionTraceOptions`, `executionEventsOptions`, `executionDataFlowsOptions` + +#### Full stale time reference + +| Query | staleTime | gcTime | +| -------------------------------- | -------------------------------- | -------------------------- | +| `useComponents` | `Infinity` | `Infinity` | +| `useMcpGroupTemplates` | `Infinity` | `Infinity` | +| `useIntegrationProviders` | `Infinity` | `Infinity` | +| `useMcpServers` | `120_000` | default | +| `useMcpAllTools` | `120_000` | default | +| `useWorkflowsSummary` | `60_000` | default | +| `useWorkflow` | `60_000` | default | +| `useSchedules` | `60_000` | default | +| `useIntegrationConnections` | `60_000` | default | +| `useWorkflowsList` | `30_000` | default | +| `useWorkflowRuns` | `30_000` | default | +| `useArtifactLibrary` | `30_000` | default | +| `executionNodeIOOptions` | `isTerminal ? Infinity : 10_000` | `isTerminal ? 10min : 30s` | +| `executionResultOptions` | `isTerminal ? Infinity : 30_000` | `isTerminal ? 10min : 30s` | +| `executionRunOptions` | `isTerminal ? Infinity : 30_000` | `isTerminal ? 10min : 30s` | +| `executionTerminalChunksOptions` | `10_000` | `30_000` | +| `executionStatusOptions` | `0` | `30_000` | +| `executionTraceOptions` | `0` | `30_000` | +| `executionEventsOptions` | `0` | `30_000` | +| `executionDataFlowsOptions` | `0` | `30_000` | + +--- + +### 4. Bundle Splitting & Code Loading + +#### 4.1 Manual Rollup chunks + +**Source:** `vite.config.ts` + +Groups vendor libraries into stable named chunks. The browser can cache `vendor-react` across app updates. + +```typescript +manualChunks: { + 'vendor-react': ['react', 'react-dom', 'react-router-dom'], + 'vendor-radix': [ + '@radix-ui/react-accordion', '@radix-ui/react-avatar', + '@radix-ui/react-checkbox', '@radix-ui/react-dialog', + // ... 13 Radix primitives + ], + 'vendor-analytics': ['posthog-js'], +}, +``` + +**Rule:** When adding a new Radix primitive, add it to `vendor-radix`. When adding a large new vendor library, consider creating a new chunk. + +#### 4.2 React.lazy for all page routes + +**Source:** `src/App.tsx` + +Every page component uses `React.lazy()`. The initial bundle only contains `AppLayout`, auth providers, and routing infrastructure. + +```typescript +const WorkflowList = lazy(() => + import('@/pages/WorkflowList').then((m) => ({ default: m.WorkflowList })), +); +``` + +**Rule:** Every new page MUST use this pattern. Never add a page as a static import in App.tsx. + +#### 4.3 Suspense with PageSkeleton fallback + +**Source:** `src/App.tsx` + +A single `}>` wraps all routes. `PageSkeleton` renders placeholder content sized to match real page dimensions, preventing layout shift (CLS = 0). + +**Rule:** Do not add per-page Suspense boundaries with empty fallbacks. + +#### 4.4 Deferred chunk load (CommandPalette pattern) + +**Source:** `src/App.tsx` + +CommandPalette imports lucide-react's entire icon barrel (~350KB). It's lazy-loaded AND deferred — only mounts after the user opens it for the first time. + +```typescript +function CommandPaletteProvider({ children }: { children: React.ReactNode }) { + const isOpen = useCommandPaletteStore((state) => state.isOpen); + const [hasOpened, setHasOpened] = useState(false); + + useEffect(() => { + if (isOpen && !hasOpened) setHasOpened(true); + }, [isOpen, hasOpened]); + + return ( + <> + {children} + {hasOpened && ( + + + + )} + + ); +} +``` + +**When to apply:** Components that (a) are rarely used on first load, (b) import a large library, (c) are always hidden initially. NOT for components visible on first render. + +#### 4.5 Idle-time and hover route chunk prefetch + +**Source:** `src/lib/prefetch-routes.ts`, `src/components/layout/AppLayout.tsx` + +`prefetchIdleRoutes()` downloads all sidebar route chunks during `requestIdleCallback`. `prefetchRoute(href)` downloads a single chunk on sidebar link hover. + +```typescript +const routePrefetchMap: Record = { + '/': () => void import('@/pages/WorkflowList'), + '/schedules': () => void import('@/pages/SchedulesPage'), + '/webhooks': () => void import('@/pages/WebhooksPage'), + // ... all sidebar routes +}; + +export function prefetchIdleRoutes(): void { + const prefetch = () => Object.values(routePrefetchMap).forEach((fn) => fn()); + if (typeof window.requestIdleCallback === 'function') { + window.requestIdleCallback(prefetch, { timeout: 10_000 }); + } else { + setTimeout(prefetch, 3_000); + } +} +``` + +**Rule:** When adding a new sidebar-linked page, add it to `routePrefetchMap`. + +#### 4.6 Idle-time data prefetch + +**Source:** `src/hooks/usePrefetchOnIdle.ts` + +Called once in `AppLayout`. Uses `requestIdleCallback` (5s timeout fallback) to warm the cache for high-value queries. + +```typescript +export function usePrefetchOnIdle() { + const { isAuthenticated } = useAuth(); + useEffect(() => { + if (!isAuthenticated) return; + const prefetch = () => { + queryClient.prefetchQuery({ + queryKey: queryKeys.components.all(), + queryFn: fetchComponentIndex, + staleTime: Infinity, + gcTime: Infinity, + }); + queryClient.prefetchQuery({ + queryKey: queryKeys.workflows.summary(), + queryFn: () => api.workflows.listSummary(), + staleTime: 60_000, + }); + }; + if (typeof window.requestIdleCallback === 'function') { + const id = window.requestIdleCallback(prefetch, { timeout: 5_000 }); + return () => window.cancelIdleCallback(id); + } + const timer = setTimeout(prefetch, 2_000); + return () => clearTimeout(timer); + }, [isAuthenticated]); +} +``` + +**When to add:** If your query is used on almost every page and has `staleTime >= 30s`, consider adding it here. + +--- + +### 5. React Rendering Optimization + +#### 5.1 useMemo for derived data + +**Enforced in AGENTS.md rule 9.** + +When you need to filter, sort, or transform query data, use `useMemo`. Never copy query results into `useState`. + +```typescript +// BAD: Derived state in useState causes extra renders and stale data +const [sorted, setSorted] = useState([]); +useEffect(() => setSorted([...data].sort(...)), [data]); + +// GOOD: useMemo recalculates only when data changes +const sorted = useMemo(() => [...(data ?? [])].sort(...), [data]); +``` + +#### 5.2 React.memo on hot-path components + +`memo()` prevents re-renders when parent re-renders but props haven't changed. + +**Apply to:** Components that (a) render frequently due to parent state changes, (b) have expensive render logic, (c) receive stable, comparable props. + +**Current usage:** `DataFlowEdge` (canvas edge — re-renders during timeline playback), `MarkdownView` (large markdown content). + +**When NOT to:** Don't memoize every component. Only components on hot paths in the timeline or canvas. + +#### 5.3 select transforms in useQuery + +The `select` option transforms cached data before returning it to the component. The transform runs only when cached data changes, not on every render. + +```typescript +// src/hooks/queries/useIntegrationQueries.ts +export function useIntegrationProviders() { + return useQuery({ + queryKey: queryKeys.integrations.providers(), + queryFn: () => api.integrations.listProviders(), + staleTime: Infinity, + gcTime: Infinity, + select: sortProviders, // Sort runs once when data changes, not on every re-render + }); +} +``` + +**When:** Use `select` for sorting, filtering, or shape-mapping operations that would otherwise go in `useMemo` in a component. + +--- + +### 6. Zustand Store Optimization + +#### 6.1 Granular per-field selectors + +Call `useStore((s) => s.specificField)` rather than `useStore()`. Zustand only re-renders when the selected value changes. + +```typescript +// BAD: subscribes to the whole store, re-renders on any field change +const store = useWorkflowUiStore(); +const { mode, inspectorTab } = store; + +// GOOD: two subscriptions, each re-renders only when its field changes +const mode = useWorkflowUiStore((s) => s.mode); +const inspectorTab = useWorkflowUiStore((s) => s.inspectorTab); +``` + +**Rule:** Never destructure from a full store call. + +#### 6.2 subscribeWithSelector middleware + +**Source:** `src/store/executionTimelineStore.ts` + +Enables subscribing to a store slice outside React with equality comparison. Used for cross-store coordination. + +```typescript +// executionStore subscribes to only specific fields of executionTimelineStore +useExecutionTimelineStore.subscribe( + (state) => ({ currentTime: state.currentTime, playbackMode: state.playbackMode }), + (selected) => { + /* only runs when currentTime or playbackMode change */ + }, +); +``` + +**When:** Use when one store needs to react to state changes in another store. + +#### 6.3 persist with partialize + +**Source:** `src/store/workflowUiStore.ts` + +`persist` serializes store slices to localStorage. `partialize` limits which fields are persisted. + +**Rule:** Always provide `partialize`. Never persist the whole store including action functions. + +#### 6.4 Dynamic import for cross-store lazy coupling + +**Source:** `src/store/executionStore.ts`, `src/store/executionTimelineStore.ts` + +Mutually-referenced stores use `await import('./executionTimelineStore')` inside action functions to avoid circular imports at module evaluation time. + +**When:** Use when Store A and Store B genuinely need to call each other's actions. + +--- + +### 7. Real-time & Streaming + +#### 7.1 SSE + polling hybrid with adaptive rate + +**Source:** `src/store/executionStore.ts` + +During workflow execution, the app tries SSE first. If unavailable, it falls back to polling. When SSE is active, polling interval is extended (2s → 5s) as a backup. + +**Rule:** New real-time features should follow SSE-first + polling-fallback, not polling-only. + +#### 7.2 N+1 query avoidance + +**Source:** `src/hooks/queries/useMcpGroupQueries.ts` + +When a page needs groups AND their servers, use the batched `useMcpGroupsWithServers()` endpoint instead of looping `useMcpGroupServers(groupId)`. + +**Rule:** If you find yourself calling a query hook inside a `.map()`, you have an N+1 problem. Request a batched endpoint from the backend. + +#### 7.3 Terminal chunk cap + +**Source:** `src/store/executionStore.ts` + +`MAX_TERMINAL_CHUNKS = 500` limits memory consumption for long-running processes. Uses `slice(-MAX_TERMINAL_CHUNKS)` to trim. + +--- + +### 8. Cache Manipulation Patterns + +#### 8.1 Manual setQueryData cache upsert + +**Source:** `src/hooks/queries/useRunQueries.ts` + +`upsertRunInCache()` calls `queryClient.setQueryData()` to inject or update a run in all matching cache entries. SSE events immediately reflect in the UI without waiting for a refetch. + +```typescript +export function upsertRunInCache(run: ExecutionRun) { + const upsert = (old: RunsPage | undefined): RunsPage => { + if (!old) return { runs: [run], hasMore: false }; + const existingIndex = old.runs.findIndex((r) => r.id === run.id); + if (existingIndex === -1) { + return { ...old, runs: sortRuns([...old.runs, run]) }; + } + const updated = [...old.runs]; + updated[existingIndex] = { ...updated[existingIndex], ...run, status: run.status }; + return { ...old, runs: sortRuns(updated) }; + }; + // Update workflow-scoped, global, and detail caches + queryClient.setQueryData(queryKeys.runs.byWorkflow(run.workflowId), upsert); + queryClient.setQueryData(queryKeys.runs.global(), upsert); + queryClient.setQueryData(queryKeys.runs.detail(run.id), run); +} +``` + +**When:** Use after receiving real-time updates you trust. For optimistic updates that need rollback, use TanStack Query's `onMutate` pattern instead. + +#### 8.2 Paginated load-more with cache append + +**Source:** `src/hooks/queries/useRunQueries.ts` + +`fetchMoreRuns()` reads existing cache to determine offset, fetches the next page, and appends with `setQueryData`. Previously loaded runs are preserved. + +```typescript +export async function fetchMoreRuns(workflowId: string | null | undefined) { + const existing = queryClient.getQueryData(queryKey); + if (!existing || !existing.hasMore) return; + + const offset = existing.runs.length; + const response = await api.executions.listRuns({ limit: LOAD_MORE_LIMIT, offset }); + const normalized = rawRuns.map(normalizeRun); + + queryClient.setQueryData(queryKey, (old) => { + const existingIds = new Set(old.runs.map((r) => r.id)); + const newRuns = normalized.filter((r) => !existingIds.has(r.id)); + return { + runs: sortRuns([...old.runs, ...newRuns]), + hasMore: rawRuns.length >= LOAD_MORE_LIMIT, + }; + }); +} +``` + +--- + +## Anti-Patterns Reference + +### 1. useState + useEffect for API data + +```typescript +// BAD: Extra renders, no dedup, no caching, no error retry +const [data, setData] = useState([]); +useEffect(() => { + api.things.list().then(setData); +}, []); + +// GOOD: TanStack Query hook +const { data = [] } = useThings(); +``` + +### 2. Inline query keys + +```typescript +// BAD: Cache misses, invalidation doesn't work +useQuery({ queryKey: ['workflows', orgId], ... }); + +// GOOD: Factory from queryKeys.ts +useQuery({ queryKey: queryKeys.workflows.list(), ... }); +``` + +### 3. Full Zustand store subscription + +```typescript +// BAD: Re-renders on every store update +const { mode, inspectorTab } = useWorkflowUiStore(); + +// GOOD: Per-field selectors +const mode = useWorkflowUiStore((s) => s.mode); +``` + +### 4. N+1 queries (fetching in a loop) + +```typescript +// BAD: O(n) network requests +groups.map((g) => useMcpGroupServers(g.id)); // Also invalid — hooks in loops + +// GOOD: Batched endpoint +const { data } = useMcpGroupsWithServers(); +``` + +### 5. Copying query data into useState + +```typescript +// BAD: Stale data, double renders +const { data } = useWorkflows(); +const [sorted, setSorted] = useState([]); +useEffect(() => setSorted(sortBy(data)), [data]); + +// GOOD: useMemo +const sorted = useMemo(() => sortBy(data ?? []), [data]); +``` + +### 6. Static import of a lazy chunk + +```typescript +// BAD: Bloats initial bundle +import { WorkflowList } from '@/pages/WorkflowList'; + +// GOOD: Dynamic import +const WorkflowList = lazy(() => import('@/pages/WorkflowList').then(...)); +``` + +### 7. Missing staleTime for static data + +```typescript +// BAD: Refetches components every 30s (they rarely change) +useQuery({ queryKey: queryKeys.components.all(), queryFn: fetchComponentIndex }); + +// GOOD: Infinite cache for static reference data +useQuery({ + queryKey: queryKeys.components.all(), + queryFn: fetchComponentIndex, + staleTime: Infinity, + gcTime: Infinity, +}); +``` + +--- + +## New Feature Checklist + +Run through this before submitting a PR: + +### Data Fetching + +- [ ] All API data goes through a TanStack Query hook in `src/hooks/queries/` +- [ ] Query key added to `queryKeys.ts` using factory function pattern +- [ ] `staleTime` set appropriately (Infinity for static, 30s for user data, 0 for polling) +- [ ] `skipToken` used for conditional queries (not `enabled: false` with undefined queryFn) +- [ ] `select` transform used if data needs sorting/filtering before component use +- [ ] Static/reference data considered for `usePrefetchOnIdle.ts` +- [ ] Batched endpoint used if fetching list + per-item details + +### Bundle + +- [ ] New page routes use `React.lazy()` in `App.tsx` (not static import) +- [ ] New sidebar pages added to `routePrefetchMap` in `src/lib/prefetch-routes.ts` +- [ ] Rarely-visible heavy components use deferred load pattern (see CommandPalette) + +### Rendering + +- [ ] `useMemo` used for derived data from query results (not `useState`) +- [ ] Zustand selectors select per-field, not the full store +- [ ] `React.memo` considered for components that render frequently with stable props + +### Cache + +- [ ] Mutations call `queryClient.invalidateQueries()` after success +- [ ] Real-time updates use `upsertRunInCache()` pattern, not forcing a refetch + +--- + +## Known Gaps + +These are deliberate omissions, not oversights. Do not add them without discussing first. + +- **Virtual scrolling:** No virtual list library is installed. For long lists, use load-more pagination (see `fetchMoreRuns` pattern). +- **Image optimization:** No Next.js Image or similar. Not needed currently (no image-heavy pages). +- **Error boundaries:** No React ErrorBoundary components. Errors propagate to TanStack Query's `isError` state. +- **Service Worker / offline:** No PWA setup. All data requires network. diff --git a/frontend/docs/state.md b/frontend/docs/state.md index 51acbb3a..bf471bad 100644 --- a/frontend/docs/state.md +++ b/frontend/docs/state.md @@ -1,41 +1,243 @@ # Frontend State & Data Flow -ShipSec Studio’s UI relies on a handful of focused Zustand stores and shared Zod schemas. This guide explains how they interact with the backend contract and when to touch each layer. +ShipSec Studio's UI separates **server state** (data from the backend) from **client state** (UI-only concerns). Server state is managed exclusively through **TanStack Query** (`@tanstack/react-query`). Client-only state uses **Zustand** stores or local React state. + +## Golden Rule + +> **Never use `useState` + `useEffect` to fetch API data.** Always create or reuse a TanStack Query hook in `src/hooks/queries/`. ## Shared Types + - All schemas originate in `@shipsec/shared`. Import directly rather than re-declaring shapes. - `src/schemas/*` re-exports shared schemas or wraps them with UI-specific helpers. Any contract change starts with `docs/execution-contract.md` → shared package → frontend schema update. -## Core Stores +--- + +## Server State: TanStack Query + +All data fetched from the backend flows through TanStack Query hooks located in `src/hooks/queries/`. + +### Query Client Configuration + +`src/lib/queryClient.ts` configures sensible defaults: + +```typescript +new QueryClient({ + defaultOptions: { + queries: { + retry: 1, // One retry on failure + refetchOnWindowFocus: false, // No refetch on tab focus + staleTime: 30_000, // 30s default stale time + }, + }, +}); +``` + +### Query Key Conventions + +All query keys live in `src/lib/queryKeys.ts` and follow these rules: + +1. **Org-scoped**: Every key includes the current `organizationId` for multi-tenant isolation. +2. **Factory functions**: Keys are functions that return `readonly` tuples for type safety. +3. **Hierarchical**: Broader keys enable bulk invalidation (e.g., `['schedules']` invalidates all schedule queries). + +```typescript +// src/lib/queryKeys.ts +export const queryKeys = { + secrets: { all: () => ['secrets', getOrgScope()] as const }, + components: { all: () => ['components', getOrgScope()] as const }, + workflows: { + list: () => ['workflows', getOrgScope()] as const, + summary: () => ['workflowsSummary', getOrgScope()] as const, + detail: (id) => ['workflow', getOrgScope(), id] as const, + }, + integrations: { + providers: () => ['integrationProviders', getOrgScope()] as const, + providerConfig: (id) => ['providerConfig', getOrgScope(), id] as const, + }, + schedules: { all: (filters?) => ['schedules', getOrgScope(), filters] as const }, + webhooks: { all: (filters?) => ['webhooks', getOrgScope(), filters] as const }, + humanInputs: { all: (filters?) => ['humanInputs', getOrgScope(), filters] as const }, + mcpGroups: { + all: () => ['mcpGroups', getOrgScope()] as const, + servers: (groupId) => ['mcpGroupServers', getOrgScope(), groupId] as const, + }, + executions: { + nodeIO: (runId) => ['executionNodeIO', getOrgScope(), runId] as const, + result: (runId) => ['executionResult', getOrgScope(), runId] as const, + run: (runId) => ['executionRun', getOrgScope(), runId] as const, + }, + // ... see queryKeys.ts for the full list +}; +``` + +### Query Hook Files + +| Hook file | What it covers | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `useComponentQueries.ts` | Component catalogue (`useComponents`) | +| `useWorkflowQueries.ts` | Workflow list, summary, detail, runtime inputs (`useWorkflowsList`, `useWorkflowsSummary`, `useWorkflow`, `useWorkflowRuntimeInputs`) | +| `useScheduleQueries.ts` | Schedules CRUD (`useSchedules`, `useCreateSchedule`, etc.) | +| `useWebhookQueries.ts` | Webhooks CRUD + deliveries (`useWebhooks`, `useWebhook`, `useWebhookDeliveries`, `useCreateWebhook`, `useUpdateWebhook`, `useDeleteWebhook`) | +| `useSecretQueries.ts` | Secrets management (`useSecrets`) | +| `useApiKeyQueries.ts` | API key management (`useApiKeys`) | +| `useMcpGroupQueries.ts` | MCP groups, servers, templates (`useMcpGroups`, `useMcpGroupServers`, `useMcpGroupTemplates`) | +| `useHumanInputQueries.ts` | Human input / action center (`useHumanInputs`) | +| `useExecutionQueries.ts` | Execution data (`useExecutionNodeIO`, `useExecutionResult`, `useExecutionRun`) | +| `useRunQueries.ts` | Workflow runs (`useRuns`, `useRunDetail`) | +| `useArtifactQueries.ts` | Artifacts library + per-run artifacts | +| `useIntegrationQueries.ts` | Integration providers, connections, provider config (`useIntegrationProviders`, `useIntegrationConnections`, `useProviderConfig`) | + +### Writing a New Query Hook + +```typescript +// src/hooks/queries/useExampleQueries.ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '@/services/api'; +import { queryKeys } from '@/lib/queryKeys'; + +// 1. Add keys to queryKeys.ts first: +// examples: { all: (filters?) => ['examples', getOrgScope(), filters] as const } + +// 2. Read hook — wraps useQuery +export function useExamples(filters?: { status?: string }) { + return useQuery({ + queryKey: queryKeys.examples.all(filters as Record), + queryFn: () => api.examples.list(filters), + staleTime: 15_000, // Override default if needed + }); +} + +// 3. Mutation hook — wraps useMutation + invalidates cache +export function useDeleteExample() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => api.examples.delete(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['examples'] }); + }, + }); +} +``` + +### Cache Invalidation Patterns + +```typescript +// Invalidate all queries under a domain (broad) +queryClient.invalidateQueries({ queryKey: ['schedules'] }); + +// Invalidate a specific query (narrow) +queryClient.invalidateQueries({ queryKey: queryKeys.schedules.all({ workflowId }) }); +``` + +After mutations (create, update, delete), always invalidate the relevant queries rather than manually updating local state. + +### Prefetch on Idle + +`src/hooks/usePrefetchOnIdle.ts` warms the cache for commonly-needed data (components, workflow summaries) during browser idle time. This is called once in `AppLayout` so data is ready before the user navigates. -| Store | File | What it owns | -| --- | --- | --- | -| `useWorkflowStore` | `src/store/workflowStore.ts` | In-flight builder graph (nodes, edges, metadata) and persistence hooks. | -| `useWorkflowUiStore` | `src/store/workflowUiStore.ts` | Canvas UI toggles, panel sizing, minimap state. | -| `useComponentStore` | `src/store/componentStore.ts` | Cached component catalogue fetched via `api.components.list()`. Maintains slug ↔︎ id index. | -| `useExecutionStore` | `src/store/executionStore.ts` | Workflow run lifecycle, SSE stream wiring, log/event aggregation. | -| `useRunStore` | `src/store/runStore.ts` | Workflow-scoped run metadata cache with per-workflow fetching/invalidation hooks. | -| `useExecutionTimelineStore` | `src/store/executionTimelineStore.ts` | Timeline playback state and selection derived from `useRunStore`. | -| `useSecretStore` | `src/store/secretStore.ts` | Secret summaries + optimistic updates for the Secret Fetch UX. | +```typescript +// Runs during requestIdleCallback with a 5s timeout fallback +queryClient.prefetchQuery({ + queryKey: queryKeys.components.all(), + queryFn: fetchComponentIndex, + staleTime: 10 * 60_000, +}); +``` -Stores expose selectors (e.g. `getComponent`, `getNodeLogs`) to avoid manual traversal in components. Prefer selectors and derived helpers over duplicating logic inside React components. +When adding a new high-traffic query, consider adding it to the prefetch list. + +--- + +## Client State: Zustand + +Zustand stores handle **client-only UI state** that has no backend equivalent. They must never duplicate server data. + +| Store | File | What it owns | +| --------------------------- | ------------------------------------- | ----------------------------------------------------------------------- | +| `useWorkflowStore` | `src/store/workflowStore.ts` | In-flight builder graph (nodes, edges, metadata) and persistence hooks. | +| `useWorkflowUiStore` | `src/store/workflowUiStore.ts` | Canvas UI toggles, panel sizing, minimap state. | +| `useExecutionStore` | `src/store/executionStore.ts` | Workflow run lifecycle, SSE stream wiring, log/event aggregation. | +| `useExecutionTimelineStore` | `src/store/executionTimelineStore.ts` | Timeline playback state and selected run/node. | +| `useAuthStore` | `src/store/authStore.ts` | Auth session, org/user IDs (used by queryKeys for org scoping). | + +Stores expose selectors (e.g. `getNodeLogs`) to avoid manual traversal in components. Prefer selectors and derived helpers over duplicating logic inside React components. + +--- + +## Decision Guide: Where Does State Go? + +| Question | Answer | +| ------------------------------------------- | ----------------------------------------------------- | +| Does it come from the backend API? | **TanStack Query hook** in `src/hooks/queries/` | +| Is it UI-only and shared across components? | **Zustand store** in `src/store/` | +| Is it UI-only and scoped to one component? | **Local `useState`** | +| Is it derived from query data? | **`useMemo`** in the component, fed by the query hook | + +--- + +## Anti-Patterns (Do Not Do) + +```typescript +// BAD: Manual fetch with useState + useEffect +const [data, setData] = useState([]); +const [loading, setLoading] = useState(true); +useEffect(() => { + api.things + .list() + .then(setData) + .finally(() => setLoading(false)); +}, []); + +// GOOD: TanStack Query hook +const { data = [], isLoading } = useThings(); +``` + +```typescript +// BAD: Manual cache / dedup / TTL logic +const lastFetchRef = useRef(0); +if (Date.now() - lastFetchRef.current < 30_000) return; + +// GOOD: staleTime handles this automatically +useQuery({ queryKey: ..., queryFn: ..., staleTime: 30_000 }); +``` + +```typescript +// BAD: Optimistic local state after mutation +setItems((prev) => prev.filter((i) => i.id !== deletedId)); + +// GOOD: Invalidate and let the query refetch +queryClient.invalidateQueries({ queryKey: ['items'] }); +``` + +--- ## Execution Monitoring Pipeline + 1. `RunWorkflowDialog` invokes `useExecutionStore.startExecution`, which calls `api.executions.start()` (wrapping `POST /workflows/{id}/run`). 2. `monitorRun` seeds a poll + optional SSE stream (`api.executions.stream`). Responses are validated with `ExecutionStatusResponseSchema` and `TraceStreamEnvelopeSchema`. 3. `mergeEvents` dedupes trace events by id; `deriveNodeStates` converts those events into canvas node badges (see `WorkflowNode.tsx`). Separate helpers (`mergeLogEntries`) handle raw Loki log lines for the log viewer. 4. `useRunStore.fetchRuns` calls `api.executions.listRuns` scoped to the requested workflow and exposes cached metadata to `RunSelector`, inspectors, and `useExecutionTimelineStore`. When the backend contract expands (new trace fields, statuses), update: + - `docs/execution-contract.md` - `@shipsec/shared` schemas - Store merge helpers/tests under `src/store/__tests__` ## API Layer Expectations -- `src/services/api.ts` is the only place that touches the generated client. All responses are parsed through Zod before hitting stores. -- New endpoints should be added to `api.ts` first, with lightweight unit coverage if parsing logic branches. + +- `src/services/api.ts` is the only place that touches the generated client. All responses are parsed through Zod before hitting query hooks. +- New endpoints should be added to `api.ts` first, then wrapped in a query hook in `src/hooks/queries/`. + +## Performance Optimizations + +For the complete performance reference — bundle splitting, stale time strategy, idle prefetch, rendering optimization, Zustand selectors, and anti-patterns — see **[`docs/performance.md`](./performance.md)**. + +TL;DR: TanStack Query handles deduplication via query keys. Vite manual chunks separate vendor libraries. All page routes use `React.lazy()`. Static data uses `staleTime: Infinity`. ## Testing Strategy + - Store tests live in `src/store/__tests__/*.test.ts`. Mock `api` to emulate backend behaviour. -- Use `bun --cwd frontend run test` for Jest-style tests with Testing Library. +- Use `bun test` for tests with Testing Library. - Aim to cover merge/diff logic or any selector that massages data so runtime regressions surface quickly. diff --git a/frontend/docs/ui.md b/frontend/docs/ui.md index fda1ebc7..dde37948 100644 --- a/frontend/docs/ui.md +++ b/frontend/docs/ui.md @@ -3,14 +3,16 @@ ShipSec Studio’s UI is composed of three primary layers: layout scaffolding, workflow canvas primitives, and a shadcn-based component library. Use this guide when adding new surfaces or extending the canvas. ## Layout Skeleton + - `src/components/layout/TopBar.tsx` – Navigation + run/save controls. Keep it dumb; business logic belongs in stores/hooks. - `src/components/layout/Sidebar.tsx` – Component catalogue browser. Pull metadata through `useComponentStore`. - `src/components/layout/BottomPanel.tsx` – Tabs for Logs, Results, and History. Connects to execution/timeline stores. - `src/components/timeline/*` – Run history timeline cards and filters. -Keep layout components stateless and rely on Zustand selectors to avoid re-renders. +Keep layout components stateless. Use TanStack Query hooks for API data and Zustand selectors for client-only UI state to avoid re-renders. ## Workflow Canvas + - `src/components/workflow/Canvas.tsx` orchestrates React Flow nodes/edges, selection state, and drop handlers. - Node rendering (`WorkflowNode.tsx`) defers status colouring to `nodeStyles.ts` so badges stay consistent. - Configuration UI lives in `ConfigPanel.tsx` and `ParameterField.tsx`. Parameter metadata comes from backend component definitions. @@ -19,17 +21,21 @@ Keep layout components stateless and rely on Zustand selectors to avoid re-rende When introducing new node visuals, extend `nodeStyles.ts` first and keep Lucide icons + Tailwind tokens centralized. ## UI Component Library + - Shared primitives sit under `src/components/ui` and follow shadcn patterns (component + `*.styles.ts` when necessary). - Tailwind tokens rely on CSS variables defined in `src/index.css`. Do not hard-code colours—introduce a new variable instead. - Use `tailwind-merge` helpers for conditional classnames. ## Accessibility & UX + - All actionable elements require focus styles. Reuse existing `buttonVariants`/`badgeVariants`. - Provide aria labels for icon-only buttons (see `ComponentBadge.tsx` for reference). - The canvas should remain keyboard navigable: ensure tab index is maintained when adding overlays or drawers. ## Visual Testing Checklist + Before shipping UI changes: + 1. `bun --cwd frontend run lint` (includes Tailwind ordering rules). 2. `bun --cwd frontend run test` – ensure component snapshots/hooks pass. 3. Manual run through: load workflow, execute, verify logs stream and timeline updates. diff --git a/frontend/index.html b/frontend/index.html index c8b9ebb8..dfc7c74e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -44,6 +44,20 @@ + + + + diff --git a/frontend/package.json b/frontend/package.json index daae1f07..f85e6a52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -68,6 +68,8 @@ "@radix-ui/react-tooltip": "^1.2.8", "@shipsec/backend-client": "workspace:*", "@shipsec/shared": "workspace:*", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-query-devtools": "^5.91.3", "@uiw/react-markdown-preview": "^5.1.5", "ai": "^5.0.76", "ansi_up": "^6.0.6", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd833a1b..c679e206 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,12 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { QueryClientProvider } from '@tanstack/react-query'; +const ReactQueryDevtools = lazy(() => + import('@tanstack/react-query-devtools').then((mod) => ({ + default: mod.ReactQueryDevtools, + })), +); +import { queryClient } from '@/lib/queryClient'; import { ToastProvider } from '@/components/ui/toast-provider'; import { AppLayout } from '@/components/layout/AppLayout'; import { AuthProvider } from '@/auth/auth-context'; @@ -7,7 +14,9 @@ import { useAuthStoreIntegration } from '@/auth/store-integration'; import { ProtectedRoute } from '@/components/auth/ProtectedRoute'; import { AnalyticsRouterListener } from '@/features/analytics/AnalyticsRouterListener'; import { PostHogClerkBridge } from '@/features/analytics/PostHogClerkBridge'; -import { CommandPalette, useCommandPaletteKeyboard } from '@/features/command-palette'; +import { useCommandPaletteKeyboard } from '@/features/command-palette/useCommandPaletteKeyboard'; +import { useCommandPaletteStore } from '@/store/commandPaletteStore'; +import { Skeleton } from '@/components/ui/skeleton'; // Lazy-loaded page components const WorkflowList = lazy(() => @@ -56,6 +65,35 @@ const AnalyticsSettingsPage = lazy(() => import('@/pages/AnalyticsSettingsPage').then((m) => ({ default: m.AnalyticsSettingsPage })), ); +// Lazy-load CommandPalette — it pulls in the entire lucide-react barrel (~350KB) +const CommandPalette = lazy(() => + import('@/features/command-palette/CommandPalette').then((m) => ({ + default: m.CommandPalette, + })), +); + +function PageSkeleton() { + return ( +
+
+ + +
+
+ + + + +
+ + + +
+
+
+ ); +} + function AuthIntegration({ children }: { children: React.ReactNode }) { useAuthStoreIntegration(); return <>{children}; @@ -63,10 +101,23 @@ function AuthIntegration({ children }: { children: React.ReactNode }) { function CommandPaletteProvider({ children }: { children: React.ReactNode }) { useCommandPaletteKeyboard(); + const isOpen = useCommandPaletteStore((state) => state.isOpen); + const [hasOpened, setHasOpened] = useState(false); + + useEffect(() => { + if (isOpen && !hasOpened) { + setHasOpened(true); + } + }, [isOpen, hasOpened]); + return ( <> {children} - + {hasOpened && ( + + + + )} ); } @@ -75,73 +126,74 @@ function App() { return ( - - - - {/* Analytics wiring */} - - - - - -
-
- } - > - - } /> - - -
- } - /> - - - - } - /> - - - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } - /> - } /> - -
- - - - - + + + + + {/* Analytics wiring */} + + + + + }> + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + + + + + + + + {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEVTOOLS !== 'true' && ( + + + + )} + ); diff --git a/frontend/src/auth/store-integration.ts b/frontend/src/auth/store-integration.ts index ae522130..e6092eb6 100644 --- a/frontend/src/auth/store-integration.ts +++ b/frontend/src/auth/store-integration.ts @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useAuth, useAuthProvider } from './auth-context'; import { useAuthStore } from '../store/authStore'; +import { queryClient } from '@/lib/queryClient'; /** * Hook to integrate the new auth system with the existing Zustand store @@ -10,6 +11,25 @@ export function useAuthStoreIntegration() { const { user, token, isAuthenticated, isLoading, error } = useAuth(); const authProvider = useAuthProvider(); const { setAuthContext, clear: clearStore } = useAuthStore(); + const organizationId = useAuthStore((s) => s.organizationId); + const prevOrgRef = useRef(organizationId); + + // Clear query cache on logout + useEffect(() => { + if (!isAuthenticated) { + queryClient.cancelQueries(); + queryClient.clear(); + } + }, [isAuthenticated]); + + // Clear query cache on org change + useEffect(() => { + if (prevOrgRef.current !== organizationId) { + queryClient.cancelQueries(); + queryClient.clear(); + prevOrgRef.current = organizationId; + } + }, [organizationId]); useEffect(() => { if (isLoading) { diff --git a/frontend/src/components/artifacts/RunArtifactsPanel.tsx b/frontend/src/components/artifacts/RunArtifactsPanel.tsx index 2953fef3..b2d99de9 100644 --- a/frontend/src/components/artifacts/RunArtifactsPanel.tsx +++ b/frontend/src/components/artifacts/RunArtifactsPanel.tsx @@ -1,7 +1,9 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Download, RefreshCw, Copy, ExternalLink } from 'lucide-react'; import type { ArtifactMetadata } from '@shipsec/shared'; -import { useArtifactStore } from '@/store/artifactStore'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRunArtifacts, useDownloadArtifact } from '@/hooks/queries/useArtifactQueries'; +import { queryKeys } from '@/lib/queryKeys'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { getRemoteUploads } from '@/utils/artifacts'; @@ -29,10 +31,13 @@ interface RunArtifactsPanelProps { } export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { - const entry = useArtifactStore((state) => (runId ? state.runArtifacts[runId] : undefined)); - const fetchRunArtifacts = useArtifactStore((state) => state.fetchRunArtifacts); - const downloadArtifact = useArtifactStore((state) => state.downloadArtifact); - const downloading = useArtifactStore((state) => state.downloading); + const queryClient = useQueryClient(); + const { + data: artifacts = [], + isLoading: isLoadingArtifacts, + error, + } = useRunArtifacts(runId ?? undefined); + const downloadArtifactMutation = useDownloadArtifact(); const [copiedId, setCopiedId] = useState(null); const [copiedRemoteUri, setCopiedRemoteUri] = useState(null); @@ -48,11 +53,11 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { } }, []); - useEffect(() => { + const handleRefresh = useCallback(() => { if (runId) { - void fetchRunArtifacts(runId); + void queryClient.invalidateQueries({ queryKey: queryKeys.artifacts.byRun(runId) }); } - }, [runId, fetchRunArtifacts]); + }, [runId, queryClient]); const content = useMemo(() => { if (!runId) { @@ -63,7 +68,7 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { ); } - if (!entry || entry.loading) { + if (isLoadingArtifacts) { return (
Loading artifacts… @@ -71,16 +76,11 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { ); } - if (entry.error) { + if (error) { return (
- {entry.error} - @@ -88,7 +88,7 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { ); } - if (entry.artifacts.length === 0) { + if (artifacts.length === 0) { return (
No artifacts were saved for this run. @@ -109,11 +109,13 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { - {entry.artifacts.map((artifact) => ( + {artifacts.map((artifact: ArtifactMetadata) => ( downloadArtifact(artifact, { runId })} + onDownload={() => + downloadArtifactMutation.mutate({ artifact, runId: runId ?? undefined }) + } onCopy={() => handleCopy(artifact.id)} copied={copiedId === artifact.id} onCopyRemoteUri={async (uri: string) => { @@ -128,7 +130,10 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { } }} copiedRemoteUri={copiedRemoteUri} - isDownloading={Boolean(downloading[artifact.id])} + isDownloading={ + downloadArtifactMutation.isPending && + downloadArtifactMutation.variables?.artifact.id === artifact.id + } /> ))} @@ -137,19 +142,20 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { ); }, [ runId, - entry, - fetchRunArtifacts, - downloadArtifact, - downloading, + artifacts, + isLoadingArtifacts, + error, + handleRefresh, + downloadArtifactMutation, handleCopy, copiedId, copiedRemoteUri, ]); const handleDownloadAll = () => { - if (!entry || !entry.artifacts.length) return; - entry.artifacts.forEach((artifact) => { - downloadArtifact(artifact, { runId: runId || undefined }); + if (!artifacts.length) return; + artifacts.forEach((artifact: ArtifactMetadata) => { + downloadArtifactMutation.mutate({ artifact, runId: runId ?? undefined }); }); }; @@ -167,15 +173,15 @@ export function RunArtifactsPanel({ runId }: RunArtifactsPanelProps) { type="button" variant="ghost" size="sm" - onClick={() => fetchRunArtifacts(runId, true)} - disabled={entry?.loading} + onClick={handleRefresh} + disabled={isLoadingArtifacts} className="gap-2" > Refresh ) : null} - {entry?.artifacts && entry.artifacts.length > 0 && ( + {artifacts.length > 0 && (
diff --git a/frontend/src/components/timeline/ExecutionInspector.tsx b/frontend/src/components/timeline/ExecutionInspector.tsx index fbc208e8..119fb18d 100644 --- a/frontend/src/components/timeline/ExecutionInspector.tsx +++ b/frontend/src/components/timeline/ExecutionInspector.tsx @@ -28,9 +28,8 @@ import { useExecutionStore } from '@/store/executionStore'; import { useWorkflowExecution } from '@/hooks/useWorkflowExecution'; import { useWorkflowUiStore } from '@/store/workflowUiStore'; import { useWorkflowStore } from '@/store/workflowStore'; -import { useArtifactStore } from '@/store/artifactStore'; import { useToast } from '@/components/ui/use-toast'; -import { useRunStore } from '@/store/runStore'; +import { useWorkflowRuns } from '@/hooks/queries/useRunQueries'; import { cn } from '@/lib/utils'; import type { ExecutionLog } from '@/schemas/execution'; import { RunArtifactsPanel } from '@/components/artifacts/RunArtifactsPanel'; @@ -117,12 +116,10 @@ export function ExecutionInspector({ onRerunRun }: ExecutionInspectorProps = {}) const { id: workflowId, currentVersion: currentWorkflowVersion } = useWorkflowStore( (state) => state.metadata, ); - const workflowCacheKey = workflowId ?? '__global__'; - const scopedRuns = useRunStore((state) => state.cache[workflowCacheKey]?.runs); - const runs = scopedRuns ?? []; + const { data: runsPage, isLoading: isLoadingRuns } = useWorkflowRuns(workflowId); + const runs = runsPage?.runs ?? []; const { status, runStatus, stopExecution, runId: liveRunId } = useWorkflowExecution(); const { inspectorTab, setInspectorTab } = useWorkflowUiStore(); - const fetchRunArtifacts = useArtifactStore((state) => state.fetchRunArtifacts); const { getDisplayLogs, setLogMode } = useExecutionStore(); const [logModal, setLogModal] = useState<{ open: boolean; message: string; title: string }>({ open: false, @@ -226,12 +223,6 @@ export function ExecutionInspector({ onRerunRun }: ExecutionInspectorProps = {}) } }, [selectedRun, toast]); - useEffect(() => { - if (selectedRunId && inspectorTab === 'artifacts') { - void fetchRunArtifacts(selectedRunId); - } - }, [selectedRunId, inspectorTab, fetchRunArtifacts]); - useEffect(() => { // Switch log mode based on timeline playback mode if (playbackMode === 'live') { @@ -262,7 +253,11 @@ export function ExecutionInspector({ onRerunRun }: ExecutionInspectorProps = {})