From 6747a497fc759cbcc2667dcc14c0ae89b30689ca Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 18 Sep 2025 11:04:06 -0700 Subject: [PATCH 1/6] fix(migrations): upgrade drizzle-kit in migrations container (#1374) * fix(migrations): upgrade drizzle-kit in migrations container * fix comments * rm unused file --- apps/sim/test-self-hosting.ts | 22 ---------------------- bun.lock | 10 +++++----- docker/db.Dockerfile | 5 ++--- package.json | 6 +++--- packages/db/package.json | 4 ++-- 5 files changed, 12 insertions(+), 35 deletions(-) delete mode 100644 apps/sim/test-self-hosting.ts diff --git a/apps/sim/test-self-hosting.ts b/apps/sim/test-self-hosting.ts deleted file mode 100644 index 2e664a5681..0000000000 --- a/apps/sim/test-self-hosting.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createApiKey } from './lib/api-key/auth' - -console.log('=== Testing self-hosting scenario (no API_ENCRYPTION_KEY) ===') - -// Check environment -console.log('ENCRYPTION_KEY:', `${process.env.ENCRYPTION_KEY?.slice(0, 10)}...`) -console.log('API_ENCRYPTION_KEY:', process.env.API_ENCRYPTION_KEY) - -// Ensure API_ENCRYPTION_KEY is not set -process.env.API_ENCRYPTION_KEY = undefined -console.log('API_ENCRYPTION_KEY after delete:', process.env.API_ENCRYPTION_KEY) - -try { - const result = await createApiKey(true) - console.log('Key generated:', !!result.key) - console.log('Encrypted key generated:', !!result.encryptedKey) - console.log('Encrypted key value:', result.encryptedKey) - console.log('Are they the same?', result.key === result.encryptedKey) - console.log('Would validation pass?', !!result.encryptedKey) -} catch (error) { - console.error('Error in createApiKey:', error) -} diff --git a/bun.lock b/bun.lock index 0ea9db503f..408c4672a7 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@t3-oss/env-nextjs": "0.13.4", "@vercel/analytics": "1.5.0", "bcryptjs": "3.0.2", - "drizzle-orm": "^0.41.0", + "drizzle-orm": "^0.44.5", "geist": "^1.4.2", "mongodb": "6.19.0", "postgres": "^3.4.5", @@ -21,7 +21,7 @@ "@biomejs/biome": "2.0.0-beta.5", "@next/env": "15.4.1", "@types/bcryptjs": "3.0.0", - "drizzle-kit": "^0.31.1", + "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", "turbo": "2.5.6", @@ -224,7 +224,7 @@ "typescript": "^5.7.3", }, "peerDependencies": { - "drizzle-orm": "^0.41.0", + "drizzle-orm": "^0.44.5", "postgres": "^3.4.5", }, }, @@ -247,7 +247,7 @@ ], "overrides": { "@next/env": "15.4.1", - "drizzle-orm": "^0.41.0", + "drizzle-orm": "^0.44.5", "next": "15.4.1", "postgres": "^3.4.5", "react": "19.1.0", @@ -1980,7 +1980,7 @@ "drizzle-kit": ["drizzle-kit@0.31.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-tCPWVZWZqWVx2XUsVpJRnH9Mx0ClVOf5YUHerZ5so1OKSlqww4zy1R5ksEdGRcO3tM3zj0PYN6V48TbQCL1RfA=="], - "drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], + "drizzle-orm": ["drizzle-orm@0.44.5", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-jBe37K7d8ZSKptdKfakQFdeljtu3P2Cbo7tJoJSVZADzIKOBo9IAJPOmMsH2bZl90bZgh8FQlD8BjxXA/zuBkQ=="], "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], diff --git a/docker/db.Dockerfile b/docker/db.Dockerfile index 1e90bc301b..681cd138f4 100644 --- a/docker/db.Dockerfile +++ b/docker/db.Dockerfile @@ -8,9 +8,8 @@ WORKDIR /app COPY package.json bun.lock turbo.json ./ COPY packages/db/package.json ./packages/db/package.json -# Install minimal dependencies in one layer -RUN bun install --omit dev --ignore-scripts && \ - bun install --omit dev --ignore-scripts drizzle-kit drizzle-orm postgres +# Install dependencies +RUN bun install --ignore-scripts # ======================================== # Runner Stage: Production Environment diff --git a/package.json b/package.json index c81f4b1549..3e4b55fdd8 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "react-dom": "19.1.0", "next": "15.4.1", "@next/env": "15.4.1", - "drizzle-orm": "^0.41.0", + "drizzle-orm": "^0.44.5", "postgres": "^3.4.5" }, "dependencies": { @@ -38,7 +38,7 @@ "@t3-oss/env-nextjs": "0.13.4", "@vercel/analytics": "1.5.0", "bcryptjs": "3.0.2", - "drizzle-orm": "^0.41.0", + "drizzle-orm": "^0.44.5", "geist": "^1.4.2", "mongodb": "6.19.0", "postgres": "^3.4.5", @@ -51,7 +51,7 @@ "@biomejs/biome": "2.0.0-beta.5", "@next/env": "15.4.1", "@types/bcryptjs": "3.0.0", - "drizzle-kit": "^0.31.1", + "drizzle-kit": "^0.31.4", "husky": "9.1.7", "lint-staged": "16.0.0", "turbo": "2.5.6" diff --git a/packages/db/package.json b/packages/db/package.json index e4f689b861..2c02997457 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -25,14 +25,14 @@ "type-check": "tsc --noEmit" }, "peerDependencies": { - "drizzle-orm": "^0.41.0", + "drizzle-orm": "^0.44.5", "postgres": "^3.4.5" }, "devDependencies": { "typescript": "^5.7.3" }, "overrides": { - "drizzle-orm": "^0.41.0", + "drizzle-orm": "^0.44.5", "postgres": "^3.4.5" } } From 5d96484501e84b871b8afb44805a511c7f752bd1 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 18 Sep 2025 11:04:22 -0700 Subject: [PATCH 2/6] fix(variables): remove quote stripping from short & long inputs (#1375) * fix(variables): remove quote stripping from short & long inputs * restore env * remove quote stripping everywhere * remove unused file --- .../sub-block/components/combobox.tsx | 2 +- .../sub-block/components/long-input.tsx | 2 +- .../sub-block/components/short-input.tsx | 2 +- .../components/starter/input-format.tsx | 3 +-- .../mcp-server-modal/mcp-server-modal.tsx | 6 +++--- .../settings-modal/components/mcp/mcp.tsx | 12 ++++++------ apps/sim/components/ui/formatted-text.tsx | 19 ++----------------- .../lib/variables/variable-manager.test.ts | 17 ----------------- apps/sim/lib/variables/variable-manager.ts | 13 ------------- 9 files changed, 15 insertions(+), 61 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx index 9e30c797ce..a72e654fba 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/combobox.tsx @@ -432,7 +432,7 @@ export function ComboBox({ style={{ right: '42px' }} >
- {formatDisplayText(displayValue, true)} + {formatDisplayText(displayValue)}
{/* Chevron button */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx index 52e8e81b56..8560ddbaa3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/long-input.tsx @@ -406,7 +406,7 @@ export function LongInput({ overflow: 'hidden', }} > - {formatDisplayText(value?.toString() ?? '', true)} + {formatDisplayText(value?.toString() ?? '')} {/* Wand Button */} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx index bb95ae7504..8fbd00d76b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/short-input.tsx @@ -417,7 +417,7 @@ export function ShortInput({ > {password && !isFocused ? '•'.repeat(value?.toString().length ?? 0) - : formatDisplayText(value?.toString() ?? '', true)} + : formatDisplayText(value?.toString() ?? '')} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx index 001235e5c0..af35818e26 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/starter/input-format.tsx @@ -471,8 +471,7 @@ export function FieldFormat({ style={{ scrollbarWidth: 'none', minWidth: 'fit-content' }} > {formatDisplayText( - (localValues[field.id] ?? field.value ?? '')?.toString(), - true + (localValues[field.id] ?? field.value ?? '')?.toString() )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx index cbe6828f70..d31a79b190 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/tool-input/components/mcp-server-modal/mcp-server-modal.tsx @@ -337,7 +337,7 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe className='whitespace-nowrap' style={{ transform: `translateX(-${urlScrollLeft}px)` }} > - {formatDisplayText(formData.url || '', true)} + {formatDisplayText(formData.url || '')} @@ -389,7 +389,7 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`, }} > - {formatDisplayText(key || '', true)} + {formatDisplayText(key || '')} @@ -417,7 +417,7 @@ export function McpServerModal({ open, onOpenChange, onServerCreated }: McpServe transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`, }} > - {formatDisplayText(value || '', true)} + {formatDisplayText(value || '')} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx index d96c6ceb9b..7f1ce4b6f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/mcp/mcp.tsx @@ -399,7 +399,7 @@ export function MCP() { className='whitespace-nowrap' style={{ transform: `translateX(-${urlScrollLeft}px)` }} > - {formatDisplayText(formData.url || '', true)} + {formatDisplayText(formData.url || '')} @@ -464,7 +464,7 @@ export function MCP() { transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`, }} > - {formatDisplayText(key || '', true)} + {formatDisplayText(key || '')} @@ -500,7 +500,7 @@ export function MCP() { transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`, }} > - {formatDisplayText(value || '', true)} + {formatDisplayText(value || '')} @@ -778,7 +778,7 @@ export function MCP() { className='whitespace-nowrap' style={{ transform: `translateX(-${urlScrollLeft}px)` }} > - {formatDisplayText(formData.url || '', true)} + {formatDisplayText(formData.url || '')} @@ -845,7 +845,7 @@ export function MCP() { transform: `translateX(-${headerScrollLeft[`key-${index}`] || 0}px)`, }} > - {formatDisplayText(key || '', true)} + {formatDisplayText(key || '')} @@ -881,7 +881,7 @@ export function MCP() { transform: `translateX(-${headerScrollLeft[`value-${index}`] || 0}px)`, }} > - {formatDisplayText(value || '', true)} + {formatDisplayText(value || '')} diff --git a/apps/sim/components/ui/formatted-text.tsx b/apps/sim/components/ui/formatted-text.tsx index 116d79fe50..b61df9be46 100644 --- a/apps/sim/components/ui/formatted-text.tsx +++ b/apps/sim/components/ui/formatted-text.tsx @@ -1,33 +1,19 @@ 'use client' import type { ReactNode } from 'react' -import { VariableManager } from '@/lib/variables/variable-manager' /** * Formats text by highlighting block references (<...>) and environment variables ({{...}}) * Used in code editor, long inputs, and short inputs for consistent syntax highlighting * * @param text The text to format - * @param stripQuotes Whether to strip unnecessary quotes from the text (for plain text variables) */ -export function formatDisplayText(text: string, stripQuotes = false): ReactNode[] { +export function formatDisplayText(text: string): ReactNode[] { if (!text) return [] - // If stripQuotes is true, remove surrounding quotes that might have been added - // This is needed when displaying plain type variables in inputs - let processedText = text - if (stripQuotes && typeof text === 'string') { - // Use VariableManager to determine if quotes should be stripped - if (VariableManager.shouldStripQuotesForDisplay(text)) { - processedText = text.slice(1, -1) - } - } - - // Split the text by both tag patterns and {{ENV_VAR}} - const parts = processedText.split(/(<[^>]+>|\{\{[^}]+\}\})/g) + const parts = text.split(/(<[^>]+>|\{\{[^}]+\}\})/g) return parts.map((part, index) => { - // Handle block references if (part.startsWith('<') && part.endsWith('>')) { return ( @@ -36,7 +22,6 @@ export function formatDisplayText(text: string, stripQuotes = false): ReactNode[ ) } - // Handle environment variables if (part.match(/^\{\{[^}]+\}\}$/)) { return ( diff --git a/apps/sim/lib/variables/variable-manager.test.ts b/apps/sim/lib/variables/variable-manager.test.ts index 5ee230618f..850f2bfc1a 100644 --- a/apps/sim/lib/variables/variable-manager.test.ts +++ b/apps/sim/lib/variables/variable-manager.test.ts @@ -215,21 +215,4 @@ describe('VariableManager', () => { expect(VariableManager.formatForCodeContext(undefined, 'number')).toBe('undefined') }) }) - - describe('shouldStripQuotesForDisplay', () => { - it.concurrent('should identify strings that need quotes stripped', () => { - expect(VariableManager.shouldStripQuotesForDisplay('"hello world"')).toBe(true) - expect(VariableManager.shouldStripQuotesForDisplay("'hello world'")).toBe(true) - expect(VariableManager.shouldStripQuotesForDisplay('hello world')).toBe(false) - expect(VariableManager.shouldStripQuotesForDisplay('""')).toBe(false) // Too short - expect(VariableManager.shouldStripQuotesForDisplay("''")).toBe(false) // Too short - }) - - it.concurrent('should handle edge cases', () => { - expect(VariableManager.shouldStripQuotesForDisplay('')).toBe(false) - expect(VariableManager.shouldStripQuotesForDisplay(null as any)).toBe(false) - expect(VariableManager.shouldStripQuotesForDisplay(undefined as any)).toBe(false) - expect(VariableManager.shouldStripQuotesForDisplay(42 as any)).toBe(false) - }) - }) }) diff --git a/apps/sim/lib/variables/variable-manager.ts b/apps/sim/lib/variables/variable-manager.ts index e3a2100570..d2db3bd109 100644 --- a/apps/sim/lib/variables/variable-manager.ts +++ b/apps/sim/lib/variables/variable-manager.ts @@ -225,7 +225,6 @@ export class VariableManager { return typeof value === 'string' ? value : String(value) } if (type === 'string') { - // For backwards compatibility, add quotes only for string type in code context return typeof value === 'string' ? JSON.stringify(value) : VariableManager.formatValue(value, type, 'code') @@ -233,16 +232,4 @@ export class VariableManager { return VariableManager.formatValue(value, type, 'code') } - - /** - * Determines whether quotes should be stripped for display. - */ - static shouldStripQuotesForDisplay(value: string): boolean { - if (!value || typeof value !== 'string') return false - - return ( - (value.startsWith('"') && value.endsWith('"') && value.length > 2) || - (value.startsWith("'") && value.endsWith("'") && value.length > 2) - ) - } } From cd084e82365cc70fbac8a2cba494c9dced24a68e Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 18 Sep 2025 11:29:02 -0700 Subject: [PATCH 3/6] fix(actions): updated i18n gh action to use PAT instead of default token (#1377) --- .github/workflows/i18n.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml index 3562958db7..c7806facfd 100644 --- a/.github/workflows/i18n.yml +++ b/.github/workflows/i18n.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.GH_PAT }} fetch-depth: 0 - name: Setup Bun @@ -53,7 +53,7 @@ jobs: if: steps.changes.outputs.changes == 'true' uses: peter-evans/create-pull-request@v5 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.GH_PAT }} commit-message: "feat(i18n): update translations" title: "🌐 Auto-update translations" body: | From 3905d1cb8163482056163a57fe40f0ca2e189477 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Thu, 18 Sep 2025 11:40:36 -0700 Subject: [PATCH 4/6] fix(selectors): gdrive and slack selectors inf loops (#1376) * fix(selectors): gdrive and slack selectors inf loops * remove comment --- .../components/slack-channel-selector.tsx | 12 +++++++++++- .../file-selector/components/google-drive-picker.tsx | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx index 43dd60e1b5..fee66c7f94 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/channel-selector/components/slack-channel-selector.tsx @@ -58,12 +58,21 @@ export function SlackChannelSelector({ body: JSON.stringify({ credential, workflowId }), }) - if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) + if (!res.ok) { + const errorData = await res + .json() + .catch(() => ({ error: `HTTP error! status: ${res.status}` })) + setError(errorData.error || `HTTP error! status: ${res.status}`) + setChannels([]) + setInitialFetchDone(true) + return + } const data = await res.json() if (data.error) { setError(data.error) setChannels([]) + setInitialFetchDone(true) } else { setChannels(data.channels) setInitialFetchDone(true) @@ -72,6 +81,7 @@ export function SlackChannelSelector({ if ((err as Error).name === 'AbortError') return setError((err as Error).message) setChannels([]) + setInitialFetchDone(true) } finally { setLoading(false) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx index 918bf24cd6..89d57563ca 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/components/file-selector/components/google-drive-picker.tsx @@ -100,7 +100,9 @@ export function GoogleDrivePicker({ if (response.ok) { const data = await response.json() setCredentials(data.credentials) - // Do not auto-select. Respect persisted credential via prop when provided. + if (credentialId && !data.credentials.some((c: any) => c.id === credentialId)) { + setSelectedCredentialId('') + } } } catch (error) { logger.error('Error fetching credentials:', { error }) @@ -151,6 +153,14 @@ export function GoogleDrivePicker({ onChange('') onFileInfoChange?.(null) } + + if (response.status === 401) { + logger.info('Credential unauthorized (401), clearing selection and prompting re-auth') + setSelectedFileId('') + onChange('') + onFileInfoChange?.(null) + setShowOAuthModal(true) + } } return null } catch (error) { From eb1e90bb7f7e50ceab23fc2acaf7dc05e8cccd02 Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 18 Sep 2025 13:58:44 -0700 Subject: [PATCH 5/6] improvement(search): added more granular logs search, added logs export, improved overall search experience (#1378) * improvement(search): added more granular logs search, added logs export, improved overall search experience * updated tests --- apps/sim/app/api/logs/export/route.ts | 200 ++++++++++++++++++ apps/sim/app/api/logs/route.ts | 14 ++ .../filters/components/workflow.tsx | 18 +- .../logs/components/search/search.tsx | 89 +++++++- .../logs/hooks/use-autocomplete.ts | 91 ++++++-- .../app/workspace/[workspaceId]/logs/logs.tsx | 145 +++++++++---- apps/sim/lib/logs/query-parser.ts | 18 ++ apps/sim/lib/logs/search-suggestions.test.ts | 21 +- apps/sim/lib/logs/search-suggestions.ts | 70 +++++- 9 files changed, 584 insertions(+), 82 deletions(-) create mode 100644 apps/sim/app/api/logs/export/route.ts diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts new file mode 100644 index 0000000000..645d861333 --- /dev/null +++ b/apps/sim/app/api/logs/export/route.ts @@ -0,0 +1,200 @@ +import { db } from '@sim/db' +import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema' +import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' + +const logger = createLogger('LogsExportAPI') + +export const revalidate = 0 + +const ExportParamsSchema = z.object({ + level: z.string().optional(), + workflowIds: z.string().optional(), + folderIds: z.string().optional(), + triggers: z.string().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + search: z.string().optional(), + workflowName: z.string().optional(), + folderName: z.string().optional(), + workspaceId: z.string(), +}) + +function escapeCsv(value: any): string { + if (value === null || value === undefined) return '' + const str = String(value) + if (/[",\n]/.test(str)) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export async function GET(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const userId = session.user.id + const { searchParams } = new URL(request.url) + const params = ExportParamsSchema.parse(Object.fromEntries(searchParams.entries())) + + const selectColumns = { + id: workflowExecutionLogs.id, + workflowId: workflowExecutionLogs.workflowId, + executionId: workflowExecutionLogs.executionId, + level: workflowExecutionLogs.level, + trigger: workflowExecutionLogs.trigger, + startedAt: workflowExecutionLogs.startedAt, + endedAt: workflowExecutionLogs.endedAt, + totalDurationMs: workflowExecutionLogs.totalDurationMs, + cost: workflowExecutionLogs.cost, + executionData: workflowExecutionLogs.executionData, + workflowName: workflow.name, + } + + let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId) + + if (params.level && params.level !== 'all') { + conditions = and(conditions, eq(workflowExecutionLogs.level, params.level)) + } + + if (params.workflowIds) { + const workflowIds = params.workflowIds.split(',').filter(Boolean) + if (workflowIds.length > 0) conditions = and(conditions, inArray(workflow.id, workflowIds)) + } + + if (params.folderIds) { + const folderIds = params.folderIds.split(',').filter(Boolean) + if (folderIds.length > 0) conditions = and(conditions, inArray(workflow.folderId, folderIds)) + } + + if (params.triggers) { + const triggers = params.triggers.split(',').filter(Boolean) + if (triggers.length > 0 && !triggers.includes('all')) { + conditions = and(conditions, inArray(workflowExecutionLogs.trigger, triggers)) + } + } + + if (params.startDate) { + conditions = and(conditions, gte(workflowExecutionLogs.startedAt, new Date(params.startDate))) + } + if (params.endDate) { + conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate))) + } + + if (params.search) { + const term = `%${params.search}%` + conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${term}`) + } + if (params.workflowName) { + const nameTerm = `%${params.workflowName}%` + conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`) + } + if (params.folderName) { + const folderTerm = `%${params.folderName}%` + conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`) + } + + const header = [ + 'startedAt', + 'level', + 'workflow', + 'trigger', + 'durationMs', + 'costTotal', + 'workflowId', + 'executionId', + 'message', + 'traceSpans', + ].join(',') + + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start: async (controller) => { + controller.enqueue(encoder.encode(`${header}\n`)) + const pageSize = 1000 + let offset = 0 + try { + while (true) { + const rows = await db + .select(selectColumns) + .from(workflowExecutionLogs) + .innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id)) + .innerJoin( + permissions, + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workflow.workspaceId), + eq(permissions.userId, userId) + ) + ) + .where(conditions) + .orderBy(desc(workflowExecutionLogs.startedAt)) + .limit(pageSize) + .offset(offset) + + if (!rows.length) break + + for (const r of rows as any[]) { + let message = '' + let traces: any = null + try { + const ed = (r as any).executionData + if (ed) { + if (ed.finalOutput) + message = + typeof ed.finalOutput === 'string' + ? ed.finalOutput + : JSON.stringify(ed.finalOutput) + if (ed.message) message = ed.message + if (ed.traceSpans) traces = ed.traceSpans + } + } catch {} + const line = [ + escapeCsv(r.startedAt?.toISOString?.() || r.startedAt), + escapeCsv(r.level), + escapeCsv(r.workflowName), + escapeCsv(r.trigger), + escapeCsv(r.totalDurationMs ?? ''), + escapeCsv(r.cost?.total ?? r.cost?.value?.total ?? ''), + escapeCsv(r.workflowId ?? ''), + escapeCsv(r.executionId ?? ''), + escapeCsv(message), + escapeCsv(traces ? JSON.stringify(traces) : ''), + ].join(',') + controller.enqueue(encoder.encode(`${line}\n`)) + } + + offset += pageSize + } + controller.close() + } catch (e: any) { + logger.error('Export stream error', { error: e?.message }) + try { + controller.error(e) + } catch {} + } + }, + }) + + const ts = new Date().toISOString().replace(/[:.]/g, '-') + const filename = `logs-${ts}.csv` + + return new NextResponse(stream as any, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-cache', + }, + }) + } catch (error: any) { + logger.error('Export error', { error: error?.message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index a359a2f6db..f34b0ebe1a 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -22,6 +22,8 @@ const QueryParamsSchema = z.object({ startDate: z.string().optional(), endDate: z.string().optional(), search: z.string().optional(), + workflowName: z.string().optional(), + folderName: z.string().optional(), workspaceId: z.string(), }) @@ -155,6 +157,18 @@ export async function GET(request: NextRequest) { conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`) } + // Filter by workflow name (from advanced search input) + if (params.workflowName) { + const nameTerm = `%${params.workflowName}%` + conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`) + } + + // Filter by folder name (best-effort text match when present on workflows) + if (params.folderName) { + const folderTerm = `%${params.folderName}%` + conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`) + } + // Execute the query using the optimized join const logs = await baseQuery .where(conditions) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx index 22ced167b1..d694dc6cc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/filters/components/workflow.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Check, ChevronDown } from 'lucide-react' +import { useParams } from 'next/navigation' import { Button } from '@/components/ui/button' import { Command, @@ -26,20 +27,27 @@ interface WorkflowOption { } export default function Workflow() { - const { workflowIds, toggleWorkflowId, setWorkflowIds } = useFilterStore() + const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore() + const params = useParams() + const workspaceId = params?.workspaceId as string | undefined const [workflows, setWorkflows] = useState([]) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') - // Fetch all available workflows from the API useEffect(() => { const fetchWorkflows = async () => { try { setLoading(true) - const response = await fetch('/api/workflows') + const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : '' + const response = await fetch(`/api/workflows${query}`) if (response.ok) { const { data } = await response.json() - const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({ + const scoped = Array.isArray(data) + ? folderIds.length > 0 + ? data.filter((w: any) => (w.folderId ? folderIds.includes(w.folderId) : false)) + : data + : [] + const workflowOptions: WorkflowOption[] = scoped.map((workflow: any) => ({ id: workflow.id, name: workflow.name, color: workflow.color || '#3972F6', @@ -54,7 +62,7 @@ export default function Workflow() { } fetchWorkflows() - }, []) + }, [workspaceId, folderIds]) const getSelectedWorkflowsText = () => { if (workflowIds.length === 0) return 'All workflows' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx index 051aa2db18..b019a63734 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/search/search.tsx @@ -1,7 +1,7 @@ 'use client' -import { useMemo } from 'react' -import { Search, X } from 'lucide-react' +import { useEffect, useMemo } from 'react' +import { Loader2, Search, X } from 'lucide-react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -17,6 +17,7 @@ interface AutocompleteSearchProps { availableWorkflows?: string[] availableFolders?: string[] className?: string + onOpenChange?: (open: boolean) => void } export function AutocompleteSearch({ @@ -26,6 +27,7 @@ export function AutocompleteSearch({ availableWorkflows = [], availableFolders = [], className, + onOpenChange, }: AutocompleteSearchProps) { const suggestionEngine = useMemo(() => { return new SearchSuggestions(availableWorkflows, availableFolders) @@ -42,6 +44,8 @@ export function AutocompleteSearch({ handleKeyDown, handleFocus, handleBlur, + reset: resetAutocomplete, + closeDropdown, } = useAutocomplete({ getSuggestions: (inputValue, cursorPos) => suggestionEngine.getSuggestions(inputValue, cursorPos), @@ -52,10 +56,39 @@ export function AutocompleteSearch({ debounceMs: 100, }) + const clearAll = () => { + resetAutocomplete() + closeDropdown() + onChange('') + if (inputRef.current) { + inputRef.current.focus() + } + } + const parsedQuery = parseQuery(value) const hasFilters = parsedQuery.filters.length > 0 const hasTextSearch = parsedQuery.textSearch.length > 0 + const listboxId = 'logs-search-listbox' + const inputId = 'logs-search-input' + + useEffect(() => { + onOpenChange?.(state.isOpen) + }, [state.isOpen, onOpenChange]) + + useEffect(() => { + if (!state.isOpen || state.highlightedIndex < 0) return + const container = dropdownRef.current + const optionEl = document.getElementById(`${listboxId}-option-${state.highlightedIndex}`) + if (container && optionEl) { + try { + optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + } catch { + optionEl.scrollIntoView({ block: 'nearest' }) + } + } + }, [state.isOpen, state.highlightedIndex]) + const onInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value const cursorPos = e.target.selectionStart || 0 @@ -77,8 +110,10 @@ export function AutocompleteSearch({ ) const newQuery = [...filterStrings, parsedQuery.textSearch].filter(Boolean).join(' ') - - onChange(newQuery) + handleInputChange(newQuery, newQuery.length) + if (inputRef.current) { + inputRef.current.focus() + } } return ( @@ -91,24 +126,37 @@ export function AutocompleteSearch({ state.isOpen && 'ring-1 ring-ring' )} > - + {state.pendingQuery ? ( + + ) : ( + + )} {/* Text display with ghost text */}
{/* Invisible input for cursor and interactions */} updateCursorPosition(e.currentTarget)} - onKeyUp={(e) => updateCursorPosition(e.currentTarget)} onKeyDown={handleKeyDown} onSelect={(e) => updateCursorPosition(e.currentTarget)} className='relative z-10 w-full border-0 bg-transparent p-0 font-[380] font-sans text-base text-transparent leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' style={{ background: 'transparent' }} + role='combobox' + aria-expanded={state.isOpen} + aria-controls={state.isOpen ? listboxId : undefined} + aria-autocomplete='list' + aria-activedescendant={ + state.isOpen && state.highlightedIndex >= 0 + ? `${listboxId}-option-${state.highlightedIndex}` + : undefined + } /> {/* Always-visible text overlay */} @@ -134,7 +182,10 @@ export function AutocompleteSearch({ variant='ghost' size='sm' className='h-6 w-6 p-0 hover:bg-muted/50' - onClick={() => onChange('')} + onMouseDown={(e) => { + e.preventDefault() + clearAll() + }} > @@ -145,7 +196,10 @@ export function AutocompleteSearch({ {state.isOpen && state.suggestions.length > 0 && (
{state.suggestionType === 'filter-keys' && ( @@ -168,12 +222,20 @@ export function AutocompleteSearch({ 'transition-colors hover:bg-accent hover:text-accent-foreground', index === state.highlightedIndex && 'bg-accent text-accent-foreground' )} - onMouseEnter={() => handleSuggestionHover(index)} + onMouseEnter={() => { + if (typeof window !== 'undefined' && (window as any).__logsKeyboardNavActive) { + return + } + handleSuggestionHover(index) + }} onMouseDown={(e) => { e.preventDefault() e.stopPropagation() handleSuggestionSelect(suggestion) }} + id={`${listboxId}-option-${index}`} + role='option' + aria-selected={index === state.highlightedIndex} >
@@ -226,7 +288,14 @@ export function AutocompleteSearch({ variant='ghost' size='sm' className='h-6 text-muted-foreground text-xs hover:text-foreground' - onClick={() => onChange(parsedQuery.textSearch)} + onMouseDown={(e) => { + e.preventDefault() + const newQuery = parsedQuery.textSearch + handleInputChange(newQuery, newQuery.length) + if (inputRef.current) { + inputRef.current.focus() + } + }} > Clear all diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts index 1e821208f5..4a02831b98 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-autocomplete.ts @@ -1,11 +1,21 @@ -import { useCallback, useMemo, useReducer, useRef } from 'react' +import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react' export interface Suggestion { id: string value: string label: string description?: string - category?: string + category?: + | 'filters' + | 'level' + | 'trigger' + | 'cost' + | 'date' + | 'duration' + | 'workflow' + | 'folder' + | 'workflowId' + | 'executionId' } export interface SuggestionGroup { @@ -43,6 +53,7 @@ type AutocompleteAction = | { type: 'SET_PREVIEW'; payload: { value: string; show: boolean } } | { type: 'CLEAR_PREVIEW' } | { type: 'SET_QUERY_VALIDITY'; payload: boolean } + | { type: 'SET_PENDING'; payload: string | null } | { type: 'RESET' } const initialState: AutocompleteState = { @@ -126,6 +137,12 @@ function autocompleteReducer( isValidQuery: action.payload, } + case 'SET_PENDING': + return { + ...state, + pendingQuery: action.payload, + } + case 'RESET': return initialState @@ -153,6 +170,16 @@ export function useAutocomplete({ const inputRef = useRef(null) const dropdownRef = useRef(null) const debounceRef = useRef(null) + const pointerDownInDropdownRef = useRef(false) + const latestRef = useRef<{ inputValue: string; cursorPosition: number }>({ + inputValue: '', + cursorPosition: 0, + }) + + useEffect(() => { + latestRef.current.inputValue = state.inputValue + latestRef.current.cursorPosition = state.cursorPosition + }, [state.inputValue, state.cursorPosition]) const currentSuggestion = useMemo(() => { if (state.highlightedIndex >= 0 && state.suggestions[state.highlightedIndex]) { @@ -162,13 +189,14 @@ export function useAutocomplete({ }, [state.highlightedIndex, state.suggestions]) const updateSuggestions = useCallback(() => { - const suggestionGroup = getSuggestions(state.inputValue, state.cursorPosition) + const { inputValue, cursorPosition } = latestRef.current + const suggestionGroup = getSuggestions(inputValue, cursorPosition) if (suggestionGroup && suggestionGroup.suggestions.length > 0) { dispatch({ type: 'OPEN_DROPDOWN', payload: suggestionGroup }) const firstSuggestion = suggestionGroup.suggestions[0] - const preview = generatePreview(firstSuggestion, state.inputValue, state.cursorPosition) + const preview = generatePreview(firstSuggestion, inputValue, cursorPosition) dispatch({ type: 'HIGHLIGHT_SUGGESTION', payload: { index: 0, preview }, @@ -176,7 +204,7 @@ export function useAutocomplete({ } else { dispatch({ type: 'CLOSE_DROPDOWN' }) } - }, [state.inputValue, state.cursorPosition, getSuggestions, generatePreview]) + }, [getSuggestions, generatePreview]) const handleInputChange = useCallback( (value: string, cursorPosition: number) => { @@ -193,7 +221,11 @@ export function useAutocomplete({ clearTimeout(debounceRef.current) } - debounceRef.current = setTimeout(updateSuggestions, debounceMs) + dispatch({ type: 'SET_PENDING', payload: value }) + debounceRef.current = setTimeout(() => { + dispatch({ type: 'SET_PENDING', payload: null }) + updateSuggestions() + }, debounceMs) }, [updateSuggestions, onQueryChange, validateQuery, debounceMs] ) @@ -257,6 +289,11 @@ export function useAutocomplete({ }) } + if (debounceRef.current) { + clearTimeout(debounceRef.current) + debounceRef.current = null + } + dispatch({ type: 'SET_PENDING', payload: null }) setTimeout(updateSuggestions, 0) }, [ @@ -273,6 +310,16 @@ export function useAutocomplete({ const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault() + if (state.isOpen) { + handleSuggestionSelect() + } else if (state.isValidQuery) { + updateSuggestions() + } + return + } + if (!state.isOpen) return switch (event.key) { @@ -290,11 +337,6 @@ export function useAutocomplete({ break } - case 'Enter': - event.preventDefault() - handleSuggestionSelect() - break - case 'Escape': event.preventDefault() dispatch({ type: 'CLOSE_DROPDOWN' }) @@ -324,12 +366,37 @@ export function useAutocomplete({ updateSuggestions() }, [updateSuggestions]) - const handleBlur = useCallback(() => { + const handleBlur = useCallback((e?: React.FocusEvent) => { + const related = (e?.relatedTarget as Node) || document.activeElement + const isInsideDropdown = related && dropdownRef.current?.contains(related) + const isInsideInput = related && inputRef.current === related + if (pointerDownInDropdownRef.current || isInsideDropdown || isInsideInput) { + return + } setTimeout(() => { dispatch({ type: 'CLOSE_DROPDOWN' }) }, 150) }, []) + useEffect(() => { + const dropdownEl = dropdownRef.current + if (!dropdownEl) return + const onPointerDown = () => { + pointerDownInDropdownRef.current = true + } + const onPointerUp = () => { + setTimeout(() => { + pointerDownInDropdownRef.current = false + }, 0) + } + dropdownEl.addEventListener('pointerdown', onPointerDown) + window.addEventListener('pointerup', onPointerUp) + return () => { + dropdownEl.removeEventListener('pointerdown', onPointerDown) + window.removeEventListener('pointerup', onPointerUp) + } + }, []) + return { // State state, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 595171743a..1b254904d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -12,6 +12,7 @@ import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/component import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar' import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date' import { useDebounce } from '@/hooks/use-debounce' +import { useFolderStore } from '@/stores/folders/store' import { useFilterStore } from '@/stores/logs/filters/store' import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types' @@ -77,7 +78,6 @@ export default function Logs() { triggers, } = useFilterStore() - // Set workspace ID in store when component mounts or workspaceId changes useEffect(() => { setWorkspaceId(workspaceId) }, [workspaceId]) @@ -94,11 +94,9 @@ export default function Logs() { const scrollContainerRef = useRef(null) const isInitialized = useRef(false) - // Local search state with debouncing for the header const [searchQuery, setSearchQuery] = useState(storeSearchQuery) const debouncedSearchQuery = useDebounce(searchQuery, 300) - // Available data for suggestions const [availableWorkflows, setAvailableWorkflows] = useState([]) const [availableFolders, setAvailableFolders] = useState([]) @@ -106,29 +104,63 @@ export default function Logs() { const [isLive, setIsLive] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) const liveIntervalRef = useRef(null) + const isSearchOpenRef = useRef(false) // Sync local search query with store search query useEffect(() => { setSearchQuery(storeSearchQuery) }, [storeSearchQuery]) + const { fetchFolders, getFolderTree } = useFolderStore() + useEffect(() => { - const workflowNames = new Set() - const folderNames = new Set() + let cancelled = false + + const fetchSuggestions = async () => { + try { + const res = await fetch(`/api/workflows?workspaceId=${encodeURIComponent(workspaceId)}`) + if (res.ok) { + const body = await res.json() + const names: string[] = Array.isArray(body?.data) + ? body.data.map((w: any) => w?.name).filter(Boolean) + : [] + if (!cancelled) setAvailableWorkflows(names) + } else { + if (!cancelled) setAvailableWorkflows([]) + } - logs.forEach((log) => { - if (log.workflow?.name) { - workflowNames.add(log.workflow.name) + await fetchFolders(workspaceId) + const tree = getFolderTree(workspaceId) + + const flatten = (nodes: any[], parentPath = ''): string[] => { + const out: string[] = [] + for (const n of nodes) { + const path = parentPath ? `${parentPath} / ${n.name}` : n.name + out.push(path) + if (n.children?.length) out.push(...flatten(n.children, path)) + } + return out + } + + const folderPaths: string[] = Array.isArray(tree) ? flatten(tree) : [] + if (!cancelled) setAvailableFolders(folderPaths) + } catch { + if (!cancelled) { + setAvailableWorkflows([]) + setAvailableFolders([]) + } } - // Note: folder info would need to be added to the logs response - // For now, we'll leave folders empty - }) + } + + if (workspaceId) { + fetchSuggestions() + } - setAvailableWorkflows(Array.from(workflowNames).slice(0, 10)) // Limit to top 10 - setAvailableFolders([]) // TODO: Add folder data to logs response - }, [logs]) + return () => { + cancelled = true + } + }, [workspaceId, fetchFolders, getFolderTree]) - // Update store when debounced search query changes useEffect(() => { if (isInitialized.current && debouncedSearchQuery !== storeSearchQuery) { setStoreSearchQuery(debouncedSearchQuery) @@ -142,12 +174,10 @@ export default function Logs() { setIsSidebarOpen(true) setIsDetailsLoading(true) - // Fetch details for current, previous, and next concurrently with cache const currentId = log.id const prevId = index > 0 ? logs[index - 1]?.id : undefined const nextId = index < logs.length - 1 ? logs[index + 1]?.id : undefined - // Abort any previous details fetch batch if (detailsAbortRef.current) { try { detailsAbortRef.current.abort() @@ -167,7 +197,6 @@ export default function Logs() { if (nextId && !detailsCacheRef.current.has(nextId)) idsToFetch.push({ id: nextId, merge: false }) - // Merge cached current immediately if (cachedCurrent) { setSelectedLog((prev) => prev && prev.id === currentId @@ -207,7 +236,6 @@ export default function Logs() { setSelectedLogIndex(nextIndex) const nextLog = logs[nextIndex] setSelectedLog(nextLog) - // Abort any previous details fetch batch if (detailsAbortRef.current) { try { detailsAbortRef.current.abort() @@ -265,7 +293,6 @@ export default function Logs() { setSelectedLogIndex(prevIndex) const prevLog = logs[prevIndex] setSelectedLog(prevLog) - // Abort any previous details fetch batch if (detailsAbortRef.current) { try { detailsAbortRef.current.abort() @@ -340,19 +367,16 @@ export default function Logs() { setIsFetchingMore(true) } - // Get fresh query params by calling buildQueryParams from store const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState() const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE) - // Parse the current search query for enhanced filtering - const parsedQuery = parseQuery(searchQuery) + const { searchQuery: currentSearchQuery } = useFilterStore.getState() + const parsedQuery = parseQuery(currentSearchQuery) const enhancedParams = queryToApiParams(parsedQuery) - // Add enhanced search parameters to the query string const allParams = new URLSearchParams(queryParams) Object.entries(enhancedParams).forEach(([key, value]) => { if (key === 'triggers' && allParams.has('triggers')) { - // Combine triggers from both sources const existingTriggers = allParams.get('triggers')?.split(',') || [] const searchTriggers = value.split(',') const combined = [...new Set([...existingTriggers, ...searchTriggers])] @@ -429,7 +453,27 @@ export default function Logs() { setIsLive(!isLive) } - // Initialize filters from URL on mount + const handleExport = async () => { + const params = new URLSearchParams() + params.set('workspaceId', workspaceId) + if (level !== 'all') params.set('level', level) + if (triggers.length > 0) params.set('triggers', triggers.join(',')) + if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(',')) + if (folderIds.length > 0) params.set('folderIds', folderIds.join(',')) + + const parsed = parseQuery(debouncedSearchQuery) + const extra = queryToApiParams(parsed) + Object.entries(extra).forEach(([k, v]) => params.set(k, v)) + + const url = `/api/logs/export?${params.toString()}` + const a = document.createElement('a') + a.href = url + a.download = 'logs_export.csv' + document.body.appendChild(a) + a.click() + a.remove() + } + useEffect(() => { if (!isInitialized.current) { isInitialized.current = true @@ -437,7 +481,6 @@ export default function Logs() { } }, [initializeFromURL]) - // Handle browser navigation events (back/forward) useEffect(() => { const handlePopState = () => { initializeFromURL() @@ -447,43 +490,34 @@ export default function Logs() { return () => window.removeEventListener('popstate', handlePopState) }, [initializeFromURL]) - // Single useEffect to handle both initial load and filter changes useEffect(() => { - // Only fetch logs after initialization if (!isInitialized.current) { return } - // Reset pagination and fetch from beginning setPage(1) setHasMore(true) - // Inline fetch logic to avoid circular dependency const fetchWithFilters = async () => { try { setLoading(true) - // Build query params inline to avoid dependency issues const params = new URLSearchParams() params.set('details', 'basic') params.set('limit', LOGS_PER_PAGE.toString()) params.set('offset', '0') // Always start from page 1 params.set('workspaceId', workspaceId) - // Parse the search query for enhanced filtering - const parsedQuery = parseQuery(searchQuery) + const parsedQuery = parseQuery(debouncedSearchQuery) const enhancedParams = queryToApiParams(parsedQuery) - // Add filters from store if (level !== 'all') params.set('level', level) if (triggers.length > 0) params.set('triggers', triggers.join(',')) if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(',')) if (folderIds.length > 0) params.set('folderIds', folderIds.join(',')) - // Add enhanced search parameters (these may override some store filters) Object.entries(enhancedParams).forEach(([key, value]) => { if (key === 'triggers' && params.has('triggers')) { - // Combine triggers from both sources const storeTriggers = params.get('triggers')?.split(',') || [] const searchTriggers = value.split(',') const combined = [...new Set([...storeTriggers, ...searchTriggers])] @@ -493,7 +527,6 @@ export default function Logs() { } }) - // Add time range filter if (timeRange !== 'All time') { const now = new Date() let startDate: Date @@ -532,7 +565,7 @@ export default function Logs() { } fetchWithFilters() - }, [workspaceId, timeRange, level, workflowIds, folderIds, searchQuery, triggers]) + }, [workspaceId, timeRange, level, workflowIds, folderIds, debouncedSearchQuery, triggers]) const loadMoreLogs = useCallback(() => { if (!isFetchingMore && hasMore) { @@ -598,6 +631,7 @@ export default function Logs() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + if (isSearchOpenRef.current) return if (logs.length === 0) return if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { @@ -651,9 +685,12 @@ export default function Logs() { placeholder='Search logs...' availableWorkflows={availableWorkflows} availableFolders={availableFolders} + onOpenChange={(open) => { + isSearchOpenRef.current = open + }} /> -
+
+ + Export CSV + +