mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
Compare commits
16 Commits
feat/fix-l
...
improvemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aef5b54b01 | ||
|
|
5fe71484e3 | ||
|
|
5933877023 | ||
|
|
4f7459250c | ||
|
|
194a2d38e4 | ||
|
|
8b9367e217 | ||
|
|
12c527d7ea | ||
|
|
f7d7bc1a43 | ||
|
|
9e0fc2cd85 | ||
|
|
f588b36914 | ||
|
|
eba424c8a3 | ||
|
|
855c892f55 | ||
|
|
8ae4b88d80 | ||
|
|
a70ccddef5 | ||
|
|
b4d9b8c396 | ||
|
|
ce53275e9d |
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { performDeleteFolder } from '@/lib/workflows/orchestration'
|
||||
import { checkForCircularReference } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
@@ -156,6 +157,13 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_deleted',
|
||||
{ workspace_id: existingFolder.workspaceId },
|
||||
{ groups: { workspace: existingFolder.workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
deletedItems: result.deletedItems,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('FoldersAPI')
|
||||
@@ -145,6 +146,13 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
logger.info('Created new folder:', { id, name, workspaceId, parentId })
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'folder_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: session.user.id,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { z } from 'zod'
|
||||
import { decryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
|
||||
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
|
||||
@@ -116,6 +117,20 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
|
||||
)
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.data.syncIntervalMinutes !== undefined &&
|
||||
parsed.data.syncIntervalMinutes > 0 &&
|
||||
parsed.data.syncIntervalMinutes < 60
|
||||
) {
|
||||
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
|
||||
if (!canUseLiveSync) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Live sync requires a Max or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.data.sourceConfig !== undefined) {
|
||||
const existingRows = await db
|
||||
.select()
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { encryptApiKey } from '@/lib/api-key/crypto'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { hasLiveSyncAccess } from '@/lib/billing/core/subscription'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { dispatchSync } from '@/lib/knowledge/connectors/sync-engine'
|
||||
import { allocateTagSlots } from '@/lib/knowledge/constants'
|
||||
@@ -97,6 +98,16 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
|
||||
const { connectorType, credentialId, apiKey, sourceConfig, syncIntervalMinutes } = parsed.data
|
||||
|
||||
if (syncIntervalMinutes > 0 && syncIntervalMinutes < 60) {
|
||||
const canUseLiveSync = await hasLiveSyncAccess(auth.userId)
|
||||
if (!canUseLiveSync) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Live sync requires a Max or Enterprise plan' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const connectorConfig = CONNECTOR_REGISTRY[connectorType]
|
||||
if (!connectorConfig) {
|
||||
return NextResponse.json(
|
||||
@@ -151,19 +162,39 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
const tagSlotMapping: Record<string, string> = {}
|
||||
let newTagSlots: Record<string, string> = {}
|
||||
|
||||
if (connectorConfig.tagDefinitions?.length) {
|
||||
const disabledIds = new Set((sourceConfig.disabledTagIds as string[] | undefined) ?? [])
|
||||
const enabledDefs = connectorConfig.tagDefinitions.filter((td) => !disabledIds.has(td.id))
|
||||
|
||||
const existingDefs = await db
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.select({
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
const usedSlots = new Set<string>(existingDefs.map((d) => d.tagSlot))
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(enabledDefs, usedSlots)
|
||||
const existingByName = new Map(
|
||||
existingDefs.map((d) => [d.displayName, { tagSlot: d.tagSlot, fieldType: d.fieldType }])
|
||||
)
|
||||
|
||||
const defsNeedingSlots: typeof enabledDefs = []
|
||||
for (const td of enabledDefs) {
|
||||
const existing = existingByName.get(td.displayName)
|
||||
if (existing && existing.fieldType === td.fieldType) {
|
||||
tagSlotMapping[td.id] = existing.tagSlot
|
||||
} else {
|
||||
defsNeedingSlots.push(td)
|
||||
}
|
||||
}
|
||||
|
||||
const { mapping, skipped: skippedTags } = allocateTagSlots(defsNeedingSlots, usedSlots)
|
||||
Object.assign(tagSlotMapping, mapping)
|
||||
newTagSlots = mapping
|
||||
|
||||
for (const name of skippedTags) {
|
||||
logger.warn(`[${requestId}] No available slots for "${name}"`)
|
||||
@@ -197,7 +228,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
throw new Error('Knowledge base not found')
|
||||
}
|
||||
|
||||
for (const [semanticId, slot] of Object.entries(tagSlotMapping)) {
|
||||
for (const [semanticId, slot] of Object.entries(newTagSlots)) {
|
||||
const td = connectorConfig.tagDefinitions!.find((d) => d.id === semanticId)!
|
||||
await createTagDefinition(
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
retryDocumentProcessing,
|
||||
updateDocument,
|
||||
} from '@/lib/knowledge/documents/service'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
|
||||
|
||||
const logger = createLogger('DocumentByIdAPI')
|
||||
@@ -285,6 +286,14 @@ export async function DELETE(
|
||||
request: req,
|
||||
})
|
||||
|
||||
const kbWorkspaceId = accessCheck.knowledgeBase?.workspaceId ?? ''
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'knowledge_base_document_deleted',
|
||||
{ knowledge_base_id: knowledgeBaseId, workspace_id: kbWorkspaceId },
|
||||
kbWorkspaceId ? { groups: { workspace: kbWorkspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEnvMock, databaseMock, loggerMock } from '@sim/testing'
|
||||
import { mockNextFetchResponse } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('drizzle-orm')
|
||||
@@ -14,16 +15,6 @@ vi.mock('@/lib/knowledge/documents/utils', () => ({
|
||||
retryWithExponentialBackoff: (fn: any) => fn(),
|
||||
}))
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/core/config/env', () => createEnvMock())
|
||||
|
||||
import {
|
||||
@@ -178,17 +169,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
@@ -209,17 +199,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const result = await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://api.openai.com/v1/embeddings',
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
@@ -243,17 +232,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api-version='),
|
||||
expect.any(Object)
|
||||
)
|
||||
@@ -273,17 +261,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview',
|
||||
expect.any(Object)
|
||||
)
|
||||
@@ -311,13 +298,12 @@ describe('Knowledge Search Utils', () => {
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
mockNextFetchResponse({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Deployment not found',
|
||||
} as any)
|
||||
text: 'Deployment not found',
|
||||
})
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
@@ -332,13 +318,12 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
mockNextFetchResponse({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => 'Rate limit exceeded',
|
||||
} as any)
|
||||
text: 'Rate limit exceeded',
|
||||
})
|
||||
|
||||
await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed')
|
||||
|
||||
@@ -356,17 +341,16 @@ describe('Knowledge Search Utils', () => {
|
||||
KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
@@ -387,17 +371,16 @@ describe('Knowledge Search Utils', () => {
|
||||
OPENAI_API_KEY: 'test-openai-key',
|
||||
})
|
||||
|
||||
const fetchSpy = vi.mocked(fetch)
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
mockNextFetchResponse({
|
||||
json: {
|
||||
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
||||
}),
|
||||
} as any)
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
await generateSearchEmbedding('test query', 'text-embedding-3-small')
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(fetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -77,6 +77,7 @@ vi.stubGlobal(
|
||||
{ embedding: [0.1, 0.2], index: 0 },
|
||||
{ embedding: [0.3, 0.4], index: 1 },
|
||||
],
|
||||
usage: { prompt_tokens: 2, total_tokens: 2 },
|
||||
}),
|
||||
})
|
||||
)
|
||||
@@ -294,7 +295,7 @@ describe('Knowledge Utils', () => {
|
||||
it.concurrent('should return same length as input', async () => {
|
||||
const result = await generateEmbeddings(['a', 'b'])
|
||||
|
||||
expect(result.length).toBe(2)
|
||||
expect(result.embeddings.length).toBe(2)
|
||||
})
|
||||
|
||||
it('should use Azure OpenAI when Azure config is provided', async () => {
|
||||
@@ -313,6 +314,7 @@ describe('Knowledge Utils', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
@@ -342,6 +344,7 @@ describe('Knowledge Utils', () => {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: [{ embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}),
|
||||
} as any)
|
||||
|
||||
|
||||
@@ -159,16 +159,7 @@ export async function PATCH(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (isUnread === false) {
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_marked_read',
|
||||
{ workspace_id: updatedChat.workspaceId },
|
||||
{
|
||||
groups: { workspace: updatedChat.workspaceId },
|
||||
}
|
||||
)
|
||||
} else if (isUnread === true) {
|
||||
if (isUnread === true) {
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'task_marked_unread',
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -298,6 +299,13 @@ export async function DELETE(
|
||||
request,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'scheduled_task_deleted',
|
||||
{ workspace_id: workspaceId ?? '' },
|
||||
workspaceId ? { groups: { workspace: workspaceId } } : undefined
|
||||
)
|
||||
|
||||
return NextResponse.json({ message: 'Schedule deleted successfully' })
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting schedule`, error)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { createFeatureFlagsMock, createMockRequest } from '@sim/testing'
|
||||
import { drizzleOrmMock } from '@sim/testing/mocks'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -10,7 +13,6 @@ const {
|
||||
mockVerifyCronAuth,
|
||||
mockExecuteScheduleJob,
|
||||
mockExecuteJobInline,
|
||||
mockFeatureFlags,
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
@@ -33,12 +35,6 @@ const {
|
||||
mockVerifyCronAuth: vi.fn().mockReturnValue(null),
|
||||
mockExecuteScheduleJob: vi.fn().mockResolvedValue(undefined),
|
||||
mockExecuteJobInline: vi.fn().mockResolvedValue(undefined),
|
||||
mockFeatureFlags: {
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
},
|
||||
mockDbReturning,
|
||||
mockDbUpdate,
|
||||
mockEnqueue,
|
||||
@@ -49,6 +45,13 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
const mockFeatureFlags = createFeatureFlagsMock({
|
||||
isTriggerDevEnabled: false,
|
||||
isHosted: false,
|
||||
isProd: false,
|
||||
isDev: true,
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/internal', () => ({
|
||||
verifyCronAuth: mockVerifyCronAuth,
|
||||
}))
|
||||
@@ -91,17 +94,7 @@ vi.mock('@/lib/workflows/utils', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('drizzle-orm', () => ({
|
||||
and: vi.fn((...conditions: unknown[]) => ({ type: 'and', conditions })),
|
||||
eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })),
|
||||
ne: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'ne' })),
|
||||
lte: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lte' })),
|
||||
lt: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'lt' })),
|
||||
not: vi.fn((condition: unknown) => ({ type: 'not', condition })),
|
||||
isNull: vi.fn((field: unknown) => ({ type: 'isNull', field })),
|
||||
or: vi.fn((...conditions: unknown[]) => ({ type: 'or', conditions })),
|
||||
sql: vi.fn((strings: unknown, ...values: unknown[]) => ({ type: 'sql', strings, values })),
|
||||
}))
|
||||
vi.mock('drizzle-orm', () => drizzleOrmMock)
|
||||
|
||||
vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
@@ -177,18 +170,13 @@ const SINGLE_JOB = [
|
||||
},
|
||||
]
|
||||
|
||||
function createMockRequest(): NextRequest {
|
||||
const mockHeaders = new Map([
|
||||
['authorization', 'Bearer test-cron-secret'],
|
||||
['content-type', 'application/json'],
|
||||
])
|
||||
|
||||
return {
|
||||
headers: {
|
||||
get: (key: string) => mockHeaders.get(key.toLowerCase()) || null,
|
||||
},
|
||||
url: 'http://localhost:3000/api/schedules/execute',
|
||||
} as NextRequest
|
||||
function createCronRequest() {
|
||||
return createMockRequest(
|
||||
'GET',
|
||||
undefined,
|
||||
{ Authorization: 'Bearer test-cron-secret' },
|
||||
'http://localhost:3000/api/schedules/execute'
|
||||
)
|
||||
}
|
||||
|
||||
describe('Scheduled Workflow Execution API Route', () => {
|
||||
@@ -204,7 +192,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should execute scheduled workflows with Trigger.dev disabled', async () => {
|
||||
mockDbReturning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
|
||||
expect(response).toBeDefined()
|
||||
expect(response.status).toBe(200)
|
||||
@@ -217,7 +205,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
mockFeatureFlags.isTriggerDevEnabled = true
|
||||
mockDbReturning.mockReturnValueOnce(SINGLE_SCHEDULE).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
|
||||
expect(response).toBeDefined()
|
||||
expect(response.status).toBe(200)
|
||||
@@ -228,7 +216,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should handle case with no due schedules', async () => {
|
||||
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
@@ -239,7 +227,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should execute multiple schedules in parallel', async () => {
|
||||
mockDbReturning.mockReturnValueOnce(MULTIPLE_SCHEDULES).mockReturnValueOnce([])
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const data = await response.json()
|
||||
@@ -249,7 +237,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should queue mothership jobs to BullMQ when available', async () => {
|
||||
mockDbReturning.mockReturnValueOnce([]).mockReturnValueOnce(SINGLE_JOB)
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
@@ -274,7 +262,7 @@ describe('Scheduled Workflow Execution API Route', () => {
|
||||
it('should enqueue preassigned correlation metadata for schedules', async () => {
|
||||
mockDbReturning.mockReturnValue(SINGLE_SCHEDULE)
|
||||
|
||||
const response = await GET(createMockRequest())
|
||||
const response = await GET(createCronRequest() as unknown as NextRequest)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockEnqueueWorkspaceDispatch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { and, eq, isNull, or } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
|
||||
@@ -277,6 +278,13 @@ export async function POST(req: NextRequest) {
|
||||
lifecycle,
|
||||
})
|
||||
|
||||
captureServerEvent(
|
||||
session.user.id,
|
||||
'scheduled_task_created',
|
||||
{ workspace_id: workspaceId },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
return NextResponse.json(
|
||||
{ schedule: { id, status: 'active', cronExpression, nextRunAt } },
|
||||
{ status: 201 }
|
||||
|
||||
96
apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts
Normal file
96
apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
type AlarmType,
|
||||
CloudWatchClient,
|
||||
DescribeAlarmsCommand,
|
||||
type StateValue,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeAlarms')
|
||||
|
||||
const DescribeAlarmsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
alarmNamePrefix: z.string().optional(),
|
||||
stateValue: z.preprocess(
|
||||
(v) => (v === '' ? undefined : v),
|
||||
z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional()
|
||||
),
|
||||
alarmType: z.preprocess(
|
||||
(v) => (v === '' ? undefined : v),
|
||||
z.enum(['MetricAlarm', 'CompositeAlarm']).optional()
|
||||
),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeAlarmsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new DescribeAlarmsCommand({
|
||||
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
|
||||
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
|
||||
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
|
||||
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: a.MetricName,
|
||||
namespace: a.Namespace,
|
||||
comparisonOperator: a.ComparisonOperator,
|
||||
threshold: a.Threshold,
|
||||
evaluationPeriods: a.EvaluationPeriods,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: undefined,
|
||||
namespace: undefined,
|
||||
comparisonOperator: undefined,
|
||||
threshold: undefined,
|
||||
evaluationPeriods: undefined,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { alarms: [...metricAlarms, ...compositeAlarms] },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
|
||||
logger.error('DescribeAlarms failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogGroups')
|
||||
|
||||
const DescribeLogGroupsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
prefix: z.string().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeLogGroupsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const command = new DescribeLogGroupsCommand({
|
||||
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
|
||||
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const logGroups = (response.logGroups ?? []).map((lg) => ({
|
||||
logGroupName: lg.logGroupName ?? '',
|
||||
arn: lg.arn ?? '',
|
||||
storedBytes: lg.storedBytes ?? 0,
|
||||
retentionInDays: lg.retentionInDays,
|
||||
creationTime: lg.creationTime,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logGroups },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
|
||||
logger.error('DescribeLogGroups failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogStreams')
|
||||
|
||||
const DescribeLogStreamsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
logGroupName: z.string().min(1, 'Log group name is required'),
|
||||
prefix: z.string().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = DescribeLogStreamsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const result = await describeLogStreams(client, validatedData.logGroupName, {
|
||||
prefix: validatedData.prefix,
|
||||
limit: validatedData.limit,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logStreams: result.logStreams },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
|
||||
logger.error('DescribeLogStreams failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
60
apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts
Normal file
60
apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchGetLogEvents')
|
||||
|
||||
const GetLogEventsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
logGroupName: z.string().min(1, 'Log group name is required'),
|
||||
logStreamName: z.string().min(1, 'Log stream name is required'),
|
||||
startTime: z.number({ coerce: true }).int().optional(),
|
||||
endTime: z.number({ coerce: true }).int().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = GetLogEventsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const result = await getLogEvents(
|
||||
client,
|
||||
validatedData.logGroupName,
|
||||
validatedData.logStreamName,
|
||||
{
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
limit: validatedData.limit,
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { events: result.events },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
|
||||
logger.error('GetLogEvents failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchGetMetricStatistics')
|
||||
|
||||
const GetMetricStatisticsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
namespace: z.string().min(1, 'Namespace is required'),
|
||||
metricName: z.string().min(1, 'Metric name is required'),
|
||||
startTime: z.number({ coerce: true }).int(),
|
||||
endTime: z.number({ coerce: true }).int(),
|
||||
period: z.number({ coerce: true }).int().min(1),
|
||||
statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1),
|
||||
dimensions: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = GetMetricStatisticsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
let parsedDimensions: { Name: string; Value: string }[] | undefined
|
||||
if (validatedData.dimensions) {
|
||||
try {
|
||||
const dims = JSON.parse(validatedData.dimensions)
|
||||
if (Array.isArray(dims)) {
|
||||
parsedDimensions = dims.map((d: Record<string, string>) => ({
|
||||
Name: d.name,
|
||||
Value: d.value,
|
||||
}))
|
||||
} else if (typeof dims === 'object') {
|
||||
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
|
||||
Name: name,
|
||||
Value: String(value),
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
throw new Error('Invalid dimensions JSON')
|
||||
}
|
||||
}
|
||||
|
||||
const command = new GetMetricStatisticsCommand({
|
||||
Namespace: validatedData.namespace,
|
||||
MetricName: validatedData.metricName,
|
||||
StartTime: new Date(validatedData.startTime * 1000),
|
||||
EndTime: new Date(validatedData.endTime * 1000),
|
||||
Period: validatedData.period,
|
||||
Statistics: validatedData.statistics,
|
||||
...(parsedDimensions && { Dimensions: parsedDimensions }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const datapoints = (response.Datapoints ?? [])
|
||||
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
|
||||
.map((dp) => ({
|
||||
timestamp: dp.Timestamp ? Math.floor(dp.Timestamp.getTime() / 1000) : 0,
|
||||
average: dp.Average,
|
||||
sum: dp.Sum,
|
||||
minimum: dp.Minimum,
|
||||
maximum: dp.Maximum,
|
||||
sampleCount: dp.SampleCount,
|
||||
unit: dp.Unit,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
label: response.Label ?? validatedData.metricName,
|
||||
datapoints,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
|
||||
logger.error('GetMetricStatistics failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
67
apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts
Normal file
67
apps/sim/app/api/tools/cloudwatch/list-metrics/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { CloudWatchClient, ListMetricsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
|
||||
const logger = createLogger('CloudWatchListMetrics')
|
||||
|
||||
const ListMetricsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
namespace: z.string().optional(),
|
||||
metricName: z.string().optional(),
|
||||
recentlyActive: z.boolean().optional(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = ListMetricsSchema.parse(body)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
const command = new ListMetricsCommand({
|
||||
...(validatedData.namespace && { Namespace: validatedData.namespace }),
|
||||
...(validatedData.metricName && { MetricName: validatedData.metricName }),
|
||||
...(validatedData.recentlyActive && { RecentlyActive: 'PT3H' }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const metrics = (response.Metrics ?? []).slice(0, validatedData.limit ?? 500).map((m) => ({
|
||||
namespace: m.Namespace ?? '',
|
||||
metricName: m.MetricName ?? '',
|
||||
dimensions: (m.Dimensions ?? []).map((d) => ({
|
||||
name: d.Name ?? '',
|
||||
value: d.Value ?? '',
|
||||
})),
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { metrics },
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to list CloudWatch metrics'
|
||||
logger.error('ListMetrics failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
71
apps/sim/app/api/tools/cloudwatch/query-logs/route.ts
Normal file
71
apps/sim/app/api/tools/cloudwatch/query-logs/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { createCloudWatchLogsClient, pollQueryResults } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchQueryLogs')
|
||||
|
||||
const QueryLogsSchema = z.object({
|
||||
region: z.string().min(1, 'AWS region is required'),
|
||||
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
|
||||
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
|
||||
logGroupNames: z.array(z.string().min(1)).min(1, 'At least one log group name is required'),
|
||||
queryString: z.string().min(1, 'Query string is required'),
|
||||
startTime: z.number({ coerce: true }).int(),
|
||||
endTime: z.number({ coerce: true }).int(),
|
||||
limit: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : v),
|
||||
z.number({ coerce: true }).int().positive().optional()
|
||||
),
|
||||
})
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const validatedData = QueryLogsSchema.parse(body)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
const startQueryCommand = new StartQueryCommand({
|
||||
logGroupNames: validatedData.logGroupNames,
|
||||
queryString: validatedData.queryString,
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
|
||||
})
|
||||
|
||||
const startQueryResponse = await client.send(startQueryCommand)
|
||||
const queryId = startQueryResponse.queryId
|
||||
|
||||
if (!queryId) {
|
||||
throw new Error('Failed to start CloudWatch Log Insights query: no queryId returned')
|
||||
}
|
||||
|
||||
const result = await pollQueryResults(client, queryId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
results: result.results,
|
||||
statistics: result.statistics,
|
||||
status: result.status,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'CloudWatch Log Insights query failed'
|
||||
logger.error('QueryLogs failed', { error: errorMessage })
|
||||
return NextResponse.json({ error: errorMessage }, { status: 500 })
|
||||
}
|
||||
}
|
||||
161
apps/sim/app/api/tools/cloudwatch/utils.ts
Normal file
161
apps/sim/app/api/tools/cloudwatch/utils.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
CloudWatchLogsClient,
|
||||
DescribeLogStreamsCommand,
|
||||
GetLogEventsCommand,
|
||||
GetQueryResultsCommand,
|
||||
type ResultField,
|
||||
} from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
|
||||
|
||||
interface AwsCredentials {
|
||||
region: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient {
|
||||
return new CloudWatchLogsClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface PollOptions {
|
||||
maxWaitMs?: number
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
interface PollResult {
|
||||
results: Record<string, string>[]
|
||||
statistics: {
|
||||
bytesScanned: number
|
||||
recordsMatched: number
|
||||
recordsScanned: number
|
||||
}
|
||||
status: string
|
||||
}
|
||||
|
||||
function parseResultFields(fields: ResultField[] | undefined): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
if (!fields) return record
|
||||
for (const field of fields) {
|
||||
if (field.field && field.value !== undefined) {
|
||||
record[field.field] = field.value ?? ''
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export async function pollQueryResults(
|
||||
client: CloudWatchLogsClient,
|
||||
queryId: string,
|
||||
options: PollOptions = {}
|
||||
): Promise<PollResult> {
|
||||
const { maxWaitMs = DEFAULT_EXECUTION_TIMEOUT_MS, pollIntervalMs = 1_000 } = options
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const command = new GetQueryResultsCommand({ queryId })
|
||||
const response = await client.send(command)
|
||||
|
||||
const status = response.status ?? 'Unknown'
|
||||
|
||||
if (status === 'Complete') {
|
||||
return {
|
||||
results: (response.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: response.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: response.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: response.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'Failed' || status === 'Cancelled') {
|
||||
throw new Error(`CloudWatch Log Insights query ${status.toLowerCase()}`)
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
|
||||
}
|
||||
|
||||
// Timeout -- fetch one last time for partial results
|
||||
const finalResponse = await client.send(new GetQueryResultsCommand({ queryId }))
|
||||
return {
|
||||
results: (finalResponse.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: finalResponse.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: finalResponse.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: finalResponse.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status: `Timeout (last status: ${finalResponse.status ?? 'Unknown'})`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function describeLogStreams(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
options?: { prefix?: string; limit?: number }
|
||||
): Promise<{
|
||||
logStreams: {
|
||||
logStreamName: string
|
||||
lastEventTimestamp: number | undefined
|
||||
firstEventTimestamp: number | undefined
|
||||
creationTime: number | undefined
|
||||
storedBytes: number
|
||||
}[]
|
||||
}> {
|
||||
const hasPrefix = Boolean(options?.prefix)
|
||||
const command = new DescribeLogStreamsCommand({
|
||||
logGroupName,
|
||||
...(hasPrefix
|
||||
? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix }
|
||||
: { orderBy: 'LastEventTime', descending: true }),
|
||||
...(options?.limit !== undefined && { limit: options.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
return {
|
||||
logStreams: (response.logStreams ?? []).map((ls) => ({
|
||||
logStreamName: ls.logStreamName ?? '',
|
||||
lastEventTimestamp: ls.lastEventTimestamp,
|
||||
firstEventTimestamp: ls.firstEventTimestamp,
|
||||
creationTime: ls.creationTime,
|
||||
storedBytes: ls.storedBytes ?? 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLogEvents(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
logStreamName: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number }
|
||||
): Promise<{
|
||||
events: {
|
||||
timestamp: number | undefined
|
||||
message: string | undefined
|
||||
ingestionTime: number | undefined
|
||||
}[]
|
||||
}> {
|
||||
const command = new GetLogEventsCommand({
|
||||
logGroupIdentifier: logGroupName,
|
||||
logStreamName,
|
||||
...(options?.startTime !== undefined && { startTime: options.startTime * 1000 }),
|
||||
...(options?.endTime !== undefined && { endTime: options.endTime * 1000 }),
|
||||
...(options?.limit !== undefined && { limit: options.limit }),
|
||||
startFromHead: true,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
return {
|
||||
events: (response.events ?? []).map((e) => ({
|
||||
timestamp: e.timestamp,
|
||||
message: e.message,
|
||||
ingestionTime: e.ingestionTime,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import { workflow, workflowFolder } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { exportFolderToZip } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import JSZip from 'jszip'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
|
||||
@@ -16,7 +16,8 @@ import { workflow, workflowFolder, workspace } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { exportWorkspaceToZip } from '@/lib/workflows/operations/import-export'
|
||||
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
|
||||
import { withAdminAuthParams } from '@/app/api/v1/admin/middleware'
|
||||
import {
|
||||
|
||||
@@ -228,6 +228,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
if (!trimmed && !(fileAttachments && fileAttachments.length > 0)) return
|
||||
|
||||
captureEvent(posthogRef.current, 'task_message_sent', {
|
||||
workspace_id: workspaceId,
|
||||
has_attachments: !!(fileAttachments && fileAttachments.length > 0),
|
||||
has_contexts: !!(contexts && contexts.length > 0),
|
||||
is_new_task: !chatId,
|
||||
@@ -239,7 +240,7 @@ export function Home({ chatId }: HomeProps = {}) {
|
||||
|
||||
sendMessage(trimmed || 'Analyze the attached file(s).', fileAttachments, contexts)
|
||||
},
|
||||
[sendMessage]
|
||||
[sendMessage, workspaceId, chatId]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,26 +19,23 @@ import {
|
||||
ModalHeader,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { consumeOAuthReturnContext } from '@/lib/credentials/client-state'
|
||||
import { getProviderIdFromServiceId, type OAuthProvider } from '@/lib/oauth'
|
||||
import { OAuthModal } from '@/app/workspace/[workspaceId]/components/oauth-modal'
|
||||
import { ConnectorSelectorField } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/add-connector-modal/components/connector-selector-field'
|
||||
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
|
||||
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig, ConnectorConfigField } from '@/connectors/types'
|
||||
import { useCreateConnector } from '@/hooks/queries/kb/connectors'
|
||||
import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
import type { SelectorKey } from '@/hooks/selectors/types'
|
||||
import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers'
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ label: 'Every hour', value: 60 },
|
||||
{ label: 'Every 6 hours', value: 360 },
|
||||
{ label: 'Daily', value: 1440 },
|
||||
{ label: 'Weekly', value: 10080 },
|
||||
{ label: 'Manual only', value: 0 },
|
||||
] as const
|
||||
|
||||
const CONNECTOR_ENTRIES = Object.entries(CONNECTOR_REGISTRY)
|
||||
|
||||
interface AddConnectorModalProps {
|
||||
@@ -75,6 +72,10 @@ export function AddConnectorModal({
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>()
|
||||
const { mutate: createConnector, isPending: isCreating } = useCreateConnector()
|
||||
|
||||
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
|
||||
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
|
||||
|
||||
const connectorConfig = selectedType ? CONNECTOR_REGISTRY[selectedType] : null
|
||||
const isApiKeyMode = connectorConfig?.auth.mode === 'apiKey'
|
||||
const connectorProviderId = useMemo(
|
||||
@@ -528,8 +529,13 @@ export function AddConnectorModal({
|
||||
onValueChange={(val) => setSyncInterval(Number(val))}
|
||||
>
|
||||
{SYNC_INTERVALS.map((interval) => (
|
||||
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
|
||||
<ButtonGroupItem
|
||||
key={interval.value}
|
||||
value={String(interval.value)}
|
||||
disabled={interval.requiresMax && !hasMaxAccess}
|
||||
>
|
||||
{interval.label}
|
||||
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -79,6 +79,8 @@ export function ConnectorSelectorField({
|
||||
options={comboboxOptions}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
searchable
|
||||
searchPlaceholder={`Search ${field.title.toLowerCase()}...`}
|
||||
placeholder={
|
||||
!credentialId
|
||||
? 'Connect an account first'
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const SYNC_INTERVALS = [
|
||||
{ label: 'Live', value: 5, requiresMax: true },
|
||||
{ label: 'Every hour', value: 60, requiresMax: false },
|
||||
{ label: 'Every 6 hours', value: 360, requiresMax: false },
|
||||
{ label: 'Daily', value: 1440, requiresMax: false },
|
||||
{ label: 'Weekly', value: 10080, requiresMax: false },
|
||||
{ label: 'Manual only', value: 0, requiresMax: false },
|
||||
] as const
|
||||
@@ -21,6 +21,10 @@ import {
|
||||
ModalTabsTrigger,
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import { getSubscriptionAccessState } from '@/lib/billing/client'
|
||||
import { SYNC_INTERVALS } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/consts'
|
||||
import { MaxBadge } from '@/app/workspace/[workspaceId]/knowledge/[id]/components/max-badge'
|
||||
import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
|
||||
import { CONNECTOR_REGISTRY } from '@/connectors/registry'
|
||||
import type { ConnectorConfig } from '@/connectors/types'
|
||||
import type { ConnectorData } from '@/hooks/queries/kb/connectors'
|
||||
@@ -30,17 +34,10 @@ import {
|
||||
useRestoreConnectorDocument,
|
||||
useUpdateConnector,
|
||||
} from '@/hooks/queries/kb/connectors'
|
||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
||||
|
||||
const logger = createLogger('EditConnectorModal')
|
||||
|
||||
const SYNC_INTERVALS = [
|
||||
{ label: 'Every hour', value: 60 },
|
||||
{ label: 'Every 6 hours', value: 360 },
|
||||
{ label: 'Daily', value: 1440 },
|
||||
{ label: 'Weekly', value: 10080 },
|
||||
{ label: 'Manual only', value: 0 },
|
||||
] as const
|
||||
|
||||
/** Keys injected by the sync engine — not user-editable */
|
||||
const INTERNAL_CONFIG_KEYS = new Set(['tagSlotMapping', 'disabledTagIds'])
|
||||
|
||||
@@ -76,6 +73,10 @@ export function EditConnectorModal({
|
||||
|
||||
const { mutate: updateConnector, isPending: isSaving } = useUpdateConnector()
|
||||
|
||||
const { data: subscriptionResponse } = useSubscriptionData({ enabled: isBillingEnabled })
|
||||
const subscriptionAccess = getSubscriptionAccessState(subscriptionResponse?.data)
|
||||
const hasMaxAccess = !isBillingEnabled || subscriptionAccess.hasUsableMaxAccess
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (syncInterval !== connector.syncIntervalMinutes) return true
|
||||
for (const [key, value] of Object.entries(sourceConfig)) {
|
||||
@@ -146,6 +147,7 @@ export function EditConnectorModal({
|
||||
setSourceConfig={setSourceConfig}
|
||||
syncInterval={syncInterval}
|
||||
setSyncInterval={setSyncInterval}
|
||||
hasMaxAccess={hasMaxAccess}
|
||||
error={error}
|
||||
/>
|
||||
</ModalTabsContent>
|
||||
@@ -184,6 +186,7 @@ interface SettingsTabProps {
|
||||
setSourceConfig: React.Dispatch<React.SetStateAction<Record<string, string>>>
|
||||
syncInterval: number
|
||||
setSyncInterval: (v: number) => void
|
||||
hasMaxAccess: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -193,6 +196,7 @@ function SettingsTab({
|
||||
setSourceConfig,
|
||||
syncInterval,
|
||||
setSyncInterval,
|
||||
hasMaxAccess,
|
||||
error,
|
||||
}: SettingsTabProps) {
|
||||
return (
|
||||
@@ -234,8 +238,13 @@ function SettingsTab({
|
||||
onValueChange={(val) => setSyncInterval(Number(val))}
|
||||
>
|
||||
{SYNC_INTERVALS.map((interval) => (
|
||||
<ButtonGroupItem key={interval.value} value={String(interval.value)}>
|
||||
<ButtonGroupItem
|
||||
key={interval.value}
|
||||
value={String(interval.value)}
|
||||
disabled={interval.requiresMax && !hasMaxAccess}
|
||||
>
|
||||
{interval.label}
|
||||
{interval.requiresMax && !hasMaxAccess && <MaxBadge />}
|
||||
</ButtonGroupItem>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export function MaxBadge() {
|
||||
return (
|
||||
<span className='ml-1 shrink-0 rounded-[3px] bg-[var(--surface-5)] px-1 py-[1px] font-medium text-[9px] text-[var(--text-icon)] uppercase tracking-wide'>
|
||||
Max
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useState } from 'react'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
Button,
|
||||
@@ -18,6 +19,7 @@ import { DatePicker } from '@/components/emcn/components/date-picker/date-picker
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { hasActiveFilters } from '@/lib/logs/filters'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { type LogStatus, STATUS_CONFIG } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
@@ -179,6 +181,9 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
}: LogsToolbarProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
|
||||
const {
|
||||
level,
|
||||
@@ -258,8 +263,45 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
} else {
|
||||
setLevel(values.join(','))
|
||||
}
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'status',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setLevel]
|
||||
[setLevel, workspaceId]
|
||||
)
|
||||
|
||||
const handleWorkflowFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setWorkflowIds(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'workflow',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setWorkflowIds, workspaceId]
|
||||
)
|
||||
|
||||
const handleFolderFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setFolderIds(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'folder',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setFolderIds, workspaceId]
|
||||
)
|
||||
|
||||
const handleTriggerFilterChange = useCallback(
|
||||
(values: string[]) => {
|
||||
setTriggers(values)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'trigger',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setTriggers, workspaceId]
|
||||
)
|
||||
|
||||
const statusDisplayLabel = useMemo(() => {
|
||||
@@ -348,9 +390,13 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
} else {
|
||||
clearDateRange()
|
||||
setTimeRange(val as typeof timeRange)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'time',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
}
|
||||
},
|
||||
[timeRange, setTimeRange, clearDateRange]
|
||||
[timeRange, setTimeRange, clearDateRange, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -360,8 +406,12 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
(start: string, end: string) => {
|
||||
setDateRange(start, end)
|
||||
setDatePickerOpen(false)
|
||||
captureEvent(posthogRef.current, 'logs_filter_applied', {
|
||||
filter_type: 'time',
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
},
|
||||
[setDateRange]
|
||||
[setDateRange, workspaceId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -545,7 +595,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={workflowIds}
|
||||
onMultiSelectChange={setWorkflowIds}
|
||||
onMultiSelectChange={handleWorkflowFilterChange}
|
||||
placeholder='All workflows'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
@@ -580,7 +630,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={folderOptions}
|
||||
multiSelect
|
||||
multiSelectValues={folderIds}
|
||||
onMultiSelectChange={setFolderIds}
|
||||
onMultiSelectChange={handleFolderFilterChange}
|
||||
placeholder='All folders'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
@@ -605,7 +655,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={triggerOptions}
|
||||
multiSelect
|
||||
multiSelectValues={triggers}
|
||||
onMultiSelectChange={setTriggers}
|
||||
onMultiSelectChange={handleTriggerFilterChange}
|
||||
placeholder='All triggers'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
@@ -676,7 +726,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={workflowOptions}
|
||||
multiSelect
|
||||
multiSelectValues={workflowIds}
|
||||
onMultiSelectChange={setWorkflowIds}
|
||||
onMultiSelectChange={handleWorkflowFilterChange}
|
||||
placeholder='Workflow'
|
||||
overlayContent={
|
||||
<span className='flex items-center gap-1.5 truncate text-[var(--text-primary)]'>
|
||||
@@ -707,7 +757,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={folderOptions}
|
||||
multiSelect
|
||||
multiSelectValues={folderIds}
|
||||
onMultiSelectChange={setFolderIds}
|
||||
onMultiSelectChange={handleFolderFilterChange}
|
||||
placeholder='Folder'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{folderDisplayLabel}</span>
|
||||
@@ -726,7 +776,7 @@ export const LogsToolbar = memo(function LogsToolbar({
|
||||
options={triggerOptions}
|
||||
multiSelect
|
||||
multiSelectValues={triggers}
|
||||
onMultiSelectChange={setTriggers}
|
||||
onMultiSelectChange={handleTriggerFilterChange}
|
||||
placeholder='Trigger'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{triggerDisplayLabel}</span>
|
||||
|
||||
@@ -62,6 +62,8 @@ const roleOptions = [
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
] as const
|
||||
|
||||
const roleComboOptions = roleOptions.map((option) => ({ value: option.value, label: option.label }))
|
||||
|
||||
export function IntegrationsManager() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
@@ -1315,42 +1317,32 @@ export function IntegrationsManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
options={roleComboOptions}
|
||||
value={
|
||||
roleOptions.find((option) => option.value === member.role)?.label || ''
|
||||
}
|
||||
selectedValue={member.role}
|
||||
onChange={(value) =>
|
||||
handleChangeMemberRole(member.userId, value as WorkspaceCredentialRole)
|
||||
}
|
||||
placeholder='Role'
|
||||
disabled={
|
||||
!isSelectedAdmin || (member.role === 'admin' && adminMemberCount <= 1)
|
||||
}
|
||||
size='sm'
|
||||
/>
|
||||
{isSelectedAdmin ? (
|
||||
<>
|
||||
<Combobox
|
||||
options={roleOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
value={
|
||||
roleOptions.find((option) => option.value === member.role)?.label ||
|
||||
''
|
||||
}
|
||||
selectedValue={member.role}
|
||||
onChange={(value) =>
|
||||
handleChangeMemberRole(
|
||||
member.userId,
|
||||
value as WorkspaceCredentialRole
|
||||
)
|
||||
}
|
||||
placeholder='Role'
|
||||
disabled={member.role === 'admin' && adminMemberCount <= 1}
|
||||
size='sm'
|
||||
/>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
disabled={member.role === 'admin' && adminMemberCount <= 1}
|
||||
className='w-full justify-end'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleRemoveMember(member.userId)}
|
||||
disabled={member.role === 'admin' && adminMemberCount <= 1}
|
||||
className='w-full justify-end'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant='gray-secondary'>{member.role}</Badge>
|
||||
<div />
|
||||
</>
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -1370,10 +1362,7 @@ export function IntegrationsManager() {
|
||||
size='sm'
|
||||
/>
|
||||
<Combobox
|
||||
options={roleOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
options={roleComboOptions}
|
||||
value={
|
||||
roleOptions.find((option) => option.value === memberRole)?.label || ''
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { ArrowDown, ArrowUp, Duplicate, Pencil, Trash } from '@/components/emcn/icons'
|
||||
import type { ContextMenuState } from '../../types'
|
||||
import type { ContextMenuState } from '@/app/workspace/[workspaceId]/tables/[tableId]/types'
|
||||
|
||||
interface ContextMenuProps {
|
||||
contextMenu: ContextMenuState
|
||||
|
||||
@@ -17,13 +17,17 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
|
||||
import {
|
||||
cleanCellValue,
|
||||
formatValueForInput,
|
||||
} from '@/app/workspace/[workspaceId]/tables/[tableId]/utils'
|
||||
import {
|
||||
useCreateTableRow,
|
||||
useDeleteTableRow,
|
||||
useDeleteTableRows,
|
||||
useUpdateTableRow,
|
||||
} from '@/hooks/queries/tables'
|
||||
import { cleanCellValue, formatValueForInput } from '../../utils'
|
||||
import { useTableUndoStore } from '@/stores/table/store'
|
||||
|
||||
const logger = createLogger('RowModal')
|
||||
|
||||
@@ -39,13 +43,9 @@ export interface RowModalProps {
|
||||
|
||||
function createInitialRowData(columns: ColumnDefinition[]): Record<string, unknown> {
|
||||
const initial: Record<string, unknown> = {}
|
||||
columns.forEach((col) => {
|
||||
if (col.type === 'boolean') {
|
||||
initial[col.name] = false
|
||||
} else {
|
||||
initial[col.name] = ''
|
||||
}
|
||||
})
|
||||
for (const col of columns) {
|
||||
initial[col.name] = col.type === 'boolean' ? false : ''
|
||||
}
|
||||
return initial
|
||||
}
|
||||
|
||||
@@ -54,16 +54,13 @@ function cleanRowData(
|
||||
rowData: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const cleanData: Record<string, unknown> = {}
|
||||
|
||||
columns.forEach((col) => {
|
||||
const value = rowData[col.name]
|
||||
for (const col of columns) {
|
||||
try {
|
||||
cleanData[col.name] = cleanCellValue(value, col)
|
||||
cleanData[col.name] = cleanCellValue(rowData[col.name], col)
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON for field: ${col.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
return cleanData
|
||||
}
|
||||
|
||||
@@ -86,8 +83,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
|
||||
const workspaceId = params.workspaceId as string
|
||||
const tableId = table.id
|
||||
|
||||
const schema = table?.schema
|
||||
const columns = schema?.columns || []
|
||||
const columns = table.schema?.columns || []
|
||||
|
||||
const [rowData, setRowData] = useState<Record<string, unknown>>(() =>
|
||||
getInitialRowData(mode, columns, row)
|
||||
@@ -97,6 +93,7 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
|
||||
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
|
||||
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
|
||||
const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId })
|
||||
const pushToUndoStack = useTableUndoStore((s) => s.push)
|
||||
const isSubmitting =
|
||||
createRowMutation.isPending ||
|
||||
updateRowMutation.isPending ||
|
||||
@@ -111,9 +108,24 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
|
||||
const cleanData = cleanRowData(columns, rowData)
|
||||
|
||||
if (mode === 'add') {
|
||||
await createRowMutation.mutateAsync({ data: cleanData })
|
||||
const response = await createRowMutation.mutateAsync({ data: cleanData })
|
||||
const createdRow = (response as { data?: { row?: { id?: string; position?: number } } })
|
||||
?.data?.row
|
||||
if (createdRow?.id) {
|
||||
pushToUndoStack(tableId, {
|
||||
type: 'create-row',
|
||||
rowId: createdRow.id,
|
||||
position: createdRow.position ?? 0,
|
||||
data: cleanData,
|
||||
})
|
||||
}
|
||||
} else if (mode === 'edit' && row) {
|
||||
const oldData = row.data as Record<string, unknown>
|
||||
await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData })
|
||||
pushToUndoStack(tableId, {
|
||||
type: 'update-cells',
|
||||
cells: [{ rowId: row.id, oldData, newData: cleanData }],
|
||||
})
|
||||
}
|
||||
|
||||
onSuccess()
|
||||
@@ -129,8 +141,14 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
|
||||
const idsToDelete = rowIds ?? (row ? [row.id] : [])
|
||||
|
||||
try {
|
||||
if (idsToDelete.length === 1) {
|
||||
if (idsToDelete.length === 1 && row) {
|
||||
await deleteRowMutation.mutateAsync(idsToDelete[0])
|
||||
pushToUndoStack(tableId, {
|
||||
type: 'delete-rows',
|
||||
rows: [
|
||||
{ rowId: row.id, data: row.data as Record<string, unknown>, position: row.position },
|
||||
],
|
||||
})
|
||||
} else {
|
||||
await deleteRowsMutation.mutateAsync(idsToDelete)
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export type { TableFilterHandle } from './table-filter'
|
||||
export { TableFilter } from './table-filter'
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { nanoid } from 'nanoid'
|
||||
import {
|
||||
@@ -19,22 +27,42 @@ const OPERATOR_LABELS = Object.fromEntries(
|
||||
COMPARISON_OPERATORS.map((op) => [op.value, op.label])
|
||||
) as Record<string, string>
|
||||
|
||||
export interface TableFilterHandle {
|
||||
addColumnRule: (columnName: string) => void
|
||||
}
|
||||
|
||||
interface TableFilterProps {
|
||||
columns: Array<{ name: string; type: string }>
|
||||
filter: Filter | null
|
||||
onApply: (filter: Filter | null) => void
|
||||
onClose: () => void
|
||||
initialColumn?: string | null
|
||||
}
|
||||
|
||||
export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) {
|
||||
export const TableFilter = forwardRef<TableFilterHandle, TableFilterProps>(function TableFilter(
|
||||
{ columns, filter, onApply, onClose, initialColumn },
|
||||
ref
|
||||
) {
|
||||
const [rules, setRules] = useState<FilterRule[]>(() => {
|
||||
const fromFilter = filterToRules(filter)
|
||||
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
|
||||
if (fromFilter.length > 0) return fromFilter
|
||||
const rule = createRule(columns)
|
||||
return [initialColumn ? { ...rule, column: initialColumn } : rule]
|
||||
})
|
||||
|
||||
const rulesRef = useRef(rules)
|
||||
rulesRef.current = rules
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
addColumnRule: (columnName: string) => {
|
||||
setRules((prev) => [...prev, { ...createRule(columns), column: columnName }])
|
||||
},
|
||||
}),
|
||||
[columns]
|
||||
)
|
||||
|
||||
const columnOptions = useMemo(
|
||||
() => columns.map((col) => ({ value: col.name, label: col.name })),
|
||||
[columns]
|
||||
@@ -125,7 +153,7 @@ export function TableFilter({ columns, filter, onApply, onClose }: TableFilterPr
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
interface FilterRuleRowProps {
|
||||
rule: FilterRule
|
||||
|
||||
@@ -24,11 +24,15 @@ import {
|
||||
Skeleton,
|
||||
} from '@/components/emcn'
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Calendar as CalendarIcon,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Fingerprint,
|
||||
ListFilter,
|
||||
Pencil,
|
||||
Plus,
|
||||
Table as TableIcon,
|
||||
@@ -45,6 +49,26 @@ import type { ColumnDefinition, Filter, SortDirection, TableRow as TableRowType
|
||||
import type { ColumnOption, SortConfig } from '@/app/workspace/[workspaceId]/components'
|
||||
import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ContextMenu } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu'
|
||||
import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal'
|
||||
import type { TableFilterHandle } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter'
|
||||
import { TableFilter } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter'
|
||||
import {
|
||||
useContextMenu,
|
||||
useExportTable,
|
||||
useTableData,
|
||||
} from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks'
|
||||
import type {
|
||||
EditingCell,
|
||||
QueryOptions,
|
||||
SaveReason,
|
||||
} from '@/app/workspace/[workspaceId]/tables/[tableId]/types'
|
||||
import {
|
||||
cleanCellValue,
|
||||
displayToStorage,
|
||||
formatValueForInput,
|
||||
storageToDisplay,
|
||||
} from '@/app/workspace/[workspaceId]/tables/[tableId]/utils'
|
||||
import {
|
||||
useAddTableColumn,
|
||||
useBatchCreateTableRows,
|
||||
@@ -60,17 +84,6 @@ import {
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo'
|
||||
import type { DeletedRowSnapshot } from '@/stores/table/types'
|
||||
import { useContextMenu, useTableData } from '../../hooks'
|
||||
import type { EditingCell, QueryOptions, SaveReason } from '../../types'
|
||||
import {
|
||||
cleanCellValue,
|
||||
displayToStorage,
|
||||
formatValueForInput,
|
||||
storageToDisplay,
|
||||
} from '../../utils'
|
||||
import { ContextMenu } from '../context-menu'
|
||||
import { RowModal } from '../row-modal'
|
||||
import { TableFilter } from '../table-filter'
|
||||
|
||||
interface CellCoord {
|
||||
rowIndex: number
|
||||
@@ -88,6 +101,7 @@ interface NormalizedSelection {
|
||||
|
||||
const EMPTY_COLUMNS: never[] = []
|
||||
const EMPTY_CHECKED_ROWS = new Set<number>()
|
||||
const clearCheckedRows = (prev: Set<number>) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)
|
||||
const COL_WIDTH = 160
|
||||
const COL_WIDTH_MIN = 80
|
||||
const CHECKBOX_COL_WIDTH = 40
|
||||
@@ -196,6 +210,7 @@ export function Table({
|
||||
const [initialCharacter, setInitialCharacter] = useState<string | null>(null)
|
||||
const [selectionAnchor, setSelectionAnchor] = useState<CellCoord | null>(null)
|
||||
const [selectionFocus, setSelectionFocus] = useState<CellCoord | null>(null)
|
||||
const [isColumnSelection, setIsColumnSelection] = useState(false)
|
||||
const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS)
|
||||
const lastCheckboxRowRef = useRef<number | null>(null)
|
||||
const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false)
|
||||
@@ -220,6 +235,7 @@ export function Table({
|
||||
const metadataSeededRef = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const tableFilterRef = useRef<TableFilterHandle>(null)
|
||||
const isDraggingRef = useRef(false)
|
||||
|
||||
const { tableData, isLoadingTable, rows, isLoadingRows } = useTableData({
|
||||
@@ -291,10 +307,11 @@ export function Table({
|
||||
const positionMapRef = useRef(positionMap)
|
||||
positionMapRef.current = positionMap
|
||||
|
||||
const normalizedSelection = useMemo(
|
||||
() => computeNormalizedSelection(selectionAnchor, selectionFocus),
|
||||
[selectionAnchor, selectionFocus]
|
||||
)
|
||||
const normalizedSelection = useMemo(() => {
|
||||
const raw = computeNormalizedSelection(selectionAnchor, selectionFocus)
|
||||
if (!raw || !isColumnSelection) return raw
|
||||
return { ...raw, startRow: 0, endRow: Math.max(maxPosition, 0) }
|
||||
}, [selectionAnchor, selectionFocus, isColumnSelection, maxPosition])
|
||||
|
||||
const displayColCount = isLoadingTable ? SKELETON_COL_COUNT : displayColumns.length
|
||||
const tableWidth = useMemo(() => {
|
||||
@@ -315,7 +332,18 @@ export function Table({
|
||||
}, [resizingColumn, displayColumns, columnWidths])
|
||||
|
||||
const dropIndicatorLeft = useMemo(() => {
|
||||
if (!dropTargetColumnName) return null
|
||||
if (!dropTargetColumnName || !dragColumnName) return null
|
||||
|
||||
const dragIdx = displayColumns.findIndex((c) => c.name === dragColumnName)
|
||||
const targetIdx = displayColumns.findIndex((c) => c.name === dropTargetColumnName)
|
||||
|
||||
if (dragIdx !== -1 && targetIdx !== -1) {
|
||||
// Suppress when drop would be a no-op (same effective position)
|
||||
if (targetIdx === dragIdx) return null
|
||||
if (dropSide === 'right' && targetIdx === dragIdx - 1) return null
|
||||
if (dropSide === 'left' && targetIdx === dragIdx + 1) return null
|
||||
}
|
||||
|
||||
let left = CHECKBOX_COL_WIDTH
|
||||
for (const col of displayColumns) {
|
||||
if (dropSide === 'left' && col.name === dropTargetColumnName) return left
|
||||
@@ -323,7 +351,7 @@ export function Table({
|
||||
if (dropSide === 'right' && col.name === dropTargetColumnName) return left
|
||||
}
|
||||
return null
|
||||
}, [dropTargetColumnName, dropSide, displayColumns, columnWidths])
|
||||
}, [dropTargetColumnName, dropSide, displayColumns, columnWidths, dragColumnName])
|
||||
|
||||
const isAllRowsSelected = useMemo(() => {
|
||||
if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) {
|
||||
@@ -350,6 +378,7 @@ export function Table({
|
||||
const rowsRef = useRef(rows)
|
||||
const selectionAnchorRef = useRef(selectionAnchor)
|
||||
const selectionFocusRef = useRef(selectionFocus)
|
||||
const normalizedSelectionRef = useRef(normalizedSelection)
|
||||
|
||||
const checkedRowsRef = useRef(checkedRows)
|
||||
checkedRowsRef.current = checkedRows
|
||||
@@ -359,6 +388,7 @@ export function Table({
|
||||
rowsRef.current = rows
|
||||
selectionAnchorRef.current = selectionAnchor
|
||||
selectionFocusRef.current = selectionFocus
|
||||
normalizedSelectionRef.current = normalizedSelection
|
||||
|
||||
const deleteTableMutation = useDeleteTable(workspaceId)
|
||||
const renameTableMutation = useRenameTable(workspaceId)
|
||||
@@ -574,7 +604,8 @@ export function Table({
|
||||
|
||||
const handleCellMouseDown = useCallback(
|
||||
(rowIndex: number, colIndex: number, shiftKey: boolean) => {
|
||||
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
|
||||
setCheckedRows(clearCheckedRows)
|
||||
setIsColumnSelection(false)
|
||||
lastCheckboxRowRef.current = null
|
||||
if (shiftKey && selectionAnchorRef.current) {
|
||||
setSelectionFocus({ rowIndex, colIndex })
|
||||
@@ -597,6 +628,7 @@ export function Table({
|
||||
setEditingCell(null)
|
||||
setSelectionAnchor(null)
|
||||
setSelectionFocus(null)
|
||||
setIsColumnSelection(false)
|
||||
|
||||
if (shiftKey && lastCheckboxRowRef.current !== null) {
|
||||
const from = Math.min(lastCheckboxRowRef.current, rowIndex)
|
||||
@@ -627,7 +659,8 @@ export function Table({
|
||||
const handleClearSelection = useCallback(() => {
|
||||
setSelectionAnchor(null)
|
||||
setSelectionFocus(null)
|
||||
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
|
||||
setIsColumnSelection(false)
|
||||
setCheckedRows(clearCheckedRows)
|
||||
lastCheckboxRowRef.current = null
|
||||
}, [])
|
||||
|
||||
@@ -637,6 +670,7 @@ export function Table({
|
||||
setEditingCell(null)
|
||||
setSelectionAnchor(null)
|
||||
setSelectionFocus(null)
|
||||
setIsColumnSelection(false)
|
||||
const all = new Set<number>()
|
||||
for (const row of rws) {
|
||||
all.add(row.position)
|
||||
@@ -682,21 +716,22 @@ export function Table({
|
||||
const target = dropTargetColumnNameRef.current
|
||||
const side = dropSideRef.current
|
||||
if (target && dragged !== target) {
|
||||
const cols = columnsRef.current
|
||||
const currentOrder = columnOrderRef.current ?? cols.map((c) => c.name)
|
||||
const fromIndex = currentOrder.indexOf(dragged)
|
||||
const toIndex = currentOrder.indexOf(target)
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
const newOrder = currentOrder.filter((n) => n !== dragged)
|
||||
let insertIndex = newOrder.indexOf(target)
|
||||
if (side === 'right') insertIndex += 1
|
||||
newOrder.splice(insertIndex, 0, dragged)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
const currentOrder = columnOrderRef.current ?? columnsRef.current.map((c) => c.name)
|
||||
const newOrder = currentOrder.filter((n) => n !== dragged)
|
||||
const targetIndex = newOrder.indexOf(target)
|
||||
if (targetIndex === -1) {
|
||||
setDragColumnName(null)
|
||||
setDropTargetColumnName(null)
|
||||
setDropSide('left')
|
||||
return
|
||||
}
|
||||
const insertIndex = side === 'right' ? targetIndex + 1 : targetIndex
|
||||
newOrder.splice(insertIndex, 0, dragged)
|
||||
setColumnOrder(newOrder)
|
||||
updateMetadataRef.current({
|
||||
columnWidths: columnWidthsRef.current,
|
||||
columnOrder: newOrder,
|
||||
})
|
||||
}
|
||||
setDragColumnName(null)
|
||||
setDropTargetColumnName(null)
|
||||
@@ -782,6 +817,9 @@ export function Table({
|
||||
const updateMetadataRef = useRef(updateMetadataMutation.mutate)
|
||||
updateMetadataRef.current = updateMetadataMutation.mutate
|
||||
|
||||
const addColumnAsyncRef = useRef(addColumnMutation.mutateAsync)
|
||||
addColumnAsyncRef.current = addColumnMutation.mutateAsync
|
||||
|
||||
const toggleBooleanCellRef = useRef(toggleBooleanCell)
|
||||
toggleBooleanCellRef.current = toggleBooleanCell
|
||||
|
||||
@@ -794,7 +832,21 @@ export function Table({
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
if (e.key === 'Escape') setIsColumnSelection(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
isDraggingRef.current = false
|
||||
setSelectionAnchor(null)
|
||||
setSelectionFocus(null)
|
||||
setIsColumnSelection(false)
|
||||
setCheckedRows(clearCheckedRows)
|
||||
lastCheckboxRowRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) {
|
||||
e.preventDefault()
|
||||
@@ -806,15 +858,6 @@ export function Table({
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setSelectionAnchor(null)
|
||||
setSelectionFocus(null)
|
||||
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
|
||||
lastCheckboxRowRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
|
||||
e.preventDefault()
|
||||
const rws = rowsRef.current
|
||||
@@ -822,6 +865,7 @@ export function Table({
|
||||
setEditingCell(null)
|
||||
setSelectionAnchor(null)
|
||||
setSelectionFocus(null)
|
||||
setIsColumnSelection(false)
|
||||
const all = new Set<number>()
|
||||
for (const row of rws) {
|
||||
all.add(row.position)
|
||||
@@ -835,6 +879,7 @@ export function Table({
|
||||
const a = selectionAnchorRef.current
|
||||
if (!a || editingCellRef.current) return
|
||||
e.preventDefault()
|
||||
setIsColumnSelection(false)
|
||||
setSelectionFocus(null)
|
||||
setCheckedRows((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -887,6 +932,7 @@ export function Table({
|
||||
const row = positionMapRef.current.get(anchor.rowIndex)
|
||||
if (!row) return
|
||||
e.preventDefault()
|
||||
setIsColumnSelection(false)
|
||||
const position = row.position + 1
|
||||
const colIndex = anchor.colIndex
|
||||
createRef.current(
|
||||
@@ -908,12 +954,12 @@ export function Table({
|
||||
if (e.key === 'Enter' || e.key === 'F2') {
|
||||
if (!canEditRef.current) return
|
||||
e.preventDefault()
|
||||
setIsColumnSelection(false)
|
||||
const col = cols[anchor.colIndex]
|
||||
if (!col) return
|
||||
|
||||
const row = positionMapRef.current.get(anchor.rowIndex)
|
||||
if (!row) return
|
||||
|
||||
if (col.type === 'boolean') {
|
||||
toggleBooleanCellRef.current(row.id, col.name, row.data[col.name])
|
||||
return
|
||||
@@ -935,7 +981,8 @@ export function Table({
|
||||
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
|
||||
setCheckedRows(clearCheckedRows)
|
||||
setIsColumnSelection(false)
|
||||
lastCheckboxRowRef.current = null
|
||||
setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1))
|
||||
setSelectionFocus(null)
|
||||
@@ -944,7 +991,8 @@ export function Table({
|
||||
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault()
|
||||
setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS))
|
||||
setCheckedRows(clearCheckedRows)
|
||||
setIsColumnSelection(false)
|
||||
lastCheckboxRowRef.current = null
|
||||
const focus = selectionFocusRef.current ?? anchor
|
||||
const origin = e.shiftKey ? focus : anchor
|
||||
@@ -979,7 +1027,7 @@ export function Table({
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (!canEditRef.current) return
|
||||
e.preventDefault()
|
||||
const sel = computeNormalizedSelection(anchor, selectionFocusRef.current)
|
||||
const sel = normalizedSelectionRef.current
|
||||
if (!sel) return
|
||||
const pMap = positionMapRef.current
|
||||
const undoCells: Array<{ rowId: string; data: Record<string, unknown> }> = []
|
||||
@@ -1011,6 +1059,7 @@ export function Table({
|
||||
if (col.type === 'number' && !/[\d.-]/.test(e.key)) return
|
||||
if (col.type === 'date' && !/[\d\-/]/.test(e.key)) return
|
||||
e.preventDefault()
|
||||
setIsColumnSelection(false)
|
||||
|
||||
const row = positionMapRef.current.get(anchor.rowIndex)
|
||||
if (!row) return
|
||||
@@ -1047,10 +1096,7 @@ export function Table({
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = selectionAnchorRef.current
|
||||
if (!anchor) return
|
||||
|
||||
const sel = computeNormalizedSelection(anchor, selectionFocusRef.current)
|
||||
const sel = normalizedSelectionRef.current
|
||||
if (!sel) return
|
||||
|
||||
e.preventDefault()
|
||||
@@ -1106,10 +1152,7 @@ export function Table({
|
||||
}
|
||||
e.clipboardData?.setData('text/plain', lines.join('\n'))
|
||||
} else {
|
||||
const anchor = selectionAnchorRef.current
|
||||
if (!anchor) return
|
||||
|
||||
const sel = computeNormalizedSelection(anchor, selectionFocusRef.current)
|
||||
const sel = normalizedSelectionRef.current
|
||||
if (!sel) return
|
||||
|
||||
e.preventDefault()
|
||||
@@ -1145,7 +1188,7 @@ export function Table({
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const tag = (e.target as HTMLElement).tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
if (!canEditRef.current) return
|
||||
@@ -1164,8 +1207,48 @@ export function Table({
|
||||
|
||||
if (pasteRows.length === 0) return
|
||||
|
||||
const currentCols = columnsRef.current
|
||||
let currentCols = columnsRef.current
|
||||
const pMap = positionMapRef.current
|
||||
const maxPasteCols = Math.max(...pasteRows.map((pr) => pr.length))
|
||||
const neededExtraCols = Math.max(
|
||||
0,
|
||||
currentAnchor.colIndex + maxPasteCols - currentCols.length
|
||||
)
|
||||
|
||||
if (neededExtraCols > 0) {
|
||||
// Generate unique names for the new columns without colliding with each other
|
||||
const existingNames = new Set(currentCols.map((c) => c.name.toLowerCase()))
|
||||
const newColNames: string[] = []
|
||||
for (let i = 0; i < neededExtraCols; i++) {
|
||||
let name = 'untitled'
|
||||
let n = 2
|
||||
while (existingNames.has(name.toLowerCase())) {
|
||||
name = `untitled_${n}`
|
||||
n++
|
||||
}
|
||||
existingNames.add(name.toLowerCase())
|
||||
newColNames.push(name)
|
||||
}
|
||||
|
||||
// Create columns sequentially so each invalidation completes before the next
|
||||
const createdColNames: string[] = []
|
||||
try {
|
||||
for (const name of newColNames) {
|
||||
await addColumnAsyncRef.current({ name, type: 'string' })
|
||||
createdColNames.push(name)
|
||||
}
|
||||
} catch {
|
||||
// If column creation fails partway, paste into whatever columns were created
|
||||
}
|
||||
|
||||
// Build updated column list locally — React Query cache may not have refreshed yet
|
||||
if (createdColNames.length > 0) {
|
||||
currentCols = [
|
||||
...currentCols,
|
||||
...createdColNames.map((name) => ({ name, type: 'string' as const })),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const undoCells: Array<{ rowId: string; data: Record<string, unknown> }> = []
|
||||
const updateBatch: Array<{ rowId: string; data: Record<string, unknown> }> = []
|
||||
@@ -1245,7 +1328,6 @@ export function Table({
|
||||
)
|
||||
}
|
||||
|
||||
const maxPasteCols = Math.max(...pasteRows.map((pr) => pr.length))
|
||||
setSelectionFocus({
|
||||
rowIndex: currentAnchor.rowIndex + pasteRows.length - 1,
|
||||
colIndex: Math.min(currentAnchor.colIndex + maxPasteCols - 1, currentCols.length - 1),
|
||||
@@ -1321,10 +1403,10 @@ export function Table({
|
||||
}, [])
|
||||
|
||||
const generateColumnName = useCallback(() => {
|
||||
const existing = schemaColumnsRef.current.map((c) => c.name.toLowerCase())
|
||||
const existing = new Set(schemaColumnsRef.current.map((c) => c.name.toLowerCase()))
|
||||
let name = 'untitled'
|
||||
let i = 2
|
||||
while (existing.includes(name.toLowerCase())) {
|
||||
while (existing.has(name)) {
|
||||
name = `untitled_${i}`
|
||||
i++
|
||||
}
|
||||
@@ -1429,7 +1511,10 @@ export function Table({
|
||||
}, [])
|
||||
|
||||
const handleRenameColumn = useCallback(
|
||||
(name: string) => columnRename.startRename(name, name),
|
||||
(name: string) => {
|
||||
isDraggingRef.current = false
|
||||
columnRename.startRename(name, name)
|
||||
},
|
||||
[columnRename.startRename]
|
||||
)
|
||||
|
||||
@@ -1440,10 +1525,22 @@ export function Table({
|
||||
const handleDeleteColumnConfirm = useCallback(() => {
|
||||
if (!deletingColumn) return
|
||||
const columnToDelete = deletingColumn
|
||||
const column = schemaColumnsRef.current.find((c) => c.name === columnToDelete)
|
||||
const position = schemaColumnsRef.current.findIndex((c) => c.name === columnToDelete)
|
||||
const orderAtDelete = columnOrderRef.current
|
||||
setDeletingColumn(null)
|
||||
deleteColumnMutation.mutate(columnToDelete, {
|
||||
onSuccess: () => {
|
||||
if (column && position !== -1) {
|
||||
pushUndoRef.current({
|
||||
type: 'delete-column',
|
||||
columnName: columnToDelete,
|
||||
columnType: column.type,
|
||||
position,
|
||||
unique: !!column.unique,
|
||||
required: !!column.required,
|
||||
})
|
||||
}
|
||||
if (!orderAtDelete) return
|
||||
const newOrder = orderAtDelete.filter((n) => n !== columnToDelete)
|
||||
setColumnOrder(newOrder)
|
||||
@@ -1468,13 +1565,28 @@ export function Table({
|
||||
}, [])
|
||||
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [initialFilterColumn, setInitialFilterColumn] = useState<string | null>(null)
|
||||
|
||||
const handleFilterToggle = useCallback(() => {
|
||||
setInitialFilterColumn(null)
|
||||
setFilterOpen((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const handleFilterClose = useCallback(() => {
|
||||
setFilterOpen(false)
|
||||
setInitialFilterColumn(null)
|
||||
}, [])
|
||||
|
||||
const filterOpenRef = useRef(filterOpen)
|
||||
filterOpenRef.current = filterOpen
|
||||
|
||||
const handleFilterByColumn = useCallback((columnName: string) => {
|
||||
if (filterOpenRef.current && tableFilterRef.current) {
|
||||
tableFilterRef.current.addColumnRule(columnName)
|
||||
} else {
|
||||
setInitialFilterColumn(columnName)
|
||||
setFilterOpen(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const columnOptions = useMemo<ColumnOption[]>(
|
||||
@@ -1555,6 +1667,27 @@ export function Table({
|
||||
[handleAddColumn, addColumnMutation.isPending]
|
||||
)
|
||||
|
||||
const { handleExportTable, isExporting } = useExportTable({
|
||||
workspaceId,
|
||||
tableId,
|
||||
tableName: tableData?.name,
|
||||
columns: displayColumns,
|
||||
queryOptions,
|
||||
canExport: userPermissions.canEdit,
|
||||
})
|
||||
|
||||
const headerActions = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: isExporting ? 'Exporting...' : 'Export CSV',
|
||||
icon: Download,
|
||||
onClick: () => void handleExportTable(),
|
||||
disabled: !userPermissions.canEdit || !hasTableData || isLoadingTable || isExporting,
|
||||
},
|
||||
],
|
||||
[handleExportTable, hasTableData, isExporting, isLoadingTable, userPermissions.canEdit]
|
||||
)
|
||||
|
||||
const activeSortState = useMemo(() => {
|
||||
if (!queryOptions.sort) return null
|
||||
const entries = Object.entries(queryOptions.sort)
|
||||
@@ -1563,6 +1696,32 @@ export function Table({
|
||||
return { column, direction }
|
||||
}, [queryOptions.sort])
|
||||
|
||||
const selectedColumnRange = useMemo(() => {
|
||||
if (!isColumnSelection || !normalizedSelection) return null
|
||||
return { start: normalizedSelection.startCol, end: normalizedSelection.endCol }
|
||||
}, [isColumnSelection, normalizedSelection])
|
||||
|
||||
const draggingColIndex = useMemo(
|
||||
() => (dragColumnName ? displayColumns.findIndex((c) => c.name === dragColumnName) : null),
|
||||
[dragColumnName, displayColumns]
|
||||
)
|
||||
|
||||
const handleColumnSelect = useCallback((colIndex: number) => {
|
||||
setSelectionAnchor({ rowIndex: 0, colIndex })
|
||||
setSelectionFocus({ rowIndex: 0, colIndex })
|
||||
setIsColumnSelection(true)
|
||||
}, [])
|
||||
|
||||
const handleSortAsc = useCallback(
|
||||
(columnName: string) => handleSortChange(columnName, 'asc'),
|
||||
[handleSortChange]
|
||||
)
|
||||
|
||||
const handleSortDesc = useCallback(
|
||||
(columnName: string) => handleSortChange(columnName, 'desc'),
|
||||
[handleSortChange]
|
||||
)
|
||||
|
||||
const sortConfig = useMemo<SortConfig>(
|
||||
() => ({
|
||||
options: columnOptions,
|
||||
@@ -1619,7 +1778,12 @@ export function Table({
|
||||
<div ref={containerRef} className='flex h-full flex-col overflow-hidden'>
|
||||
{!embedded && (
|
||||
<>
|
||||
<ResourceHeader icon={TableIcon} breadcrumbs={breadcrumbs} create={createAction} />
|
||||
<ResourceHeader
|
||||
icon={TableIcon}
|
||||
breadcrumbs={breadcrumbs}
|
||||
actions={headerActions}
|
||||
create={createAction}
|
||||
/>
|
||||
|
||||
<ResourceOptionsBar
|
||||
sort={sortConfig}
|
||||
@@ -1628,10 +1792,12 @@ export function Table({
|
||||
/>
|
||||
{filterOpen && (
|
||||
<TableFilter
|
||||
ref={tableFilterRef}
|
||||
columns={displayColumns}
|
||||
filter={queryOptions.filter}
|
||||
onApply={handleFilterApply}
|
||||
onClose={handleFilterClose}
|
||||
initialColumn={initialFilterColumn}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -1691,10 +1857,11 @@ export function Table({
|
||||
checked={isAllRowsSelected}
|
||||
onCheckedChange={handleSelectAllToggle}
|
||||
/>
|
||||
{displayColumns.map((column) => (
|
||||
{displayColumns.map((column, colIndex) => (
|
||||
<ColumnHeaderMenu
|
||||
key={column.name}
|
||||
column={column}
|
||||
colIndex={colIndex}
|
||||
readOnly={!userPermissions.canEdit}
|
||||
isRenaming={columnRename.editingId === column.name}
|
||||
renameValue={
|
||||
@@ -1713,10 +1880,20 @@ export function Table({
|
||||
onResize={handleColumnResize}
|
||||
onResizeEnd={handleColumnResizeEnd}
|
||||
isDragging={dragColumnName === column.name}
|
||||
isDropTarget={
|
||||
dropTargetColumnName === column.name && dropIndicatorLeft !== null
|
||||
}
|
||||
onDragStart={handleColumnDragStart}
|
||||
onDragOver={handleColumnDragOver}
|
||||
onDragEnd={handleColumnDragEnd}
|
||||
onDragLeave={handleColumnDragLeave}
|
||||
sortDirection={
|
||||
activeSortState?.column === column.name ? activeSortState.direction : null
|
||||
}
|
||||
onSortAsc={handleSortAsc}
|
||||
onSortDesc={handleSortDesc}
|
||||
onFilterColumn={handleFilterByColumn}
|
||||
onColumnSelect={handleColumnSelect}
|
||||
/>
|
||||
))}
|
||||
{userPermissions.canEdit && (
|
||||
@@ -1744,6 +1921,7 @@ export function Table({
|
||||
startPosition={prevPosition + 1}
|
||||
columns={displayColumns}
|
||||
normalizedSelection={normalizedSelection}
|
||||
draggingColIndex={draggingColIndex}
|
||||
checkedRows={checkedRows}
|
||||
firstRowUnderHeader={prevPosition === -1}
|
||||
onCellMouseDown={handleCellMouseDown}
|
||||
@@ -1766,6 +1944,7 @@ export function Table({
|
||||
: null
|
||||
}
|
||||
normalizedSelection={normalizedSelection}
|
||||
draggingColIndex={draggingColIndex}
|
||||
onClick={handleCellClick}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
onSave={handleInlineSave}
|
||||
@@ -1917,6 +2096,7 @@ interface PositionGapRowsProps {
|
||||
startPosition: number
|
||||
columns: ColumnDefinition[]
|
||||
normalizedSelection: NormalizedSelection | null
|
||||
draggingColIndex: number | null
|
||||
checkedRows: Set<number>
|
||||
firstRowUnderHeader?: boolean
|
||||
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
|
||||
@@ -1930,6 +2110,7 @@ const PositionGapRows = React.memo(
|
||||
startPosition,
|
||||
columns,
|
||||
normalizedSelection,
|
||||
draggingColIndex,
|
||||
checkedRows,
|
||||
firstRowUnderHeader = false,
|
||||
onCellMouseDown,
|
||||
@@ -1995,7 +2176,11 @@ const PositionGapRows = React.memo(
|
||||
key={col.name}
|
||||
data-row={position}
|
||||
data-col={colIndex}
|
||||
className={cn(CELL, (isHighlighted || isAnchor) && 'relative')}
|
||||
className={cn(
|
||||
CELL,
|
||||
(isHighlighted || isAnchor) && 'relative',
|
||||
draggingColIndex === colIndex && 'opacity-40'
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
onCellMouseDown(position, colIndex, e.shiftKey)
|
||||
@@ -2040,6 +2225,7 @@ const PositionGapRows = React.memo(
|
||||
prev.startPosition !== next.startPosition ||
|
||||
prev.columns !== next.columns ||
|
||||
prev.normalizedSelection !== next.normalizedSelection ||
|
||||
prev.draggingColIndex !== next.draggingColIndex ||
|
||||
prev.firstRowUnderHeader !== next.firstRowUnderHeader ||
|
||||
prev.onCellMouseDown !== next.onCellMouseDown ||
|
||||
prev.onCellMouseEnter !== next.onCellMouseEnter ||
|
||||
@@ -2082,6 +2268,7 @@ interface DataRowProps {
|
||||
initialCharacter: string | null
|
||||
pendingCellValue: Record<string, unknown> | null
|
||||
normalizedSelection: NormalizedSelection | null
|
||||
draggingColIndex: number | null
|
||||
onClick: (rowId: string, columnName: string) => void
|
||||
onDoubleClick: (rowId: string, columnName: string) => void
|
||||
onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void
|
||||
@@ -2132,6 +2319,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
|
||||
prev.isFirstRow !== next.isFirstRow ||
|
||||
prev.editingColumnName !== next.editingColumnName ||
|
||||
prev.pendingCellValue !== next.pendingCellValue ||
|
||||
prev.draggingColIndex !== next.draggingColIndex ||
|
||||
prev.onClick !== next.onClick ||
|
||||
prev.onDoubleClick !== next.onDoubleClick ||
|
||||
prev.onSave !== next.onSave ||
|
||||
@@ -2168,6 +2356,7 @@ const DataRow = React.memo(function DataRow({
|
||||
initialCharacter,
|
||||
pendingCellValue,
|
||||
normalizedSelection,
|
||||
draggingColIndex,
|
||||
isRowChecked,
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
@@ -2235,7 +2424,11 @@ const DataRow = React.memo(function DataRow({
|
||||
key={column.name}
|
||||
data-row={rowIndex}
|
||||
data-col={colIndex}
|
||||
className={cn(CELL, (isHighlighted || isAnchor || isEditing) && 'relative')}
|
||||
className={cn(
|
||||
CELL,
|
||||
(isHighlighted || isAnchor || isEditing) && 'relative',
|
||||
draggingColIndex === colIndex && 'opacity-40'
|
||||
)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button !== 0 || isEditing) return
|
||||
onCellMouseDown(rowIndex, colIndex, e.shiftKey)
|
||||
@@ -2605,6 +2798,7 @@ const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementTyp
|
||||
|
||||
const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
column,
|
||||
colIndex,
|
||||
readOnly,
|
||||
isRenaming,
|
||||
renameValue,
|
||||
@@ -2621,12 +2815,19 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
isDragging,
|
||||
isDropTarget,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragEnd,
|
||||
onDragLeave,
|
||||
sortDirection,
|
||||
onSortAsc,
|
||||
onSortDesc,
|
||||
onFilterColumn,
|
||||
onColumnSelect,
|
||||
}: {
|
||||
column: ColumnDefinition
|
||||
colIndex: number
|
||||
readOnly?: boolean
|
||||
isRenaming: boolean
|
||||
renameValue: string
|
||||
@@ -2643,10 +2844,16 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
onResize: (columnName: string, width: number) => void
|
||||
onResizeEnd: () => void
|
||||
isDragging?: boolean
|
||||
isDropTarget?: boolean
|
||||
onDragStart?: (columnName: string) => void
|
||||
onDragOver?: (columnName: string, side: 'left' | 'right') => void
|
||||
onDragEnd?: () => void
|
||||
onDragLeave?: () => void
|
||||
sortDirection?: SortDirection | null
|
||||
onSortAsc?: (columnName: string) => void
|
||||
onSortDesc?: (columnName: string) => void
|
||||
onFilterColumn?: (columnName: string) => void
|
||||
onColumnSelect?: (colIndex: number) => void
|
||||
}) {
|
||||
const renameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -2735,7 +2942,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
<th
|
||||
className={cn(
|
||||
'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
|
||||
isDragging && 'opacity-40'
|
||||
isDragging && 'opacity-40',
|
||||
isDropTarget && 'bg-[var(--selection)]/10'
|
||||
)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
@@ -2760,7 +2968,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
) : readOnly ? (
|
||||
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
|
||||
{column.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -2771,15 +2979,34 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
|
||||
<button
|
||||
type='button'
|
||||
className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none'
|
||||
onClick={() => onColumnSelect?.(colIndex)}
|
||||
>
|
||||
<ColumnTypeIcon type={column.type} />
|
||||
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
|
||||
{column.name}
|
||||
</span>
|
||||
{sortDirection && (
|
||||
<span className='ml-1 shrink-0'>
|
||||
<SortDirectionIndicator direction={sortDirection} />
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className='ml-1.5 h-[7px] w-[9px] shrink-0 text-[var(--text-muted)]' />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onSelect={() => onSortAsc?.(column.name)}>
|
||||
<ArrowUp />
|
||||
Sort ascending
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSortDesc?.(column.name)}>
|
||||
<ArrowDown />
|
||||
Sort descending
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onFilterColumn?.(column.name)}>
|
||||
<ListFilter />
|
||||
Filter by this column
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onRenameColumn(column.name)}>
|
||||
<Pencil />
|
||||
Rename column
|
||||
@@ -2900,3 +3127,11 @@ function ColumnTypeIcon({ type }: { type: string }) {
|
||||
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText
|
||||
return <Icon className='h-3 w-3 shrink-0 text-[var(--text-icon)]' />
|
||||
}
|
||||
|
||||
function SortDirectionIndicator({ direction }: { direction: SortDirection }) {
|
||||
return direction === 'asc' ? (
|
||||
<ArrowUp className='h-[10px] w-[10px] text-[var(--text-muted)]' />
|
||||
) : (
|
||||
<ArrowDown className='h-[10px] w-[10px] text-[var(--text-muted)]' />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createTableColumn, createTableRow } from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTableCsv, formatTableExportValue } from './export'
|
||||
|
||||
describe('table export utils', () => {
|
||||
it('formats exported values using table display conventions', () => {
|
||||
expect(formatTableExportValue('2026-04-03', { name: 'date', type: 'date' })).toBe('04/03/2026')
|
||||
expect(formatTableExportValue({ nested: true }, { name: 'payload', type: 'json' })).toBe(
|
||||
'{"nested":true}'
|
||||
)
|
||||
expect(formatTableExportValue(null, { name: 'empty', type: 'string' })).toBe('')
|
||||
})
|
||||
|
||||
it('builds CSV using visible columns and escaped values', () => {
|
||||
const columns = [
|
||||
createTableColumn({ name: 'name', type: 'string' }),
|
||||
createTableColumn({ name: 'date', type: 'date' }),
|
||||
createTableColumn({ name: 'notes', type: 'json' }),
|
||||
]
|
||||
|
||||
const rows = [
|
||||
createTableRow({
|
||||
id: 'row_1',
|
||||
position: 0,
|
||||
createdAt: '2026-04-03T00:00:00.000Z',
|
||||
updatedAt: '2026-04-03T00:00:00.000Z',
|
||||
data: {
|
||||
name: 'Ada "Lovelace"',
|
||||
date: '2026-04-03',
|
||||
notes: { text: 'line 1\nline 2' },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
expect(buildTableCsv(columns, rows)).toBe(
|
||||
'name,date,notes\r\n"Ada ""Lovelace""",04/03/2026,"{""text"":""line 1\\nline 2""}"'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ColumnDefinition, TableRow } from '@/lib/table'
|
||||
import { storageToDisplay } from './utils'
|
||||
|
||||
function safeJsonStringify(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTableExportValue(value: unknown, column: ColumnDefinition): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
|
||||
switch (column.type) {
|
||||
case 'date':
|
||||
return storageToDisplay(String(value))
|
||||
case 'json':
|
||||
return typeof value === 'string' ? value : safeJsonStringify(value)
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeCsvCell(value: string): string {
|
||||
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value
|
||||
}
|
||||
|
||||
export function buildTableCsv(columns: ColumnDefinition[], rows: TableRow[]): string {
|
||||
const headerRow = columns.map((column) => escapeCsvCell(column.name)).join(',')
|
||||
const dataRows = rows.map((row) =>
|
||||
columns
|
||||
.map((column) => escapeCsvCell(formatTableExportValue(row.data[column.name], column)))
|
||||
.join(',')
|
||||
)
|
||||
|
||||
return [headerRow, ...dataRows].join('\r\n')
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './use-context-menu'
|
||||
export * from './use-export-table'
|
||||
export * from './use-table-data'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { TableRow } from '@/lib/table'
|
||||
import type { ContextMenuState } from '../types'
|
||||
import type { ContextMenuState } from '@/app/workspace/[workspaceId]/tables/[tableId]/types'
|
||||
|
||||
interface UseContextMenuReturn {
|
||||
contextMenu: ContextMenuState
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { toast } from '@/components/emcn'
|
||||
import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import type { ColumnDefinition } from '@/lib/table'
|
||||
import { buildTableCsv } from '@/app/workspace/[workspaceId]/tables/[tableId]/export'
|
||||
import type { QueryOptions } from '@/app/workspace/[workspaceId]/tables/[tableId]/types'
|
||||
import { fetchAllTableRows } from '@/hooks/queries/tables'
|
||||
|
||||
interface UseExportTableParams {
|
||||
workspaceId: string
|
||||
tableId: string
|
||||
tableName?: string | null
|
||||
columns: ColumnDefinition[]
|
||||
queryOptions: QueryOptions
|
||||
canExport: boolean
|
||||
}
|
||||
|
||||
export function useExportTable({
|
||||
workspaceId,
|
||||
tableId,
|
||||
tableName,
|
||||
columns,
|
||||
queryOptions,
|
||||
canExport,
|
||||
}: UseExportTableParams) {
|
||||
const posthog = usePostHog()
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const isExportingRef = useRef(false)
|
||||
|
||||
const handleExportTable = useCallback(async () => {
|
||||
if (!canExport || !workspaceId || !tableId || isExportingRef.current) return
|
||||
|
||||
isExportingRef.current = true
|
||||
setIsExporting(true)
|
||||
|
||||
try {
|
||||
const { rows } = await fetchAllTableRows({
|
||||
workspaceId,
|
||||
tableId,
|
||||
filter: queryOptions.filter,
|
||||
sort: queryOptions.sort,
|
||||
})
|
||||
|
||||
const filename = `${sanitizePathSegment(tableName?.trim() || 'table')}.csv`
|
||||
const csvContent = buildTableCsv(columns, rows)
|
||||
|
||||
downloadFile(csvContent, filename, 'text/csv;charset=utf-8;')
|
||||
|
||||
captureEvent(posthog, 'table_exported', {
|
||||
workspace_id: workspaceId,
|
||||
table_id: tableId,
|
||||
row_count: rows.length,
|
||||
column_count: columns.length,
|
||||
has_filter: Boolean(queryOptions.filter),
|
||||
has_sort: Boolean(queryOptions.sort),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to export table', {
|
||||
duration: 5000,
|
||||
})
|
||||
} finally {
|
||||
isExportingRef.current = false
|
||||
setIsExporting(false)
|
||||
}
|
||||
}, [
|
||||
canExport,
|
||||
columns,
|
||||
posthog,
|
||||
queryOptions.filter,
|
||||
queryOptions.sort,
|
||||
tableId,
|
||||
tableName,
|
||||
workspaceId,
|
||||
])
|
||||
|
||||
return {
|
||||
isExporting,
|
||||
handleExportTable,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TableDefinition, TableRow } from '@/lib/table'
|
||||
import { TABLE_LIMITS } from '@/lib/table/constants'
|
||||
import type { QueryOptions } from '@/app/workspace/[workspaceId]/tables/[tableId]/types'
|
||||
import { useTable, useTableRows } from '@/hooks/queries/tables'
|
||||
import type { QueryOptions } from '../types'
|
||||
|
||||
interface UseTableDataParams {
|
||||
workspaceId: string
|
||||
@@ -30,7 +31,7 @@ export function useTableData({
|
||||
} = useTableRows({
|
||||
workspaceId,
|
||||
tableId,
|
||||
limit: 1000,
|
||||
limit: TABLE_LIMITS.MAX_QUERY_LIMIT,
|
||||
offset: 0,
|
||||
filter: queryOptions.filter,
|
||||
sort: queryOptions.sort,
|
||||
|
||||
@@ -68,9 +68,8 @@ export function Tables() {
|
||||
const { data: tables = [], isLoading, error } = useTablesList(workspaceId)
|
||||
const { data: members } = useWorkspaceMembersQuery(workspaceId)
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to load tables:', error)
|
||||
}
|
||||
if (error) logger.error('Failed to load tables:', error)
|
||||
|
||||
const deleteTable = useDeleteTable(workspaceId)
|
||||
const createTable = useCreateTable(workspaceId)
|
||||
const uploadCsv = useUploadCsvToTable()
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Command } from 'cmdk'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Library } from '@/components/emcn'
|
||||
import { Calendar, Database, File, HelpCircle, Settings, Table } from '@/components/emcn/icons'
|
||||
import { Search } from '@/components/emcn/icons/search'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
@@ -55,11 +57,14 @@ export function SearchModal({
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { navigateToSettings } = useSettingsNavigation()
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const posthog = usePostHog()
|
||||
|
||||
const routerRef = useRef(router)
|
||||
routerRef.current = router
|
||||
const onOpenChangeRef = useRef(onOpenChange)
|
||||
onOpenChangeRef.current = onOpenChange
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -154,6 +159,8 @@ export function SearchModal({
|
||||
}, [open])
|
||||
|
||||
const deferredSearch = useDeferredValue(search)
|
||||
const deferredSearchRef = useRef(deferredSearch)
|
||||
deferredSearchRef.current = deferredSearch
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearch(value)
|
||||
@@ -188,59 +195,151 @@ export function SearchModal({
|
||||
detail: { type: block.type, enableTriggerMode },
|
||||
})
|
||||
)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: type,
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[]
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleToolOperationSelect = useCallback((op: SearchToolOperationItem) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: op.blockType, presetOperation: op.operationId },
|
||||
})
|
||||
)
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
|
||||
const handleWorkflowSelect = useCallback((workflow: WorkflowItem) => {
|
||||
if (!workflow.isCurrent && workflow.href) {
|
||||
routerRef.current.push(workflow.href)
|
||||
const handleToolOperationSelect = useCallback(
|
||||
(op: SearchToolOperationItem) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
|
||||
new CustomEvent('add-block-from-toolbar', {
|
||||
detail: { type: op.blockType, presetOperation: op.operationId },
|
||||
})
|
||||
)
|
||||
}
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'tool_operation',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleWorkspaceSelect = useCallback((workspace: WorkspaceItem) => {
|
||||
if (!workspace.isCurrent && workspace.href) {
|
||||
routerRef.current.push(workspace.href)
|
||||
}
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
|
||||
const handleTaskSelect = useCallback((task: TaskItem) => {
|
||||
routerRef.current.push(task.href)
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
|
||||
const handlePageSelect = useCallback((page: PageItem) => {
|
||||
if (page.onClick) {
|
||||
page.onClick()
|
||||
} else if (page.href) {
|
||||
if (page.href.startsWith('http')) {
|
||||
window.open(page.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
routerRef.current.push(page.href)
|
||||
const handleWorkflowSelect = useCallback(
|
||||
(workflow: WorkflowItem) => {
|
||||
if (!workflow.isCurrent && workflow.href) {
|
||||
routerRef.current.push(workflow.href)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SIDEBAR_SCROLL_EVENT, { detail: { itemId: workflow.id } })
|
||||
)
|
||||
}
|
||||
}
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'workflow',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleDocSelect = useCallback((doc: SearchDocItem) => {
|
||||
window.open(doc.href, '_blank', 'noopener,noreferrer')
|
||||
onOpenChangeRef.current(false)
|
||||
}, [])
|
||||
const handleWorkspaceSelect = useCallback(
|
||||
(workspace: WorkspaceItem) => {
|
||||
if (!workspace.isCurrent && workspace.href) {
|
||||
routerRef.current.push(workspace.href)
|
||||
}
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'workspace',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleTaskSelect = useCallback(
|
||||
(task: TaskItem) => {
|
||||
routerRef.current.push(task.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'task',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleTableSelect = useCallback(
|
||||
(item: TaskItem) => {
|
||||
routerRef.current.push(item.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'table',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(item: TaskItem) => {
|
||||
routerRef.current.push(item.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'file',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleKbSelect = useCallback(
|
||||
(item: TaskItem) => {
|
||||
routerRef.current.push(item.href)
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'knowledge_base',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handlePageSelect = useCallback(
|
||||
(page: PageItem) => {
|
||||
if (page.onClick) {
|
||||
page.onClick()
|
||||
} else if (page.href) {
|
||||
if (page.href.startsWith('http')) {
|
||||
window.open(page.href, '_blank', 'noopener,noreferrer')
|
||||
} else {
|
||||
routerRef.current.push(page.href)
|
||||
}
|
||||
}
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'page',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleDocSelect = useCallback(
|
||||
(doc: SearchDocItem) => {
|
||||
window.open(doc.href, '_blank', 'noopener,noreferrer')
|
||||
captureEvent(posthogRef.current, 'search_result_selected', {
|
||||
result_type: 'docs',
|
||||
query_length: deferredSearchRef.current.length,
|
||||
workspace_id: workspaceId,
|
||||
})
|
||||
onOpenChangeRef.current(false)
|
||||
},
|
||||
[workspaceId]
|
||||
)
|
||||
|
||||
const handleBlockSelectAsBlock = useCallback(
|
||||
(block: SearchBlockItem) => handleBlockSelect(block, 'block'),
|
||||
@@ -370,9 +469,9 @@ export function SearchModal({
|
||||
<TriggersGroup items={filteredTriggers} onSelect={handleBlockSelectAsTrigger} />
|
||||
<WorkflowsGroup items={filteredWorkflows} onSelect={handleWorkflowSelect} />
|
||||
<TasksGroup items={filteredTasks} onSelect={handleTaskSelect} />
|
||||
<TablesGroup items={filteredTables} onSelect={handleTaskSelect} />
|
||||
<FilesGroup items={filteredFiles} onSelect={handleTaskSelect} />
|
||||
<KnowledgeBasesGroup items={filteredKnowledgeBases} onSelect={handleTaskSelect} />
|
||||
<TablesGroup items={filteredTables} onSelect={handleTableSelect} />
|
||||
<FilesGroup items={filteredFiles} onSelect={handleFileSelect} />
|
||||
<KnowledgeBasesGroup items={filteredKnowledgeBases} onSelect={handleKbSelect} />
|
||||
<ToolOpsGroup items={filteredToolOps} onSelect={handleToolOperationSelect} />
|
||||
<WorkspacesGroup items={filteredWorkspaces} onSelect={handleWorkspaceSelect} />
|
||||
<DocsGroup items={filteredDocs} onSelect={handleDocSelect} />
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { getFolderById } from '@/lib/folders/tree'
|
||||
import {
|
||||
downloadFile,
|
||||
exportFolderToZip,
|
||||
type FolderExportData,
|
||||
fetchWorkflowForExport,
|
||||
sanitizePathSegment,
|
||||
type WorkflowExportData,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { downloadFile } from '@/lib/core/utils/file-download'
|
||||
import {
|
||||
downloadFile,
|
||||
exportWorkflowsToZip,
|
||||
type FolderExportData,
|
||||
fetchWorkflowForExport,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
downloadFile,
|
||||
exportWorkflowsToZip,
|
||||
exportWorkflowToJson,
|
||||
fetchWorkflowForExport,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { getWorkflows } from '@/hooks/queries/utils/workflow-cache'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
@@ -27,6 +28,7 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string | undefined
|
||||
const posthog = usePostHog()
|
||||
|
||||
const onSuccessRef = useRef(onSuccess)
|
||||
onSuccessRef.current = onSuccess
|
||||
@@ -34,6 +36,9 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
|
||||
const workspaceIdRef = useRef(workspaceId)
|
||||
workspaceIdRef.current = workspaceId
|
||||
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
|
||||
/**
|
||||
* Export the workflow(s) to JSON or ZIP
|
||||
* - Single workflow: exports as JSON file
|
||||
@@ -100,6 +105,12 @@ export function useExportWorkflow({ onSuccess }: UseExportWorkflowProps = {}) {
|
||||
const { clearSelection } = useFolderStore.getState()
|
||||
clearSelection()
|
||||
|
||||
captureEvent(posthogRef.current, 'workflow_exported', {
|
||||
workspace_id: workspaceIdRef.current ?? '',
|
||||
workflow_count: exportedWorkflows.length,
|
||||
format: exportedWorkflows.length === 1 ? 'json' : 'zip',
|
||||
})
|
||||
|
||||
logger.info('Workflow(s) exported successfully', {
|
||||
workflowIds: workflowIdsToExport,
|
||||
count: exportedWorkflows.length,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { downloadFile, sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import {
|
||||
downloadFile,
|
||||
exportWorkspaceToZip,
|
||||
type FolderExportData,
|
||||
fetchWorkflowForExport,
|
||||
sanitizePathSegment,
|
||||
type WorkflowExportData,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
extractWorkflowsFromFiles,
|
||||
extractWorkflowsFromZip,
|
||||
persistImportedWorkflow,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
|
||||
@@ -36,6 +38,9 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const clearDiff = useWorkflowDiffStore((state) => state.clearDiff)
|
||||
const posthog = usePostHog()
|
||||
const posthogRef = useRef(posthog)
|
||||
posthogRef.current = posthog
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
|
||||
/**
|
||||
@@ -204,6 +209,11 @@ export function useImportWorkflow({ workspaceId }: UseImportWorkflowProps) {
|
||||
logger.info(`Import complete. Imported ${importedWorkflowIds.length} workflow(s)`)
|
||||
|
||||
if (importedWorkflowIds.length > 0) {
|
||||
captureEvent(posthogRef.current, 'workflow_imported', {
|
||||
workspace_id: workspaceId,
|
||||
workflow_count: importedWorkflowIds.length,
|
||||
format: hasZip && fileArray.length === 1 ? 'zip' : 'json',
|
||||
})
|
||||
router.push(
|
||||
`/workspace/${workspaceId}/w/${importedWorkflowIds[importedWorkflowIds.length - 1]}`
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import {
|
||||
extractWorkflowName,
|
||||
extractWorkflowsFromZip,
|
||||
parseWorkflowJson,
|
||||
sanitizePathSegment,
|
||||
} from '@/lib/workflows/operations/import-export'
|
||||
import { useCreateFolder } from '@/hooks/queries/folders'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
|
||||
571
apps/sim/blocks/blocks/cloudwatch.ts
Normal file
571
apps/sim/blocks/blocks/cloudwatch.ts
Normal file
@@ -0,0 +1,571 @@
|
||||
import { CloudWatchIcon } from '@/components/icons'
|
||||
import type { BlockConfig } from '@/blocks/types'
|
||||
import { IntegrationType } from '@/blocks/types'
|
||||
import type {
|
||||
CloudWatchDescribeAlarmsResponse,
|
||||
CloudWatchDescribeLogGroupsResponse,
|
||||
CloudWatchDescribeLogStreamsResponse,
|
||||
CloudWatchGetLogEventsResponse,
|
||||
CloudWatchGetMetricStatisticsResponse,
|
||||
CloudWatchListMetricsResponse,
|
||||
CloudWatchQueryLogsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
|
||||
export const CloudWatchBlock: BlockConfig<
|
||||
| CloudWatchQueryLogsResponse
|
||||
| CloudWatchDescribeLogGroupsResponse
|
||||
| CloudWatchDescribeLogStreamsResponse
|
||||
| CloudWatchGetLogEventsResponse
|
||||
| CloudWatchDescribeAlarmsResponse
|
||||
| CloudWatchListMetricsResponse
|
||||
| CloudWatchGetMetricStatisticsResponse
|
||||
> = {
|
||||
type: 'cloudwatch',
|
||||
name: 'CloudWatch',
|
||||
description: 'Query and monitor AWS CloudWatch logs, metrics, and alarms',
|
||||
longDescription:
|
||||
'Integrate AWS CloudWatch into workflows. Run Log Insights queries, list log groups, retrieve log events, list and get metrics, and monitor alarms. Requires AWS access key and secret access key.',
|
||||
category: 'tools',
|
||||
integrationType: IntegrationType.Analytics,
|
||||
tags: ['cloud', 'monitoring'],
|
||||
bgColor: 'linear-gradient(45deg, #B0084D 0%, #FF4F8B 100%)',
|
||||
icon: CloudWatchIcon,
|
||||
subBlocks: [
|
||||
{
|
||||
id: 'operation',
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Query Logs (Insights)', id: 'query_logs' },
|
||||
{ label: 'Describe Log Groups', id: 'describe_log_groups' },
|
||||
{ label: 'Get Log Events', id: 'get_log_events' },
|
||||
{ label: 'Describe Log Streams', id: 'describe_log_streams' },
|
||||
{ label: 'List Metrics', id: 'list_metrics' },
|
||||
{ label: 'Get Metric Statistics', id: 'get_metric_statistics' },
|
||||
{ label: 'Describe Alarms', id: 'describe_alarms' },
|
||||
],
|
||||
value: () => 'query_logs',
|
||||
},
|
||||
{
|
||||
id: 'awsRegion',
|
||||
title: 'AWS Region',
|
||||
type: 'short-input',
|
||||
placeholder: 'us-east-1',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'awsAccessKeyId',
|
||||
title: 'AWS Access Key ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'AKIA...',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'awsSecretAccessKey',
|
||||
title: 'AWS Secret Access Key',
|
||||
type: 'short-input',
|
||||
placeholder: 'Your secret access key',
|
||||
password: true,
|
||||
required: true,
|
||||
},
|
||||
// Query Logs fields
|
||||
{
|
||||
id: 'logGroupSelector',
|
||||
title: 'Log Group',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'logGroupNames',
|
||||
selectorKey: 'cloudwatch.logGroups',
|
||||
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'],
|
||||
placeholder: 'Select a log group',
|
||||
condition: { field: 'operation', value: 'query_logs' },
|
||||
required: { field: 'operation', value: 'query_logs' },
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'logGroupNamesInput',
|
||||
title: 'Log Group Names',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'logGroupNames',
|
||||
placeholder: '/aws/lambda/my-func, /aws/ecs/my-service',
|
||||
condition: { field: 'operation', value: 'query_logs' },
|
||||
required: { field: 'operation', value: 'query_logs' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'queryString',
|
||||
title: 'Query',
|
||||
type: 'code',
|
||||
placeholder: 'fields @timestamp, @message\n| sort @timestamp desc\n| limit 20',
|
||||
condition: { field: 'operation', value: 'query_logs' },
|
||||
required: { field: 'operation', value: 'query_logs' },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt: `Generate a CloudWatch Log Insights query based on the user's description.
|
||||
The query language supports: fields, filter, stats, sort, limit, parse, display.
|
||||
Common patterns:
|
||||
- fields @timestamp, @message | sort @timestamp desc | limit 20
|
||||
- filter @message like /ERROR/ | stats count(*) by bin(1h)
|
||||
- stats avg(duration) as avgDuration by functionName | sort avgDuration desc
|
||||
- filter @message like /Exception/ | parse @message "* Exception: *" as prefix, errorMsg
|
||||
- stats count(*) as requestCount by status | sort requestCount desc
|
||||
|
||||
Return ONLY the query — no explanations, no markdown code blocks.`,
|
||||
placeholder: 'Describe what you want to find in the logs...',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'startTime',
|
||||
title: 'Start Time (Unix epoch seconds)',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 1711900800',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['query_logs', 'get_log_events', 'get_metric_statistics'],
|
||||
},
|
||||
required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] },
|
||||
},
|
||||
{
|
||||
id: 'endTime',
|
||||
title: 'End Time (Unix epoch seconds)',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 1711987200',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['query_logs', 'get_log_events', 'get_metric_statistics'],
|
||||
},
|
||||
required: { field: 'operation', value: ['query_logs', 'get_metric_statistics'] },
|
||||
},
|
||||
// Describe Log Groups fields
|
||||
{
|
||||
id: 'prefix',
|
||||
title: 'Log Group Name Prefix',
|
||||
type: 'short-input',
|
||||
placeholder: '/aws/lambda/',
|
||||
condition: { field: 'operation', value: 'describe_log_groups' },
|
||||
},
|
||||
// Get Log Events / Describe Log Streams — shared log group selector
|
||||
{
|
||||
id: 'logGroupNameSelector',
|
||||
title: 'Log Group',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'logGroupName',
|
||||
selectorKey: 'cloudwatch.logGroups',
|
||||
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'],
|
||||
placeholder: 'Select a log group',
|
||||
condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'logGroupNameInput',
|
||||
title: 'Log Group Name',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'logGroupName',
|
||||
placeholder: '/aws/lambda/my-func',
|
||||
condition: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
required: { field: 'operation', value: ['get_log_events', 'describe_log_streams'] },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// Describe Log Streams — stream prefix filter
|
||||
{
|
||||
id: 'streamPrefix',
|
||||
title: 'Stream Name Prefix',
|
||||
type: 'short-input',
|
||||
placeholder: '2024/03/31/',
|
||||
condition: { field: 'operation', value: 'describe_log_streams' },
|
||||
},
|
||||
// Get Log Events — log stream selector (cascading: depends on log group)
|
||||
{
|
||||
id: 'logStreamNameSelector',
|
||||
title: 'Log Stream',
|
||||
type: 'file-selector',
|
||||
canonicalParamId: 'logStreamName',
|
||||
selectorKey: 'cloudwatch.logStreams',
|
||||
dependsOn: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion', 'logGroupNameSelector'],
|
||||
placeholder: 'Select a log stream',
|
||||
condition: { field: 'operation', value: 'get_log_events' },
|
||||
required: { field: 'operation', value: 'get_log_events' },
|
||||
mode: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'logStreamNameInput',
|
||||
title: 'Log Stream Name',
|
||||
type: 'short-input',
|
||||
canonicalParamId: 'logStreamName',
|
||||
placeholder: '2024/03/31/[$LATEST]abc123',
|
||||
condition: { field: 'operation', value: 'get_log_events' },
|
||||
required: { field: 'operation', value: 'get_log_events' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
// List Metrics fields
|
||||
{
|
||||
id: 'metricNamespace',
|
||||
title: 'Namespace',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., AWS/EC2, AWS/Lambda, AWS/RDS',
|
||||
condition: { field: 'operation', value: ['list_metrics', 'get_metric_statistics'] },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'metricName',
|
||||
title: 'Metric Name',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., CPUUtilization, Invocations',
|
||||
condition: { field: 'operation', value: ['list_metrics', 'get_metric_statistics'] },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'recentlyActive',
|
||||
title: 'Recently Active Only',
|
||||
type: 'switch',
|
||||
condition: { field: 'operation', value: 'list_metrics' },
|
||||
},
|
||||
// Get Metric Statistics fields
|
||||
{
|
||||
id: 'metricPeriod',
|
||||
title: 'Period (seconds)',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 60, 300, 3600',
|
||||
condition: { field: 'operation', value: 'get_metric_statistics' },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'metricStatistics',
|
||||
title: 'Statistics',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'Average', id: 'Average' },
|
||||
{ label: 'Sum', id: 'Sum' },
|
||||
{ label: 'Minimum', id: 'Minimum' },
|
||||
{ label: 'Maximum', id: 'Maximum' },
|
||||
{ label: 'Sample Count', id: 'SampleCount' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'get_metric_statistics' },
|
||||
required: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
{
|
||||
id: 'metricDimensions',
|
||||
title: 'Dimensions',
|
||||
type: 'table',
|
||||
columns: ['name', 'value'],
|
||||
condition: { field: 'operation', value: 'get_metric_statistics' },
|
||||
},
|
||||
// Describe Alarms fields
|
||||
{
|
||||
id: 'alarmNamePrefix',
|
||||
title: 'Alarm Name Prefix',
|
||||
type: 'short-input',
|
||||
placeholder: 'my-service-',
|
||||
condition: { field: 'operation', value: 'describe_alarms' },
|
||||
},
|
||||
{
|
||||
id: 'stateValue',
|
||||
title: 'State',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All States', id: '' },
|
||||
{ label: 'OK', id: 'OK' },
|
||||
{ label: 'ALARM', id: 'ALARM' },
|
||||
{ label: 'INSUFFICIENT_DATA', id: 'INSUFFICIENT_DATA' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'describe_alarms' },
|
||||
},
|
||||
{
|
||||
id: 'alarmType',
|
||||
title: 'Alarm Type',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'All Types', id: '' },
|
||||
{ label: 'Metric Alarm', id: 'MetricAlarm' },
|
||||
{ label: 'Composite Alarm', id: 'CompositeAlarm' },
|
||||
],
|
||||
condition: { field: 'operation', value: 'describe_alarms' },
|
||||
},
|
||||
// Shared limit field
|
||||
{
|
||||
id: 'limit',
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '100',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: [
|
||||
'query_logs',
|
||||
'describe_log_groups',
|
||||
'get_log_events',
|
||||
'describe_log_streams',
|
||||
'list_metrics',
|
||||
'describe_alarms',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
tools: {
|
||||
access: [
|
||||
'cloudwatch_query_logs',
|
||||
'cloudwatch_describe_log_groups',
|
||||
'cloudwatch_get_log_events',
|
||||
'cloudwatch_describe_log_streams',
|
||||
'cloudwatch_list_metrics',
|
||||
'cloudwatch_get_metric_statistics',
|
||||
'cloudwatch_describe_alarms',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => {
|
||||
switch (params.operation) {
|
||||
case 'query_logs':
|
||||
return 'cloudwatch_query_logs'
|
||||
case 'describe_log_groups':
|
||||
return 'cloudwatch_describe_log_groups'
|
||||
case 'get_log_events':
|
||||
return 'cloudwatch_get_log_events'
|
||||
case 'describe_log_streams':
|
||||
return 'cloudwatch_describe_log_streams'
|
||||
case 'list_metrics':
|
||||
return 'cloudwatch_list_metrics'
|
||||
case 'get_metric_statistics':
|
||||
return 'cloudwatch_get_metric_statistics'
|
||||
case 'describe_alarms':
|
||||
return 'cloudwatch_describe_alarms'
|
||||
default:
|
||||
throw new Error(`Invalid CloudWatch operation: ${params.operation}`)
|
||||
}
|
||||
},
|
||||
params: (params) => {
|
||||
const { operation, startTime, endTime, limit, ...rest } = params
|
||||
|
||||
const awsRegion = rest.awsRegion
|
||||
const awsAccessKeyId = rest.awsAccessKeyId
|
||||
const awsSecretAccessKey = rest.awsSecretAccessKey
|
||||
const parsedLimit = limit ? Number.parseInt(String(limit), 10) : undefined
|
||||
|
||||
switch (operation) {
|
||||
case 'query_logs': {
|
||||
const logGroupNames = rest.logGroupNames
|
||||
if (!logGroupNames) {
|
||||
throw new Error('Log group names are required')
|
||||
}
|
||||
if (!startTime) {
|
||||
throw new Error('Start time is required')
|
||||
}
|
||||
if (!endTime) {
|
||||
throw new Error('End time is required')
|
||||
}
|
||||
|
||||
const groupNames =
|
||||
typeof logGroupNames === 'string'
|
||||
? logGroupNames
|
||||
.split(',')
|
||||
.map((n: string) => n.trim())
|
||||
.filter(Boolean)
|
||||
: logGroupNames
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
logGroupNames: groupNames,
|
||||
queryString: rest.queryString,
|
||||
startTime: Number(startTime),
|
||||
endTime: Number(endTime),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_log_groups':
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
...(rest.prefix && { prefix: rest.prefix }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
|
||||
case 'get_log_events': {
|
||||
if (!rest.logGroupName) {
|
||||
throw new Error('Log group name is required')
|
||||
}
|
||||
if (!rest.logStreamName) {
|
||||
throw new Error('Log stream name is required')
|
||||
}
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
logGroupName: rest.logGroupName,
|
||||
logStreamName: rest.logStreamName,
|
||||
...(startTime && { startTime: Number(startTime) }),
|
||||
...(endTime && { endTime: Number(endTime) }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_log_streams': {
|
||||
if (!rest.logGroupName) {
|
||||
throw new Error('Log group name is required')
|
||||
}
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
logGroupName: rest.logGroupName,
|
||||
...(rest.streamPrefix && { prefix: rest.streamPrefix }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
}
|
||||
|
||||
case 'list_metrics':
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
...(rest.metricNamespace && { namespace: rest.metricNamespace }),
|
||||
...(rest.metricName && { metricName: rest.metricName }),
|
||||
...(rest.recentlyActive && { recentlyActive: true }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
|
||||
case 'get_metric_statistics': {
|
||||
if (!rest.metricNamespace) {
|
||||
throw new Error('Namespace is required')
|
||||
}
|
||||
if (!rest.metricName) {
|
||||
throw new Error('Metric name is required')
|
||||
}
|
||||
if (!startTime) {
|
||||
throw new Error('Start time is required')
|
||||
}
|
||||
if (!endTime) {
|
||||
throw new Error('End time is required')
|
||||
}
|
||||
if (!rest.metricPeriod) {
|
||||
throw new Error('Period is required')
|
||||
}
|
||||
|
||||
const stat = rest.metricStatistics
|
||||
if (!stat) {
|
||||
throw new Error('Statistics is required')
|
||||
}
|
||||
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
namespace: rest.metricNamespace,
|
||||
metricName: rest.metricName,
|
||||
startTime: Number(startTime),
|
||||
endTime: Number(endTime),
|
||||
period: Number(rest.metricPeriod),
|
||||
statistics: Array.isArray(stat) ? stat : [stat],
|
||||
...(rest.metricDimensions && {
|
||||
dimensions: (() => {
|
||||
const dims = rest.metricDimensions
|
||||
if (typeof dims === 'string') return dims
|
||||
if (Array.isArray(dims)) {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const row of dims) {
|
||||
const name = row.cells?.name
|
||||
const value = row.cells?.value
|
||||
if (name && value !== undefined) obj[name] = String(value)
|
||||
}
|
||||
return JSON.stringify(obj)
|
||||
}
|
||||
return JSON.stringify(dims)
|
||||
})(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
case 'describe_alarms':
|
||||
return {
|
||||
awsRegion,
|
||||
awsAccessKeyId,
|
||||
awsSecretAccessKey,
|
||||
...(rest.alarmNamePrefix && { alarmNamePrefix: rest.alarmNamePrefix }),
|
||||
...(rest.stateValue && { stateValue: rest.stateValue }),
|
||||
...(rest.alarmType && { alarmType: rest.alarmType }),
|
||||
...(parsedLimit !== undefined && { limit: parsedLimit }),
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Invalid CloudWatch operation: ${operation}`)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', description: 'CloudWatch operation to perform' },
|
||||
awsRegion: { type: 'string', description: 'AWS region' },
|
||||
awsAccessKeyId: { type: 'string', description: 'AWS access key ID' },
|
||||
awsSecretAccessKey: { type: 'string', description: 'AWS secret access key' },
|
||||
logGroupNames: { type: 'string', description: 'Log group name(s) for query' },
|
||||
queryString: { type: 'string', description: 'CloudWatch Log Insights query string' },
|
||||
startTime: { type: 'string', description: 'Start time as Unix epoch seconds' },
|
||||
endTime: { type: 'string', description: 'End time as Unix epoch seconds' },
|
||||
prefix: { type: 'string', description: 'Log group name prefix filter' },
|
||||
logGroupName: {
|
||||
type: 'string',
|
||||
description: 'Log group name for get events / describe streams',
|
||||
},
|
||||
logStreamName: { type: 'string', description: 'Log stream name for get events' },
|
||||
streamPrefix: { type: 'string', description: 'Log stream name prefix filter' },
|
||||
metricNamespace: { type: 'string', description: 'Metric namespace (e.g., AWS/EC2)' },
|
||||
metricName: { type: 'string', description: 'Metric name (e.g., CPUUtilization)' },
|
||||
recentlyActive: { type: 'boolean', description: 'Only show recently active metrics' },
|
||||
metricPeriod: { type: 'number', description: 'Granularity in seconds' },
|
||||
metricStatistics: { type: 'string', description: 'Statistic type (Average, Sum, etc.)' },
|
||||
metricDimensions: { type: 'json', description: 'Metric dimensions (Name/Value pairs)' },
|
||||
alarmNamePrefix: { type: 'string', description: 'Alarm name prefix filter' },
|
||||
stateValue: {
|
||||
type: 'string',
|
||||
description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)',
|
||||
},
|
||||
alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' },
|
||||
limit: { type: 'number', description: 'Maximum number of results' },
|
||||
},
|
||||
outputs: {
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Log Insights query result rows',
|
||||
},
|
||||
statistics: {
|
||||
type: 'json',
|
||||
description: 'Query statistics (bytesScanned, recordsMatched, recordsScanned)',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'Query completion status',
|
||||
},
|
||||
logGroups: {
|
||||
type: 'array',
|
||||
description: 'List of CloudWatch log groups',
|
||||
},
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'Log events with timestamp and message',
|
||||
},
|
||||
logStreams: {
|
||||
type: 'array',
|
||||
description: 'Log streams with metadata',
|
||||
},
|
||||
metrics: {
|
||||
type: 'array',
|
||||
description: 'List of available metrics',
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
description: 'Metric label',
|
||||
},
|
||||
datapoints: {
|
||||
type: 'array',
|
||||
description: 'Metric datapoints with timestamps and values',
|
||||
},
|
||||
alarms: {
|
||||
type: 'array',
|
||||
description: 'CloudWatch alarms with state and configuration',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { CirclebackBlock } from '@/blocks/blocks/circleback'
|
||||
import { ClayBlock } from '@/blocks/blocks/clay'
|
||||
import { ClerkBlock } from '@/blocks/blocks/clerk'
|
||||
import { CloudflareBlock } from '@/blocks/blocks/cloudflare'
|
||||
import { CloudWatchBlock } from '@/blocks/blocks/cloudwatch'
|
||||
import { ConditionBlock } from '@/blocks/blocks/condition'
|
||||
import { ConfluenceBlock, ConfluenceV2Block } from '@/blocks/blocks/confluence'
|
||||
import { CredentialBlock } from '@/blocks/blocks/credential'
|
||||
@@ -241,6 +242,7 @@ export const registry: Record<string, BlockConfig> = {
|
||||
chat_trigger: ChatTriggerBlock,
|
||||
circleback: CirclebackBlock,
|
||||
cloudflare: CloudflareBlock,
|
||||
cloudwatch: CloudWatchBlock,
|
||||
clay: ClayBlock,
|
||||
clerk: ClerkBlock,
|
||||
condition: ConditionBlock,
|
||||
|
||||
@@ -51,6 +51,13 @@ import { Button } from '../button/button'
|
||||
const ANIMATION_CLASSES =
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in motion-reduce:animate-none'
|
||||
|
||||
/**
|
||||
* Modal content animation classes.
|
||||
* We keep only the slide animations (no zoom) to stabilize positioning while avoiding scale effects.
|
||||
*/
|
||||
const CONTENT_ANIMATION_CLASSES =
|
||||
'data-[state=closed]:slide-out-to-top-[50%] data-[state=open]:slide-in-from-top-[50%] motion-reduce:animate-none'
|
||||
|
||||
/**
|
||||
* Root modal component. Manages open state.
|
||||
*/
|
||||
@@ -159,8 +166,7 @@ const ModalContent = React.forwardRef<
|
||||
)}
|
||||
style={{
|
||||
left: isWorkflowPage
|
||||
? // --panel-width is always the rendered panel width on /w/ routes (panel is never hidden/collapsed)
|
||||
'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
|
||||
? 'calc(50% + (var(--sidebar-width) - var(--panel-width)) / 2)'
|
||||
: 'calc(var(--sidebar-width) / 2 + 50%)',
|
||||
...style,
|
||||
}}
|
||||
|
||||
@@ -4653,6 +4653,33 @@ export function SQSIcon(props: SVGProps<SVGSVGElement>) {
|
||||
)
|
||||
}
|
||||
|
||||
export function CloudWatchIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox='0 0 80 80'
|
||||
version='1.1'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
>
|
||||
<g
|
||||
id='Icon-Architecture/64/Arch_Amazon-CloudWatch_64'
|
||||
stroke='none'
|
||||
strokeWidth='1'
|
||||
fill='none'
|
||||
fillRule='evenodd'
|
||||
transform='translate(40, 40) scale(1.25) translate(-40, -40)'
|
||||
>
|
||||
<path
|
||||
d='M53,42 L41,42 L41,24 L43,24 L43,40 L53,40 L53,42 Z M40,66 C24.561,66 12,53.439 12,38 C12,22.561 24.561,10 40,10 C55.439,10 68,22.561 68,38 C68,53.439 55.439,66 40,66 M40,8 C23.458,8 10,21.458 10,38 C10,54.542 23.458,68 40,68 C56.542,68 70,54.542 70,38 C70,21.458 56.542,8 40,8'
|
||||
id='Amazon-CloudWatch_Icon_64_Squid'
|
||||
fill='currentColor'
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextractIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -119,7 +119,7 @@ const ISSUE_FIELDS = `
|
||||
`
|
||||
|
||||
const ISSUE_BY_ID_QUERY = `
|
||||
query GetIssue($id: String!) {
|
||||
query GetIssue($id: ID!) {
|
||||
issue(id: $id) {
|
||||
${ISSUE_FIELDS}
|
||||
}
|
||||
@@ -147,13 +147,13 @@ function buildIssuesQuery(sourceConfig: Record<string, unknown>): {
|
||||
const variables: Record<string, unknown> = {}
|
||||
|
||||
if (teamId) {
|
||||
varDefs.push('$teamId: String!')
|
||||
varDefs.push('$teamId: ID!')
|
||||
filterClauses.push('team: { id: { eq: $teamId } }')
|
||||
variables.teamId = teamId
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
varDefs.push('$projectId: String!')
|
||||
varDefs.push('$projectId: ID!')
|
||||
filterClauses.push('project: { id: { eq: $projectId } }')
|
||||
variables.projectId = projectId
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '@/components/emcn'
|
||||
import type { Filter, RowData, Sort, TableDefinition, TableMetadata, TableRow } from '@/lib/table'
|
||||
import { TABLE_LIMITS } from '@/lib/table/constants'
|
||||
|
||||
const logger = createLogger('TableQueries')
|
||||
|
||||
@@ -23,7 +24,7 @@ export const tableKeys = {
|
||||
[...tableKeys.rowsRoot(tableId), paramsKey] as const,
|
||||
}
|
||||
|
||||
interface TableRowsParams {
|
||||
export interface TableRowsParams {
|
||||
workspaceId: string
|
||||
tableId: string
|
||||
limit: number
|
||||
@@ -32,7 +33,7 @@ interface TableRowsParams {
|
||||
sort?: Sort | null
|
||||
}
|
||||
|
||||
interface TableRowsResponse {
|
||||
export interface TableRowsResponse {
|
||||
rows: TableRow[]
|
||||
totalCount: number
|
||||
}
|
||||
@@ -83,7 +84,7 @@ async function fetchTable(
|
||||
return (data as { table: TableDefinition }).table
|
||||
}
|
||||
|
||||
async function fetchTableRows({
|
||||
export async function fetchTableRows({
|
||||
workspaceId,
|
||||
tableId,
|
||||
limit,
|
||||
@@ -125,6 +126,48 @@ async function fetchTableRows({
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllTableRows({
|
||||
workspaceId,
|
||||
tableId,
|
||||
filter,
|
||||
sort,
|
||||
pageSize = TABLE_LIMITS.MAX_QUERY_LIMIT,
|
||||
signal,
|
||||
}: Pick<TableRowsParams, 'workspaceId' | 'tableId' | 'filter' | 'sort'> & {
|
||||
pageSize?: number
|
||||
signal?: AbortSignal
|
||||
}): Promise<TableRowsResponse> {
|
||||
const rows: TableRow[] = []
|
||||
let totalCount = Number.POSITIVE_INFINITY
|
||||
let offset = 0
|
||||
|
||||
while (rows.length < totalCount) {
|
||||
const response = await fetchTableRows({
|
||||
workspaceId,
|
||||
tableId,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
filter,
|
||||
sort,
|
||||
signal,
|
||||
})
|
||||
|
||||
rows.push(...response.rows)
|
||||
totalCount = response.totalCount
|
||||
|
||||
if (response.rows.length === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
offset += response.rows.length
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalCount: Number.isFinite(totalCount) ? totalCount : rows.length,
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateRowData(queryClient: ReturnType<typeof useQueryClient>, tableId: string) {
|
||||
queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
|
||||
}
|
||||
|
||||
@@ -1716,6 +1716,81 @@ const registry: Record<SelectorKey, SelectorDefinition> = {
|
||||
}))
|
||||
},
|
||||
},
|
||||
'cloudwatch.logGroups': {
|
||||
key: 'cloudwatch.logGroups',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'cloudwatch.logGroups',
|
||||
context.awsAccessKeyId ?? 'none',
|
||||
context.awsRegion ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(context.awsAccessKeyId && context.awsSecretAccessKey && context.awsRegion),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const body = JSON.stringify({
|
||||
accessKeyId: context.awsAccessKeyId,
|
||||
secretAccessKey: context.awsSecretAccessKey,
|
||||
region: context.awsRegion,
|
||||
...(search && { prefix: search }),
|
||||
})
|
||||
const data = await fetchJson<{
|
||||
output: { logGroups: { logGroupName: string }[] }
|
||||
}>('/api/tools/cloudwatch/describe-log-groups', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return (data.output?.logGroups || []).map((lg) => ({
|
||||
id: lg.logGroupName,
|
||||
label: lg.logGroupName,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
return { id: detailId, label: detailId }
|
||||
},
|
||||
},
|
||||
'cloudwatch.logStreams': {
|
||||
key: 'cloudwatch.logStreams',
|
||||
staleTime: SELECTOR_STALE,
|
||||
getQueryKey: ({ context }: SelectorQueryArgs) => [
|
||||
'selectors',
|
||||
'cloudwatch.logStreams',
|
||||
context.awsAccessKeyId ?? 'none',
|
||||
context.awsRegion ?? 'none',
|
||||
context.logGroupName ?? 'none',
|
||||
],
|
||||
enabled: ({ context }) =>
|
||||
Boolean(
|
||||
context.awsAccessKeyId &&
|
||||
context.awsSecretAccessKey &&
|
||||
context.awsRegion &&
|
||||
context.logGroupName
|
||||
),
|
||||
fetchList: async ({ context, search }: SelectorQueryArgs) => {
|
||||
const body = JSON.stringify({
|
||||
accessKeyId: context.awsAccessKeyId,
|
||||
secretAccessKey: context.awsSecretAccessKey,
|
||||
region: context.awsRegion,
|
||||
logGroupName: context.logGroupName,
|
||||
...(search && { prefix: search }),
|
||||
})
|
||||
const data = await fetchJson<{
|
||||
output: { logStreams: { logStreamName: string; lastEventTimestamp?: number }[] }
|
||||
}>('/api/tools/cloudwatch/describe-log-streams', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
return (data.output?.logStreams || []).map((ls) => ({
|
||||
id: ls.logStreamName,
|
||||
label: ls.logStreamName,
|
||||
}))
|
||||
},
|
||||
fetchById: async ({ detailId }: SelectorQueryArgs) => {
|
||||
if (!detailId) return null
|
||||
return { id: detailId, label: detailId }
|
||||
},
|
||||
},
|
||||
'sim.workflows': {
|
||||
key: 'sim.workflows',
|
||||
staleTime: SELECTOR_STALE,
|
||||
|
||||
@@ -49,6 +49,8 @@ export type SelectorKey =
|
||||
| 'webflow.sites'
|
||||
| 'webflow.collections'
|
||||
| 'webflow.items'
|
||||
| 'cloudwatch.logGroups'
|
||||
| 'cloudwatch.logStreams'
|
||||
| 'sim.workflows'
|
||||
|
||||
export interface SelectorOption {
|
||||
@@ -78,6 +80,10 @@ export interface SelectorContext {
|
||||
datasetId?: string
|
||||
serviceDeskId?: string
|
||||
impersonateUserEmail?: string
|
||||
awsAccessKeyId?: string
|
||||
awsSecretAccessKey?: string
|
||||
awsRegion?: string
|
||||
logGroupName?: string
|
||||
}
|
||||
|
||||
export interface SelectorQueryArgs {
|
||||
|
||||
@@ -191,6 +191,21 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) {
|
||||
break
|
||||
}
|
||||
|
||||
case 'delete-column': {
|
||||
if (direction === 'undo') {
|
||||
addColumnMutation.mutate({
|
||||
name: action.columnName,
|
||||
type: action.columnType,
|
||||
position: action.position,
|
||||
unique: action.unique,
|
||||
required: action.required,
|
||||
})
|
||||
} else {
|
||||
deleteColumnMutation.mutate(action.columnName)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'rename-column': {
|
||||
if (direction === 'undo') {
|
||||
updateColumnMutation.mutate({
|
||||
|
||||
@@ -448,9 +448,11 @@ export async function hasInboxAccess(userId: string): Promise<boolean> {
|
||||
if (!isProd) {
|
||||
return true
|
||||
}
|
||||
const sub = await getHighestPrioritySubscription(userId)
|
||||
const [sub, billingStatus] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
getEffectiveBillingStatus(userId),
|
||||
])
|
||||
if (!sub) return false
|
||||
const billingStatus = await getEffectiveBillingStatus(userId)
|
||||
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
|
||||
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
|
||||
} catch (error) {
|
||||
@@ -459,6 +461,30 @@ export async function hasInboxAccess(userId: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has access to live sync (every 5 minutes) for KB connectors
|
||||
* Returns true if:
|
||||
* - Self-hosted deployment, OR
|
||||
* - User has a Max plan (credits >= 25000) or enterprise plan
|
||||
*/
|
||||
export async function hasLiveSyncAccess(userId: string): Promise<boolean> {
|
||||
try {
|
||||
if (!isHosted) {
|
||||
return true
|
||||
}
|
||||
const [sub, billingStatus] = await Promise.all([
|
||||
getHighestPrioritySubscription(userId),
|
||||
getEffectiveBillingStatus(userId),
|
||||
])
|
||||
if (!sub) return false
|
||||
if (!hasUsableSubscriptionAccess(sub.status, billingStatus.billingBlocked)) return false
|
||||
return getPlanTierCredits(sub.plan) >= 25000 || checkEnterprisePlan(sub)
|
||||
} catch (error) {
|
||||
logger.error('Error checking live sync access', { error, userId })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has exceeded their cost limit based on current period usage
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@ export type UsageLogSource =
|
||||
| 'workspace-chat'
|
||||
| 'mcp_copilot'
|
||||
| 'mothership_block'
|
||||
| 'knowledge-base'
|
||||
|
||||
/**
|
||||
* Metadata for 'model' category charges
|
||||
|
||||
@@ -81,7 +81,8 @@ export class DocsChunker {
|
||||
const textChunks = await this.splitContent(markdownContent)
|
||||
|
||||
logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`)
|
||||
const embeddings = textChunks.length > 0 ? await generateEmbeddings(textChunks) : []
|
||||
const embeddings: number[][] =
|
||||
textChunks.length > 0 ? (await generateEmbeddings(textChunks)).embeddings : []
|
||||
const embeddingModel = 'text-embedding-3-small'
|
||||
|
||||
const chunks: DocChunk[] = []
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEditWorkflowRegistryMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createBlockFromParams } from './builders'
|
||||
|
||||
const agentBlockConfig = {
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
outputs: {
|
||||
content: { type: 'string', description: 'Default content output' },
|
||||
},
|
||||
subBlocks: [{ id: 'responseFormat', type: 'response-format' }],
|
||||
}
|
||||
|
||||
const conditionBlockConfig = {
|
||||
type: 'condition',
|
||||
name: 'Condition',
|
||||
outputs: {},
|
||||
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
|
||||
}
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getAllBlocks: () => [agentBlockConfig, conditionBlockConfig],
|
||||
getBlock: (type: string) =>
|
||||
type === 'agent' ? agentBlockConfig : type === 'condition' ? conditionBlockConfig : undefined,
|
||||
}))
|
||||
vi.mock('@/blocks/registry', () => createEditWorkflowRegistryMock(['agent', 'condition']))
|
||||
|
||||
describe('createBlockFromParams', () => {
|
||||
it('derives agent outputs from responseFormat when outputs are not provided', () => {
|
||||
|
||||
@@ -1,69 +1,16 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEditWorkflowRegistryMock } from '@sim/testing'
|
||||
import { loggerMock } from '@sim/testing/mocks'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { applyOperationsToWorkflowState } from './engine'
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getAllBlocks: () => [
|
||||
{
|
||||
type: 'condition',
|
||||
name: 'Condition',
|
||||
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
|
||||
},
|
||||
{
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', type: 'long-input' },
|
||||
{ id: 'model', type: 'combobox' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
name: 'Function',
|
||||
subBlocks: [
|
||||
{ id: 'code', type: 'code' },
|
||||
{ id: 'language', type: 'dropdown' },
|
||||
],
|
||||
},
|
||||
],
|
||||
getBlock: (type: string) => {
|
||||
const blocks: Record<string, any> = {
|
||||
condition: {
|
||||
type: 'condition',
|
||||
name: 'Condition',
|
||||
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
|
||||
},
|
||||
agent: {
|
||||
type: 'agent',
|
||||
name: 'Agent',
|
||||
subBlocks: [
|
||||
{ id: 'systemPrompt', type: 'long-input' },
|
||||
{ id: 'model', type: 'combobox' },
|
||||
],
|
||||
},
|
||||
function: {
|
||||
type: 'function',
|
||||
name: 'Function',
|
||||
subBlocks: [
|
||||
{ id: 'code', type: 'code' },
|
||||
{ id: 'language', type: 'dropdown' },
|
||||
],
|
||||
},
|
||||
}
|
||||
return blocks[type] || undefined
|
||||
},
|
||||
}))
|
||||
vi.mock('@/blocks/registry', () =>
|
||||
createEditWorkflowRegistryMock(['condition', 'agent', 'function'])
|
||||
)
|
||||
|
||||
function makeLoopWorkflow() {
|
||||
return {
|
||||
|
||||
@@ -1,32 +1,12 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createEditWorkflowRegistryMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { normalizeConditionRouterIds } from './builders'
|
||||
import { validateInputsForBlock } from './validation'
|
||||
|
||||
const conditionBlockConfig = {
|
||||
type: 'condition',
|
||||
name: 'Condition',
|
||||
outputs: {},
|
||||
subBlocks: [{ id: 'conditions', type: 'condition-input' }],
|
||||
}
|
||||
|
||||
const routerBlockConfig = {
|
||||
type: 'router_v2',
|
||||
name: 'Router',
|
||||
outputs: {},
|
||||
subBlocks: [{ id: 'routes', type: 'router-input' }],
|
||||
}
|
||||
|
||||
vi.mock('@/blocks/registry', () => ({
|
||||
getBlock: (type: string) =>
|
||||
type === 'condition'
|
||||
? conditionBlockConfig
|
||||
: type === 'router_v2'
|
||||
? routerBlockConfig
|
||||
: undefined,
|
||||
}))
|
||||
vi.mock('@/blocks/registry', () => createEditWorkflowRegistryMock(['condition', 'router_v2']))
|
||||
|
||||
describe('validateInputsForBlock', () => {
|
||||
it('accepts condition-input arrays with arbitrary item ids', () => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { createFeatureFlagsMock, loggerMock } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
||||
import { RateLimiter } from './rate-limiter'
|
||||
import type { ConsumeResult, RateLimitStorageAdapter, TokenStatus } from './storage'
|
||||
import { MANUAL_EXECUTION_LIMIT, RATE_LIMITS, RateLimitError } from './types'
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({ isBillingEnabled: true }))
|
||||
vi.mock('@/lib/core/config/feature-flags', () => createFeatureFlagsMock({ isBillingEnabled: true }))
|
||||
|
||||
interface MockAdapter {
|
||||
consumeTokens: Mock
|
||||
|
||||
36
apps/sim/lib/core/utils/file-download.ts
Normal file
36
apps/sim/lib/core/utils/file-download.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('FileDownload')
|
||||
|
||||
/**
|
||||
* Sanitizes a string for use as a file or path segment in exported assets.
|
||||
*/
|
||||
export function sanitizePathSegment(name: string): string {
|
||||
return name.replace(/[^a-z0-9-_]/gi, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file to the user's device.
|
||||
* Throws if the browser cannot create or trigger the download.
|
||||
*/
|
||||
export function downloadFile(
|
||||
content: Blob | string,
|
||||
filename: string,
|
||||
mimeType = 'application/json'
|
||||
): void {
|
||||
try {
|
||||
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
logger.error('Failed to download file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export async function createChunk(
|
||||
workspaceId?: string | null
|
||||
): Promise<ChunkData> {
|
||||
logger.info(`[${requestId}] Generating embedding for manual chunk`)
|
||||
const embeddings = await generateEmbeddings([chunkData.content], undefined, workspaceId)
|
||||
const { embeddings } = await generateEmbeddings([chunkData.content], undefined, workspaceId)
|
||||
|
||||
// Calculate accurate token count
|
||||
const tokenCount = estimateTokenCount(chunkData.content, 'openai')
|
||||
@@ -359,7 +359,7 @@ export async function updateChunk(
|
||||
if (content !== currentChunk[0].content) {
|
||||
logger.info(`[${requestId}] Content changed, regenerating embedding for chunk ${chunkId}`)
|
||||
|
||||
const embeddings = await generateEmbeddings([content], undefined, workspaceId)
|
||||
const { embeddings } = await generateEmbeddings([content], undefined, workspaceId)
|
||||
|
||||
// Calculate accurate token count
|
||||
const tokenCount = estimateTokenCount(content, 'openai')
|
||||
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
type SQL,
|
||||
sql,
|
||||
} from 'drizzle-orm'
|
||||
import { recordUsage } from '@/lib/billing/core/usage-log'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { getCostMultiplier, isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
|
||||
import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch'
|
||||
import { processDocument } from '@/lib/knowledge/documents/document-processor'
|
||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||
@@ -43,6 +45,7 @@ import type { ProcessedDocumentTags } from '@/lib/knowledge/types'
|
||||
import { deleteFile } from '@/lib/uploads/core/storage-service'
|
||||
import { extractStorageKey } from '@/lib/uploads/utils/file-utils'
|
||||
import type { DocumentProcessingPayload } from '@/background/knowledge-processing'
|
||||
import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('DocumentService')
|
||||
|
||||
@@ -460,6 +463,10 @@ export async function processDocumentAsync(
|
||||
overlap: rawConfig?.overlap ?? 200,
|
||||
}
|
||||
|
||||
let totalEmbeddingTokens = 0
|
||||
let embeddingIsBYOK = false
|
||||
let embeddingModelName = 'text-embedding-3-small'
|
||||
|
||||
await withTimeout(
|
||||
(async () => {
|
||||
const processed = await processDocument(
|
||||
@@ -500,10 +507,20 @@ export async function processDocumentAsync(
|
||||
const batchNum = Math.floor(i / batchSize) + 1
|
||||
|
||||
logger.info(`[${documentId}] Processing embedding batch ${batchNum}/${totalBatches}`)
|
||||
const batchEmbeddings = await generateEmbeddings(batch, undefined, kb[0].workspaceId)
|
||||
const {
|
||||
embeddings: batchEmbeddings,
|
||||
totalTokens: batchTokens,
|
||||
isBYOK,
|
||||
modelName,
|
||||
} = await generateEmbeddings(batch, undefined, kb[0].workspaceId)
|
||||
for (const emb of batchEmbeddings) {
|
||||
embeddings.push(emb)
|
||||
}
|
||||
totalEmbeddingTokens += batchTokens
|
||||
if (i === 0) {
|
||||
embeddingIsBYOK = isBYOK
|
||||
embeddingModelName = modelName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -638,6 +655,45 @@ export async function processDocumentAsync(
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
logger.info(`[${documentId}] Successfully processed document in ${processingTime}ms`)
|
||||
|
||||
if (!embeddingIsBYOK && totalEmbeddingTokens > 0 && kb[0].userId) {
|
||||
try {
|
||||
const costMultiplier = getCostMultiplier()
|
||||
const { total: cost } = calculateCost(
|
||||
embeddingModelName,
|
||||
totalEmbeddingTokens,
|
||||
0,
|
||||
false,
|
||||
costMultiplier
|
||||
)
|
||||
if (cost > 0) {
|
||||
await recordUsage({
|
||||
userId: kb[0].userId,
|
||||
workspaceId: kb[0].workspaceId ?? undefined,
|
||||
entries: [
|
||||
{
|
||||
category: 'model',
|
||||
source: 'knowledge-base',
|
||||
description: embeddingModelName,
|
||||
cost,
|
||||
metadata: { inputTokens: totalEmbeddingTokens, outputTokens: 0 },
|
||||
},
|
||||
],
|
||||
additionalStats: {
|
||||
totalTokensUsed: sql`total_tokens_used + ${totalEmbeddingTokens}`,
|
||||
},
|
||||
})
|
||||
await checkAndBillOverageThreshold(kb[0].userId)
|
||||
} else {
|
||||
logger.warn(
|
||||
`[${documentId}] Embedding model "${embeddingModelName}" has no pricing entry — billing skipped`,
|
||||
{ totalEmbeddingTokens, embeddingModelName }
|
||||
)
|
||||
}
|
||||
} catch (billingError) {
|
||||
logger.error(`[${documentId}] Failed to record embedding usage`, { error: billingError })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
@@ -35,6 +35,7 @@ interface EmbeddingConfig {
|
||||
apiUrl: string
|
||||
headers: Record<string, string>
|
||||
modelName: string
|
||||
isBYOK: boolean
|
||||
}
|
||||
|
||||
interface EmbeddingResponseItem {
|
||||
@@ -71,16 +72,19 @@ async function getEmbeddingConfig(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
modelName: kbModelName,
|
||||
isBYOK: false,
|
||||
}
|
||||
}
|
||||
|
||||
let openaiApiKey = env.OPENAI_API_KEY
|
||||
let isBYOK = false
|
||||
|
||||
if (workspaceId) {
|
||||
const byokResult = await getBYOKKey(workspaceId, 'openai')
|
||||
if (byokResult) {
|
||||
logger.info('Using workspace BYOK key for OpenAI embeddings')
|
||||
openaiApiKey = byokResult.apiKey
|
||||
isBYOK = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,12 +102,16 @@ async function getEmbeddingConfig(
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
modelName: embeddingModel,
|
||||
isBYOK,
|
||||
}
|
||||
}
|
||||
|
||||
const EMBEDDING_REQUEST_TIMEOUT_MS = 60_000
|
||||
|
||||
async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Promise<number[][]> {
|
||||
async function callEmbeddingAPI(
|
||||
inputs: string[],
|
||||
config: EmbeddingConfig
|
||||
): Promise<{ embeddings: number[][]; totalTokens: number }> {
|
||||
return retryWithExponentialBackoff(
|
||||
async () => {
|
||||
const useDimensions = supportsCustomDimensions(config.modelName)
|
||||
@@ -140,7 +148,10 @@ async function callEmbeddingAPI(inputs: string[], config: EmbeddingConfig): Prom
|
||||
}
|
||||
|
||||
const data: EmbeddingAPIResponse = await response.json()
|
||||
return data.data.map((item) => item.embedding)
|
||||
return {
|
||||
embeddings: data.data.map((item) => item.embedding),
|
||||
totalTokens: data.usage.total_tokens,
|
||||
}
|
||||
},
|
||||
{
|
||||
maxRetries: 3,
|
||||
@@ -178,14 +189,23 @@ async function processWithConcurrency<T, R>(
|
||||
return results
|
||||
}
|
||||
|
||||
export interface GenerateEmbeddingsResult {
|
||||
embeddings: number[][]
|
||||
totalTokens: number
|
||||
isBYOK: boolean
|
||||
modelName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for multiple texts with token-aware batching and parallel processing
|
||||
* Generate embeddings for multiple texts with token-aware batching and parallel processing.
|
||||
* Returns embeddings alongside actual token count, model name, and whether a workspace BYOK key
|
||||
* was used (vs. the platform's shared key) — enabling callers to make correct billing decisions.
|
||||
*/
|
||||
export async function generateEmbeddings(
|
||||
texts: string[],
|
||||
embeddingModel = 'text-embedding-3-small',
|
||||
workspaceId?: string | null
|
||||
): Promise<number[][]> {
|
||||
): Promise<GenerateEmbeddingsResult> {
|
||||
const config = await getEmbeddingConfig(embeddingModel, workspaceId)
|
||||
|
||||
const batches = batchByTokenLimit(texts, MAX_TOKENS_PER_REQUEST, embeddingModel)
|
||||
@@ -204,13 +224,20 @@ export async function generateEmbeddings(
|
||||
)
|
||||
|
||||
const allEmbeddings: number[][] = []
|
||||
let totalTokens = 0
|
||||
for (const batch of batchResults) {
|
||||
for (const emb of batch) {
|
||||
for (const emb of batch.embeddings) {
|
||||
allEmbeddings.push(emb)
|
||||
}
|
||||
totalTokens += batch.totalTokens
|
||||
}
|
||||
|
||||
return allEmbeddings
|
||||
return {
|
||||
embeddings: allEmbeddings,
|
||||
totalTokens,
|
||||
isBYOK: config.isBYOK,
|
||||
modelName: config.modelName,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,6 +254,6 @@ export async function generateSearchEmbedding(
|
||||
`Using ${config.useAzure ? 'Azure OpenAI' : 'OpenAI'} for search embedding generation`
|
||||
)
|
||||
|
||||
const embeddings = await callEmbeddingAPI([query], config)
|
||||
const { embeddings } = await callEmbeddingAPI([query], config)
|
||||
return embeddings[0]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { loggerMock } from '@sim/testing'
|
||||
import { createFeatureFlagsMock, loggerMock } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
interface MockMcpClient {
|
||||
@@ -38,7 +38,7 @@ const { MockMcpClientConstructor, mockOnToolsChanged, mockPublishToolsChanged }
|
||||
)
|
||||
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({ isTest: false }))
|
||||
vi.mock('@/lib/core/config/feature-flags', () => createFeatureFlagsMock({ isTest: false }))
|
||||
vi.mock('@/lib/mcp/pubsub', () => ({
|
||||
mcpPubSub: {
|
||||
onToolsChanged: mockOnToolsChanged,
|
||||
|
||||
@@ -317,6 +317,15 @@ export interface PostHogEventMap {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
table_exported: {
|
||||
workspace_id: string
|
||||
table_id: string
|
||||
row_count: number
|
||||
column_count: number
|
||||
has_filter: boolean
|
||||
has_sort: boolean
|
||||
}
|
||||
|
||||
custom_tool_saved: {
|
||||
tool_id: string
|
||||
workspace_id: string
|
||||
@@ -358,15 +367,12 @@ export interface PostHogEventMap {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
task_marked_read: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
task_marked_unread: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
task_message_sent: {
|
||||
workspace_id: string
|
||||
has_attachments: boolean
|
||||
has_contexts: boolean
|
||||
is_new_task: boolean
|
||||
@@ -389,6 +395,62 @@ export interface PostHogEventMap {
|
||||
source: 'help_menu' | 'editor_button' | 'toolbar_context_menu'
|
||||
block_type?: string
|
||||
}
|
||||
|
||||
search_result_selected: {
|
||||
result_type:
|
||||
| 'block'
|
||||
| 'tool'
|
||||
| 'trigger'
|
||||
| 'tool_operation'
|
||||
| 'workflow'
|
||||
| 'workspace'
|
||||
| 'task'
|
||||
| 'table'
|
||||
| 'file'
|
||||
| 'knowledge_base'
|
||||
| 'page'
|
||||
| 'docs'
|
||||
query_length: number
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
workflow_imported: {
|
||||
workspace_id: string
|
||||
workflow_count: number
|
||||
format: 'json' | 'zip'
|
||||
}
|
||||
|
||||
workflow_exported: {
|
||||
workspace_id: string
|
||||
workflow_count: number
|
||||
format: 'json' | 'zip'
|
||||
}
|
||||
|
||||
folder_created: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
folder_deleted: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
logs_filter_applied: {
|
||||
filter_type: 'status' | 'workflow' | 'folder' | 'trigger' | 'time'
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
knowledge_base_document_deleted: {
|
||||
knowledge_base_id: string
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
scheduled_task_created: {
|
||||
workspace_id: string
|
||||
}
|
||||
|
||||
scheduled_task_deleted: {
|
||||
workspace_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export type PostHogEventName = keyof PostHogEventMap
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createTableColumn } from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { TABLE_LIMITS } from '../constants'
|
||||
import {
|
||||
type ColumnDefinition,
|
||||
getUniqueColumns,
|
||||
type TableSchema,
|
||||
validateColumnDefinition,
|
||||
@@ -66,12 +66,12 @@ describe('Validation', () => {
|
||||
|
||||
describe('validateColumnDefinition', () => {
|
||||
it('should accept valid column definition', () => {
|
||||
const column: ColumnDefinition = {
|
||||
const column = createTableColumn({
|
||||
name: 'email',
|
||||
type: 'string',
|
||||
required: true,
|
||||
unique: true,
|
||||
}
|
||||
})
|
||||
const result = validateColumnDefinition(column)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
@@ -80,19 +80,20 @@ describe('Validation', () => {
|
||||
const types = ['string', 'number', 'boolean', 'date', 'json'] as const
|
||||
|
||||
for (const type of types) {
|
||||
const result = validateColumnDefinition({ name: 'test', type })
|
||||
const result = validateColumnDefinition(createTableColumn({ name: 'test', type }))
|
||||
expect(result.valid).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject empty column name', () => {
|
||||
const result = validateColumnDefinition({ name: '', type: 'string' })
|
||||
const result = validateColumnDefinition(createTableColumn({ name: '', type: 'string' }))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors).toContain('Column name is required')
|
||||
})
|
||||
|
||||
it('should reject invalid column type', () => {
|
||||
const result = validateColumnDefinition({
|
||||
...createTableColumn({ name: 'test' }),
|
||||
name: 'test',
|
||||
type: 'invalid' as any,
|
||||
})
|
||||
@@ -102,7 +103,7 @@ describe('Validation', () => {
|
||||
|
||||
it('should reject column name exceeding max length', () => {
|
||||
const longName = 'a'.repeat(TABLE_LIMITS.MAX_COLUMN_NAME_LENGTH + 1)
|
||||
const result = validateColumnDefinition({ name: longName, type: 'string' })
|
||||
const result = validateColumnDefinition(createTableColumn({ name: longName, type: 'string' }))
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors[0]).toContain('exceeds maximum length')
|
||||
})
|
||||
@@ -112,9 +113,9 @@ describe('Validation', () => {
|
||||
it('should accept valid schema', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', required: true, unique: true },
|
||||
{ name: 'name', type: 'string', required: true },
|
||||
{ name: 'age', type: 'number' },
|
||||
createTableColumn({ name: 'id', type: 'string', required: true, unique: true }),
|
||||
createTableColumn({ name: 'name', type: 'string', required: true }),
|
||||
createTableColumn({ name: 'age', type: 'number' }),
|
||||
],
|
||||
}
|
||||
const result = validateTableSchema(schema)
|
||||
@@ -131,8 +132,8 @@ describe('Validation', () => {
|
||||
it('should reject duplicate column names', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [
|
||||
{ name: 'id', type: 'string' },
|
||||
{ name: 'ID', type: 'number' },
|
||||
createTableColumn({ name: 'id', type: 'string' }),
|
||||
createTableColumn({ name: 'ID', type: 'number' }),
|
||||
],
|
||||
}
|
||||
const result = validateTableSchema(schema)
|
||||
@@ -153,10 +154,9 @@ describe('Validation', () => {
|
||||
})
|
||||
|
||||
it('should reject schema exceeding max columns', () => {
|
||||
const columns = Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, (_, i) => ({
|
||||
name: `col_${i}`,
|
||||
type: 'string' as const,
|
||||
}))
|
||||
const columns = Array.from({ length: TABLE_LIMITS.MAX_COLUMNS_PER_TABLE + 1 }, (_, i) =>
|
||||
createTableColumn({ name: `col_${i}`, type: 'string' })
|
||||
)
|
||||
const result = validateTableSchema({ columns })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors[0]).toContain('exceeds maximum columns')
|
||||
@@ -182,11 +182,11 @@ describe('Validation', () => {
|
||||
describe('validateRowAgainstSchema', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [
|
||||
{ name: 'name', type: 'string', required: true },
|
||||
{ name: 'age', type: 'number' },
|
||||
{ name: 'active', type: 'boolean' },
|
||||
{ name: 'created', type: 'date' },
|
||||
{ name: 'metadata', type: 'json' },
|
||||
createTableColumn({ name: 'name', type: 'string', required: true }),
|
||||
createTableColumn({ name: 'age', type: 'number' }),
|
||||
createTableColumn({ name: 'active', type: 'boolean' }),
|
||||
createTableColumn({ name: 'created', type: 'date' }),
|
||||
createTableColumn({ name: 'metadata', type: 'json' }),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -281,10 +281,10 @@ describe('Validation', () => {
|
||||
it('should return only columns with unique=true', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', unique: true },
|
||||
{ name: 'email', type: 'string', unique: true },
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'count', type: 'number', unique: false },
|
||||
createTableColumn({ name: 'id', type: 'string', unique: true }),
|
||||
createTableColumn({ name: 'email', type: 'string', unique: true }),
|
||||
createTableColumn({ name: 'name', type: 'string' }),
|
||||
createTableColumn({ name: 'count', type: 'number', unique: false }),
|
||||
],
|
||||
}
|
||||
const result = getUniqueColumns(schema)
|
||||
@@ -295,8 +295,8 @@ describe('Validation', () => {
|
||||
it('should return empty array when no unique columns', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [
|
||||
{ name: 'name', type: 'string' },
|
||||
{ name: 'value', type: 'number' },
|
||||
createTableColumn({ name: 'name', type: 'string' }),
|
||||
createTableColumn({ name: 'value', type: 'number' }),
|
||||
],
|
||||
}
|
||||
const result = getUniqueColumns(schema)
|
||||
@@ -307,9 +307,9 @@ describe('Validation', () => {
|
||||
describe('validateUniqueConstraints', () => {
|
||||
const schema: TableSchema = {
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', unique: true },
|
||||
{ name: 'email', type: 'string', unique: true },
|
||||
{ name: 'name', type: 'string' },
|
||||
createTableColumn({ name: 'id', type: 'string', unique: true }),
|
||||
createTableColumn({ name: 'email', type: 'string', unique: true }),
|
||||
createTableColumn({ name: 'name', type: 'string' }),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* Tests for workflow change detection comparison logic
|
||||
*/
|
||||
|
||||
import type { WorkflowVariableFixture } from '@sim/testing'
|
||||
import {
|
||||
createBlock as createTestBlock,
|
||||
createWorkflowState as createTestWorkflowState,
|
||||
createWorkflowVariablesMap,
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
@@ -46,6 +49,12 @@ function createBlock(id: string, overrides: Record<string, any> = {}): any {
|
||||
})
|
||||
}
|
||||
|
||||
function createVariablesMap(
|
||||
...variables: Parameters<typeof createWorkflowVariablesMap>[0]
|
||||
): Record<string, WorkflowVariableFixture> {
|
||||
return createWorkflowVariablesMap(variables)
|
||||
}
|
||||
|
||||
describe('hasWorkflowChanged', () => {
|
||||
describe('Basic Cases', () => {
|
||||
it.concurrent('should return true when deployedState is null', () => {
|
||||
@@ -2181,9 +2190,12 @@ describe('hasWorkflowChanged', () => {
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'myVar',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
}),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
|
||||
@@ -2192,9 +2204,12 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should detect removed variables', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'myVar',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
}),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
@@ -2208,16 +2223,22 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should detect variable value changes', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'myVar',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
}),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'world' },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'myVar',
|
||||
type: 'string',
|
||||
value: 'world',
|
||||
}),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
|
||||
@@ -2226,16 +2247,12 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should detect variable type changes', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: '123' },
|
||||
},
|
||||
variables: createVariablesMap({ id: 'var1', name: 'myVar', type: 'string', value: '123' }),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'number', value: 123 },
|
||||
},
|
||||
variables: createVariablesMap({ id: 'var1', name: 'myVar', type: 'number', value: 123 }),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
|
||||
@@ -2244,16 +2261,22 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should detect variable name changes', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'oldName', type: 'string', value: 'hello' },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'oldName',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
}),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'newName', type: 'string', value: 'hello' },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'newName',
|
||||
type: 'string',
|
||||
value: 'hello',
|
||||
}),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
|
||||
@@ -2262,18 +2285,18 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should not detect change for identical variables', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
|
||||
},
|
||||
variables: createVariablesMap(
|
||||
{ id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
{ id: 'var2', name: 'count', type: 'number', value: 42 }
|
||||
),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
|
||||
},
|
||||
variables: createVariablesMap(
|
||||
{ id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
{ id: 'var2', name: 'count', type: 'number', value: 42 }
|
||||
),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false)
|
||||
@@ -2310,16 +2333,22 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should handle complex variable values (objects)', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'config', type: 'object', value: { key: 'value1' } },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'config',
|
||||
type: 'object',
|
||||
value: { key: 'value1' },
|
||||
}),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'config', type: 'object', value: { key: 'value2' } },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'config',
|
||||
type: 'object',
|
||||
value: { key: 'value2' },
|
||||
}),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
|
||||
@@ -2328,16 +2357,22 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should handle complex variable values (arrays)', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'items', type: 'array', value: [1, 2, 3] },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
value: [1, 2, 3],
|
||||
}),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'items', type: 'array', value: [1, 2, 4] },
|
||||
},
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
value: [1, 2, 4],
|
||||
}),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(true)
|
||||
@@ -2346,18 +2381,18 @@ describe('hasWorkflowChanged', () => {
|
||||
it.concurrent('should not detect change when variable key order differs', () => {
|
||||
const deployedState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
|
||||
},
|
||||
variables: createVariablesMap(
|
||||
{ id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
{ id: 'var2', name: 'count', type: 'number', value: 42 }
|
||||
),
|
||||
}
|
||||
|
||||
const currentState = {
|
||||
...createWorkflowState({}),
|
||||
variables: {
|
||||
var2: { id: 'var2', name: 'count', type: 'number', value: 42 },
|
||||
var1: { id: 'var1', name: 'myVar', type: 'string', value: 'hello' },
|
||||
},
|
||||
variables: createVariablesMap(
|
||||
{ id: 'var2', name: 'count', type: 'number', value: 42 },
|
||||
{ id: 'var1', name: 'myVar', type: 'string', value: 'hello' }
|
||||
),
|
||||
}
|
||||
|
||||
expect(hasWorkflowChanged(currentState as any, deployedState as any)).toBe(false)
|
||||
@@ -2840,175 +2875,135 @@ describe('hasWorkflowChanged', () => {
|
||||
describe('Variables (UI-only fields should not trigger change)', () => {
|
||||
it.concurrent('should not detect change when validationError differs', () => {
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(deployedState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'plain',
|
||||
value: 'test',
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(currentState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'plain',
|
||||
value: 'test',
|
||||
validationError: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should not detect change when validationError has value vs missing', () => {
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(deployedState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'number',
|
||||
value: 'invalid',
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(currentState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'number',
|
||||
value: 'invalid',
|
||||
validationError: 'Not a valid number',
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should detect change when variable value differs', () => {
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(deployedState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'plain',
|
||||
value: 'old value',
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(currentState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'plain',
|
||||
value: 'new value',
|
||||
validationError: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect change when variable is added', () => {
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: {},
|
||||
})
|
||||
;(deployedState as any).variables = {}
|
||||
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(currentState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'plain',
|
||||
value: 'test',
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect change when variable is removed', () => {
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
})
|
||||
;(deployedState as any).variables = {
|
||||
var1: {
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: createVariablesMap({
|
||||
id: 'var1',
|
||||
workflowId: 'workflow1',
|
||||
name: 'myVar',
|
||||
type: 'plain',
|
||||
value: 'test',
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: {},
|
||||
})
|
||||
;(currentState as any).variables = {}
|
||||
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not detect change when empty array vs empty object', () => {
|
||||
const deployedState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
blocks: { block1: createBlock('block1') },
|
||||
})
|
||||
;(deployedState as any).variables = []
|
||||
// Intentional type violation to test robustness with malformed data
|
||||
;(deployedState as unknown as Record<string, unknown>).variables = []
|
||||
|
||||
const currentState = createWorkflowState({
|
||||
blocks: {
|
||||
block1: createBlock('block1'),
|
||||
},
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: {},
|
||||
})
|
||||
;(currentState as any).variables = {}
|
||||
|
||||
expect(hasWorkflowChanged(currentState, deployedState)).toBe(false)
|
||||
})
|
||||
@@ -3151,7 +3146,7 @@ describe('generateWorkflowDiffSummary', () => {
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'hello' } },
|
||||
variables: createVariablesMap({ id: 'var1', name: 'test', type: 'string', value: 'hello' }),
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
@@ -3161,11 +3156,11 @@ describe('generateWorkflowDiffSummary', () => {
|
||||
it.concurrent('should detect modified variables', () => {
|
||||
const previousState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'hello' } },
|
||||
variables: createVariablesMap({ id: 'var1', name: 'test', type: 'string', value: 'hello' }),
|
||||
})
|
||||
const currentState = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1') },
|
||||
variables: { var1: { id: 'var1', name: 'test', type: 'string', value: 'world' } },
|
||||
variables: createVariablesMap({ id: 'var1', name: 'test', type: 'string', value: 'world' }),
|
||||
})
|
||||
const result = generateWorkflowDiffSummary(currentState, previousState)
|
||||
expect(result.hasChanges).toBe(true)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockSelectChain, createMockUpdateChain } from '@sim/testing'
|
||||
import { loggerMock } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
@@ -35,13 +37,7 @@ vi.mock('@sim/db/schema', () => ({
|
||||
workflowSchedule: { archivedAt: 'workflow_schedule_archived_at' },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: (...args: unknown[]) => mockGetWorkflowById(...args),
|
||||
@@ -66,24 +62,6 @@ vi.mock('@/lib/core/telemetry', () => ({
|
||||
|
||||
import { archiveWorkflow } from '@/lib/workflows/lifecycle'
|
||||
|
||||
function createSelectChain<T>(result: T) {
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
innerJoin: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(result),
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
function createUpdateChain() {
|
||||
return {
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('workflow lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -107,10 +85,10 @@ describe('workflow lifecycle', () => {
|
||||
archivedAt: new Date(),
|
||||
})
|
||||
|
||||
mockSelect.mockReturnValue(createSelectChain([]))
|
||||
mockSelect.mockReturnValue(createMockSelectChain([]))
|
||||
|
||||
const tx = {
|
||||
update: vi.fn().mockImplementation(() => createUpdateChain()),
|
||||
update: vi.fn().mockImplementation(() => createMockUpdateChain()),
|
||||
}
|
||||
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
|
||||
callback(tx)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sanitizePathSegment } from '@/lib/core/utils/file-download'
|
||||
import {
|
||||
type ExportWorkflowState,
|
||||
sanitizeForExport,
|
||||
@@ -43,36 +44,6 @@ export interface WorkspaceExportStructure {
|
||||
folders: FolderExportData[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string for use as a path segment in a ZIP file.
|
||||
*/
|
||||
export function sanitizePathSegment(name: string): string {
|
||||
return name.replace(/[^a-z0-9-_]/gi, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads a file to the user's device.
|
||||
*/
|
||||
export function downloadFile(
|
||||
content: Blob | string,
|
||||
filename: string,
|
||||
mimeType = 'application/json'
|
||||
): void {
|
||||
try {
|
||||
const blob = content instanceof Blob ? content : new Blob([content], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
logger.error('Failed to download file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a workflow's state and variables for export.
|
||||
* Returns null if the workflow cannot be fetched.
|
||||
|
||||
@@ -22,6 +22,10 @@ export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
|
||||
'datasetId',
|
||||
'serviceDeskId',
|
||||
'impersonateUserEmail',
|
||||
'awsAccessKeyId',
|
||||
'awsSecretAccessKey',
|
||||
'awsRegion',
|
||||
'logGroupName',
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { createMockDeleteChain, createMockSelectChain, createMockUpdateChain } from '@sim/testing'
|
||||
import { loggerMock } from '@sim/testing/mocks'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSelect, mockTransaction, mockArchiveWorkflowsForWorkspace, mockGetWorkspaceWithOwner } =
|
||||
@@ -33,13 +35,7 @@ vi.mock('@sim/db/schema', () => ({
|
||||
workspaceNotificationSubscription: { active: 'workspace_notification_active' },
|
||||
}))
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('@sim/logger', () => loggerMock)
|
||||
|
||||
vi.mock('@/lib/workflows/lifecycle', () => ({
|
||||
archiveWorkflowsForWorkspace: (...args: unknown[]) => mockArchiveWorkflowsForWorkspace(...args),
|
||||
@@ -51,14 +47,6 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
|
||||
import { archiveWorkspace } from './lifecycle'
|
||||
|
||||
function createUpdateChain() {
|
||||
return {
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -72,22 +60,12 @@ describe('workspace lifecycle', () => {
|
||||
archivedAt: null,
|
||||
})
|
||||
mockArchiveWorkflowsForWorkspace.mockResolvedValue(2)
|
||||
mockSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'server-1' }]),
|
||||
}),
|
||||
})
|
||||
mockSelect.mockReturnValue(createMockSelectChain([{ id: 'server-1' }]))
|
||||
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'kb-1' }]),
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockImplementation(() => createUpdateChain()),
|
||||
delete: vi.fn().mockImplementation(() => ({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
select: vi.fn().mockReturnValue(createMockSelectChain([{ id: 'kb-1' }])),
|
||||
update: vi.fn().mockImplementation(() => createMockUpdateChain()),
|
||||
delete: vi.fn().mockImplementation(() => createMockDeleteChain()),
|
||||
}
|
||||
mockTransaction.mockImplementation(async (callback: (trx: typeof tx) => Promise<void>) =>
|
||||
callback(tx)
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.940.0",
|
||||
"@aws-sdk/client-cloudwatch": "3.940.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "3.940.0",
|
||||
"@aws-sdk/client-dynamodb": "3.940.0",
|
||||
"@aws-sdk/client-rds-data": "3.940.0",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
|
||||
@@ -114,6 +114,12 @@ export const useTableUndoStore = create<TableUndoState>()(
|
||||
if (action.type === 'create-row' && action.rowId === oldRowId) {
|
||||
return { ...entry, action: { ...action, rowId: newRowId } }
|
||||
}
|
||||
if (action.type === 'create-rows') {
|
||||
const patchedRows = action.rows.map((r) =>
|
||||
r.rowId === oldRowId ? { ...r, rowId: newRowId } : r
|
||||
)
|
||||
return { ...entry, action: { ...action, rows: patchedRows } }
|
||||
}
|
||||
return entry
|
||||
})
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ export type TableUndoAction =
|
||||
}
|
||||
| { type: 'delete-rows'; rows: DeletedRowSnapshot[] }
|
||||
| { type: 'create-column'; columnName: string; position: number }
|
||||
| {
|
||||
type: 'delete-column'
|
||||
columnName: string
|
||||
columnType: string
|
||||
position: number
|
||||
unique: boolean
|
||||
required: boolean
|
||||
}
|
||||
| { type: 'rename-column'; oldName: string; newName: string }
|
||||
| { type: 'update-column-type'; columnName: string; previousType: string; newType: string }
|
||||
| {
|
||||
|
||||
99
apps/sim/tools/cloudwatch/describe_alarms.ts
Normal file
99
apps/sim/tools/cloudwatch/describe_alarms.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type {
|
||||
CloudWatchDescribeAlarmsParams,
|
||||
CloudWatchDescribeAlarmsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeAlarmsTool: ToolConfig<
|
||||
CloudWatchDescribeAlarmsParams,
|
||||
CloudWatchDescribeAlarmsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_describe_alarms',
|
||||
name: 'CloudWatch Describe Alarms',
|
||||
description: 'List and filter CloudWatch alarms',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
alarmNamePrefix: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter alarms by name prefix',
|
||||
},
|
||||
stateValue: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by alarm state (OK, ALARM, INSUFFICIENT_DATA)',
|
||||
},
|
||||
alarmType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by alarm type (MetricAlarm, CompositeAlarm)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of alarms to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/describe-alarms',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
...(params.alarmNamePrefix && { alarmNamePrefix: params.alarmNamePrefix }),
|
||||
...(params.stateValue && { stateValue: params.stateValue }),
|
||||
...(params.alarmType && { alarmType: params.alarmType }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudWatch alarms')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
alarms: data.output.alarms,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
alarms: {
|
||||
type: 'array',
|
||||
description: 'List of CloudWatch alarms with state and configuration',
|
||||
},
|
||||
},
|
||||
}
|
||||
82
apps/sim/tools/cloudwatch/describe_log_groups.ts
Normal file
82
apps/sim/tools/cloudwatch/describe_log_groups.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
CloudWatchDescribeLogGroupsParams,
|
||||
CloudWatchDescribeLogGroupsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeLogGroupsTool: ToolConfig<
|
||||
CloudWatchDescribeLogGroupsParams,
|
||||
CloudWatchDescribeLogGroupsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_describe_log_groups',
|
||||
name: 'CloudWatch Describe Log Groups',
|
||||
description: 'List available CloudWatch log groups',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
prefix: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter log groups by name prefix',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of log groups to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/describe-log-groups',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
...(params.prefix && { prefix: params.prefix }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudWatch log groups')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
logGroups: data.output.logGroups,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
logGroups: { type: 'array', description: 'List of CloudWatch log groups with metadata' },
|
||||
},
|
||||
}
|
||||
92
apps/sim/tools/cloudwatch/describe_log_streams.ts
Normal file
92
apps/sim/tools/cloudwatch/describe_log_streams.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
CloudWatchDescribeLogStreamsParams,
|
||||
CloudWatchDescribeLogStreamsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const describeLogStreamsTool: ToolConfig<
|
||||
CloudWatchDescribeLogStreamsParams,
|
||||
CloudWatchDescribeLogStreamsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_describe_log_streams',
|
||||
name: 'CloudWatch Describe Log Streams',
|
||||
description: 'List log streams within a CloudWatch log group',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
logGroupName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CloudWatch log group name',
|
||||
},
|
||||
prefix: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter log streams by name prefix',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of log streams to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/describe-log-streams',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
logGroupName: params.logGroupName,
|
||||
...(params.prefix && { prefix: params.prefix }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to describe CloudWatch log streams')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
logStreams: data.output.logStreams,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
logStreams: {
|
||||
type: 'array',
|
||||
description: 'List of log streams with metadata',
|
||||
},
|
||||
},
|
||||
}
|
||||
106
apps/sim/tools/cloudwatch/get_log_events.ts
Normal file
106
apps/sim/tools/cloudwatch/get_log_events.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
CloudWatchGetLogEventsParams,
|
||||
CloudWatchGetLogEventsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getLogEventsTool: ToolConfig<
|
||||
CloudWatchGetLogEventsParams,
|
||||
CloudWatchGetLogEventsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_get_log_events',
|
||||
name: 'CloudWatch Get Log Events',
|
||||
description: 'Retrieve log events from a specific CloudWatch log stream',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
logGroupName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CloudWatch log group name',
|
||||
},
|
||||
logStreamName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CloudWatch log stream name',
|
||||
},
|
||||
startTime: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start time as Unix epoch seconds',
|
||||
},
|
||||
endTime: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End time as Unix epoch seconds',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of events to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/get-log-events',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
logGroupName: params.logGroupName,
|
||||
logStreamName: params.logStreamName,
|
||||
...(params.startTime !== undefined && { startTime: params.startTime }),
|
||||
...(params.endTime !== undefined && { endTime: params.endTime }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to get CloudWatch log events')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
events: data.output.events,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
events: {
|
||||
type: 'array',
|
||||
description: 'Log events with timestamp, message, and ingestion time',
|
||||
},
|
||||
},
|
||||
}
|
||||
119
apps/sim/tools/cloudwatch/get_metric_statistics.ts
Normal file
119
apps/sim/tools/cloudwatch/get_metric_statistics.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type {
|
||||
CloudWatchGetMetricStatisticsParams,
|
||||
CloudWatchGetMetricStatisticsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const getMetricStatisticsTool: ToolConfig<
|
||||
CloudWatchGetMetricStatisticsParams,
|
||||
CloudWatchGetMetricStatisticsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_get_metric_statistics',
|
||||
name: 'CloudWatch Get Metric Statistics',
|
||||
description: 'Get statistics for a CloudWatch metric over a time range',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
namespace: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Metric namespace (e.g., AWS/EC2, AWS/Lambda)',
|
||||
},
|
||||
metricName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Metric name (e.g., CPUUtilization, Invocations)',
|
||||
},
|
||||
startTime: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start time as Unix epoch seconds',
|
||||
},
|
||||
endTime: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End time as Unix epoch seconds',
|
||||
},
|
||||
period: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Granularity in seconds (e.g., 60, 300, 3600)',
|
||||
},
|
||||
statistics: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Statistics to retrieve (Average, Sum, Minimum, Maximum, SampleCount)',
|
||||
},
|
||||
dimensions: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Dimensions as JSON (e.g., {"InstanceId": "i-1234"})',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/get-metric-statistics',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
namespace: params.namespace,
|
||||
metricName: params.metricName,
|
||||
startTime: params.startTime,
|
||||
endTime: params.endTime,
|
||||
period: params.period,
|
||||
statistics: params.statistics,
|
||||
...(params.dimensions && { dimensions: params.dimensions }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to get CloudWatch metric statistics')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
label: data.output.label,
|
||||
datapoints: data.output.datapoints,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
label: { type: 'string', description: 'Metric label' },
|
||||
datapoints: { type: 'array', description: 'Datapoints with timestamp and statistics values' },
|
||||
},
|
||||
}
|
||||
15
apps/sim/tools/cloudwatch/index.ts
Normal file
15
apps/sim/tools/cloudwatch/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { describeAlarmsTool } from '@/tools/cloudwatch/describe_alarms'
|
||||
import { describeLogGroupsTool } from '@/tools/cloudwatch/describe_log_groups'
|
||||
import { describeLogStreamsTool } from '@/tools/cloudwatch/describe_log_streams'
|
||||
import { getLogEventsTool } from '@/tools/cloudwatch/get_log_events'
|
||||
import { getMetricStatisticsTool } from '@/tools/cloudwatch/get_metric_statistics'
|
||||
import { listMetricsTool } from '@/tools/cloudwatch/list_metrics'
|
||||
import { queryLogsTool } from '@/tools/cloudwatch/query_logs'
|
||||
|
||||
export const cloudwatchDescribeAlarmsTool = describeAlarmsTool
|
||||
export const cloudwatchDescribeLogGroupsTool = describeLogGroupsTool
|
||||
export const cloudwatchDescribeLogStreamsTool = describeLogStreamsTool
|
||||
export const cloudwatchGetLogEventsTool = getLogEventsTool
|
||||
export const cloudwatchGetMetricStatisticsTool = getMetricStatisticsTool
|
||||
export const cloudwatchListMetricsTool = listMetricsTool
|
||||
export const cloudwatchQueryLogsTool = queryLogsTool
|
||||
96
apps/sim/tools/cloudwatch/list_metrics.ts
Normal file
96
apps/sim/tools/cloudwatch/list_metrics.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
CloudWatchListMetricsParams,
|
||||
CloudWatchListMetricsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const listMetricsTool: ToolConfig<
|
||||
CloudWatchListMetricsParams,
|
||||
CloudWatchListMetricsResponse
|
||||
> = {
|
||||
id: 'cloudwatch_list_metrics',
|
||||
name: 'CloudWatch List Metrics',
|
||||
description: 'List available CloudWatch metrics',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
namespace: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by namespace (e.g., AWS/EC2, AWS/Lambda)',
|
||||
},
|
||||
metricName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Filter by metric name',
|
||||
},
|
||||
recentlyActive: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Only show metrics active in the last 3 hours',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of metrics to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/list-metrics',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
...(params.namespace && { namespace: params.namespace }),
|
||||
...(params.metricName && { metricName: params.metricName }),
|
||||
...(params.recentlyActive && { recentlyActive: params.recentlyActive }),
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to list CloudWatch metrics')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
metrics: data.output.metrics,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
metrics: { type: 'array', description: 'List of metrics with namespace, name, and dimensions' },
|
||||
},
|
||||
}
|
||||
107
apps/sim/tools/cloudwatch/query_logs.ts
Normal file
107
apps/sim/tools/cloudwatch/query_logs.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
CloudWatchQueryLogsParams,
|
||||
CloudWatchQueryLogsResponse,
|
||||
} from '@/tools/cloudwatch/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const queryLogsTool: ToolConfig<CloudWatchQueryLogsParams, CloudWatchQueryLogsResponse> = {
|
||||
id: 'cloudwatch_query_logs',
|
||||
name: 'CloudWatch Query Logs',
|
||||
description: 'Run a CloudWatch Log Insights query against one or more log groups',
|
||||
version: '1.0',
|
||||
|
||||
params: {
|
||||
awsRegion: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS region (e.g., us-east-1)',
|
||||
},
|
||||
awsAccessKeyId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS access key ID',
|
||||
},
|
||||
awsSecretAccessKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'AWS secret access key',
|
||||
},
|
||||
logGroupNames: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Log group names to query',
|
||||
},
|
||||
queryString: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'CloudWatch Log Insights query string',
|
||||
},
|
||||
startTime: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Start time as Unix epoch seconds',
|
||||
},
|
||||
endTime: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'End time as Unix epoch seconds',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of results to return',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/cloudwatch/query-logs',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
region: params.awsRegion,
|
||||
accessKeyId: params.awsAccessKeyId,
|
||||
secretAccessKey: params.awsSecretAccessKey,
|
||||
logGroupNames: params.logGroupNames,
|
||||
queryString: params.queryString,
|
||||
startTime: params.startTime,
|
||||
endTime: params.endTime,
|
||||
...(params.limit !== undefined && { limit: params.limit }),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'CloudWatch Log Insights query failed')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
results: data.output.results,
|
||||
statistics: data.output.statistics,
|
||||
status: data.output.status,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
results: { type: 'array', description: 'Query result rows' },
|
||||
statistics: {
|
||||
type: 'object',
|
||||
description: 'Query statistics (bytesScanned, recordsMatched, recordsScanned)',
|
||||
},
|
||||
status: { type: 'string', description: 'Query completion status' },
|
||||
},
|
||||
}
|
||||
146
apps/sim/tools/cloudwatch/types.ts
Normal file
146
apps/sim/tools/cloudwatch/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
export interface CloudWatchConnectionConfig {
|
||||
awsRegion: string
|
||||
awsAccessKeyId: string
|
||||
awsSecretAccessKey: string
|
||||
}
|
||||
|
||||
export interface CloudWatchQueryLogsParams extends CloudWatchConnectionConfig {
|
||||
logGroupNames: string[]
|
||||
queryString: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CloudWatchDescribeLogGroupsParams extends CloudWatchConnectionConfig {
|
||||
prefix?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CloudWatchGetLogEventsParams extends CloudWatchConnectionConfig {
|
||||
logGroupName: string
|
||||
logStreamName: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CloudWatchQueryLogsResponse extends ToolResponse {
|
||||
output: {
|
||||
results: Record<string, string>[]
|
||||
statistics: {
|
||||
bytesScanned: number
|
||||
recordsMatched: number
|
||||
recordsScanned: number
|
||||
}
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudWatchDescribeLogGroupsResponse extends ToolResponse {
|
||||
output: {
|
||||
logGroups: {
|
||||
logGroupName: string
|
||||
arn: string
|
||||
storedBytes: number
|
||||
retentionInDays: number | undefined
|
||||
creationTime: number | undefined
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudWatchGetLogEventsResponse extends ToolResponse {
|
||||
output: {
|
||||
events: {
|
||||
timestamp: number | undefined
|
||||
message: string | undefined
|
||||
ingestionTime: number | undefined
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudWatchDescribeLogStreamsParams extends CloudWatchConnectionConfig {
|
||||
logGroupName: string
|
||||
prefix?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CloudWatchDescribeLogStreamsResponse extends ToolResponse {
|
||||
output: {
|
||||
logStreams: {
|
||||
logStreamName: string
|
||||
lastEventTimestamp: number | undefined
|
||||
firstEventTimestamp: number | undefined
|
||||
creationTime: number | undefined
|
||||
storedBytes: number
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudWatchListMetricsParams extends CloudWatchConnectionConfig {
|
||||
namespace?: string
|
||||
metricName?: string
|
||||
recentlyActive?: boolean
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CloudWatchListMetricsResponse extends ToolResponse {
|
||||
output: {
|
||||
metrics: {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: { name: string; value: string }[]
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudWatchGetMetricStatisticsParams extends CloudWatchConnectionConfig {
|
||||
namespace: string
|
||||
metricName: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
period: number
|
||||
statistics: string[]
|
||||
dimensions?: string
|
||||
}
|
||||
|
||||
export interface CloudWatchGetMetricStatisticsResponse extends ToolResponse {
|
||||
output: {
|
||||
label: string
|
||||
datapoints: {
|
||||
timestamp: number
|
||||
average?: number
|
||||
sum?: number
|
||||
minimum?: number
|
||||
maximum?: number
|
||||
sampleCount?: number
|
||||
unit?: string
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface CloudWatchDescribeAlarmsParams extends CloudWatchConnectionConfig {
|
||||
alarmNamePrefix?: string
|
||||
stateValue?: string
|
||||
alarmType?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface CloudWatchDescribeAlarmsResponse extends ToolResponse {
|
||||
output: {
|
||||
alarms: {
|
||||
alarmName: string
|
||||
alarmArn: string
|
||||
stateValue: string
|
||||
stateReason: string
|
||||
metricName: string | undefined
|
||||
namespace: string | undefined
|
||||
comparisonOperator: string | undefined
|
||||
threshold: number | undefined
|
||||
evaluationPeriods: number | undefined
|
||||
stateUpdatedTimestamp: number | undefined
|
||||
}[]
|
||||
}
|
||||
}
|
||||
@@ -275,6 +275,15 @@ import {
|
||||
cloudflareUpdateDnsRecordTool,
|
||||
cloudflareUpdateZoneSettingTool,
|
||||
} from '@/tools/cloudflare'
|
||||
import {
|
||||
cloudwatchDescribeAlarmsTool,
|
||||
cloudwatchDescribeLogGroupsTool,
|
||||
cloudwatchDescribeLogStreamsTool,
|
||||
cloudwatchGetLogEventsTool,
|
||||
cloudwatchGetMetricStatisticsTool,
|
||||
cloudwatchListMetricsTool,
|
||||
cloudwatchQueryLogsTool,
|
||||
} from '@/tools/cloudwatch'
|
||||
import {
|
||||
confluenceAddLabelTool,
|
||||
confluenceCreateBlogPostTool,
|
||||
@@ -3376,6 +3385,13 @@ export const tools: Record<string, ToolConfig> = {
|
||||
rds_delete: rdsDeleteTool,
|
||||
rds_execute: rdsExecuteTool,
|
||||
rds_introspect: rdsIntrospectTool,
|
||||
cloudwatch_query_logs: cloudwatchQueryLogsTool,
|
||||
cloudwatch_describe_log_groups: cloudwatchDescribeLogGroupsTool,
|
||||
cloudwatch_describe_alarms: cloudwatchDescribeAlarmsTool,
|
||||
cloudwatch_describe_log_streams: cloudwatchDescribeLogStreamsTool,
|
||||
cloudwatch_get_log_events: cloudwatchGetLogEventsTool,
|
||||
cloudwatch_list_metrics: cloudwatchListMetricsTool,
|
||||
cloudwatch_get_metric_statistics: cloudwatchGetMetricStatisticsTool,
|
||||
dynamodb_get: dynamodbGetTool,
|
||||
dynamodb_put: dynamodbPutTool,
|
||||
dynamodb_query: dynamodbQueryTool,
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -57,6 +57,8 @@
|
||||
"@a2a-js/sdk": "0.3.7",
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@aws-sdk/client-bedrock-runtime": "3.940.0",
|
||||
"@aws-sdk/client-cloudwatch": "3.940.0",
|
||||
"@aws-sdk/client-cloudwatch-logs": "3.940.0",
|
||||
"@aws-sdk/client-dynamodb": "3.940.0",
|
||||
"@aws-sdk/client-rds-data": "3.940.0",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
@@ -414,6 +416,10 @@
|
||||
|
||||
"@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/eventstream-handler-node": "3.936.0", "@aws-sdk/middleware-eventstream": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/middleware-websocket": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/token-providers": "3.940.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Gs6UUQP1zt8vahOxJ3BADcb3B+2KldUNA3bKa+KdK58de7N7tLJFJfZuXhFGGtwyNPh1aw6phtdP6dauq3OLWA=="],
|
||||
|
||||
"@aws-sdk/client-cloudwatch": ["@aws-sdk/client-cloudwatch@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-compression": "^4.3.12", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-C35xpPntRAGdEg3X5iKpSUCBaP3yxYNo1U95qipN/X1e0/TYIDWHwGt8Z1ntRafK19jp5oVzhRQ+PD1JAPSEzA=="],
|
||||
|
||||
"@aws-sdk/client-cloudwatch-logs": ["@aws-sdk/client-cloudwatch-logs@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7dEIO3D98IxA9IhqixPJbzQsBkk4TchHHpFdd0JOhlSlihWhiwbf3ijUePJVXYJxcpRRtMmAMtDRLDzCSO+ZHg=="],
|
||||
|
||||
"@aws-sdk/client-dynamodb": ["@aws-sdk/client-dynamodb@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-endpoint-discovery": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-u2sXsNJazJbuHeWICvsj6RvNyJh3isedEfPvB21jK/kxcriK+dE/izlKC2cyxUjERCmku0zTFNzY9FhrLbYHjQ=="],
|
||||
|
||||
"@aws-sdk/client-rds-data": ["@aws-sdk/client-rds-data@3.940.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.940.0", "@aws-sdk/credential-provider-node": "3.940.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-user-agent": "3.940.0", "@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/types": "3.936.0", "@aws-sdk/util-endpoints": "3.936.0", "@aws-sdk/util-user-agent-browser": "3.936.0", "@aws-sdk/util-user-agent-node": "3.940.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.12", "@smithy/middleware-retry": "^4.4.12", "@smithy/middleware-serde": "^4.2.6", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.8", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.11", "@smithy/util-defaults-mode-node": "^4.2.14", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-68NH61MvS48CVPfzBNCPdCG4KnNjM+Uj/3DSw7rT9PJvdML9ARS4M2Uqco9POPw+Aj20KBumsEUd6FMVcYBXAA=="],
|
||||
@@ -1348,6 +1354,8 @@
|
||||
|
||||
"@smithy/md5-js": ["@smithy/md5-js@4.2.12", "", { "dependencies": { "@smithy/types": "^4.13.1", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ=="],
|
||||
|
||||
"@smithy/middleware-compression": ["@smithy/middleware-compression@4.3.42", "", { "dependencies": { "@smithy/core": "^3.23.13", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.12", "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-utf8": "^4.2.2", "fflate": "0.8.1", "tslib": "^2.6.2" } }, "sha512-Ys2R8N7oZ3b6p063lhk7paRbX1F9Ju8a8Bsrw2nFfsG8iHYpgfW6ijd7hJKqRe+Wq9ABfcmX3luBlEd+B5/jVA=="],
|
||||
|
||||
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA=="],
|
||||
|
||||
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.27", "", { "dependencies": { "@smithy/core": "^3.23.12", "@smithy/middleware-serde": "^4.2.15", "@smithy/node-config-provider": "^4.3.12", "@smithy/shared-ini-file-loader": "^4.4.7", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" } }, "sha512-T3TFfUgXQlpcg+UdzcAISdZpj4Z+XECZ/cefgA6wLBd6V4lRi0svN2hBouN/be9dXQ31X4sLWz3fAQDf+nt6BA=="],
|
||||
@@ -4122,6 +4130,10 @@
|
||||
|
||||
"@shuding/opentype.js/fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="],
|
||||
|
||||
"@smithy/middleware-compression/@smithy/core": ["@smithy/core@3.23.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.12", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-J+2TT9D6oGsUVXVEMvz8h2EmdVnkBiy2auCie4aSJMvKlzUtO5hqjEzXhoCUkIMo7gAYjbQcN0g/MMSXEhDs1Q=="],
|
||||
|
||||
"@smithy/middleware-compression/fflate": ["fflate@0.8.1", "", {}, "sha512-/exOvEuc+/iaUm105QIiOt4LpBdMTWsXxqR0HDF35vx3fmaKzw7354gTilCh5rkzEt8WYyG//ku3h3nRmd7CHQ=="],
|
||||
|
||||
"@socket.io/redis-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
@@ -4660,6 +4672,8 @@
|
||||
|
||||
"@shikijs/rehype/shiki/@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="],
|
||||
|
||||
"@smithy/middleware-compression/@smithy/core/@smithy/util-stream": ["@smithy/util-stream@4.5.21", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.15", "@smithy/node-http-handler": "^4.5.1", "@smithy/types": "^4.13.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-KzSg+7KKywLnkoKejRtIBXDmwBfjGvg1U1i/etkC7XSWUyFCoLno1IohV2c74IzQqdhX5y3uE44r/8/wuK+A7Q=="],
|
||||
|
||||
"@trigger.dev/core/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.203.0", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/otlp-transformer": "0.203.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Wbxf7k+87KyvxFr5D7uOiSq/vHXWommvdnNE7vECO3tAhsA2GfOlpWINCMWUEPdHZ7tCXxw6Epp3vgx3jU7llQ=="],
|
||||
@@ -5128,6 +5142,8 @@
|
||||
|
||||
"@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"@smithy/middleware-compression/@smithy/core/@smithy/util-stream/@smithy/node-http-handler": ["@smithy/node-http-handler@4.5.1", "", { "dependencies": { "@smithy/protocol-http": "^5.3.12", "@smithy/querystring-builder": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-ejjxdAXjkPIs9lyYyVutOGNOraqUE9v/NjGMKwwFrfOM354wfSD8lmlj8hVwUzQmlLLF4+udhfCX9Exnbmvfzw=="],
|
||||
|
||||
"@trigger.dev/core/socket.io-client/engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||
|
||||
"@trigger.dev/core/socket.io-client/engine.io-client/xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.0.0", "", {}, "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="],
|
||||
|
||||
1
packages/db/migrations/0185_new_gravity.sql
Normal file
1
packages/db/migrations/0185_new_gravity.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."usage_log_source" ADD VALUE 'knowledge-base';
|
||||
14618
packages/db/migrations/meta/0185_snapshot.json
Normal file
14618
packages/db/migrations/meta/0185_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1289,6 +1289,13 @@
|
||||
"when": 1775149654511,
|
||||
"tag": "0184_hard_thaddeus_ross",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 185,
|
||||
"version": "7",
|
||||
"when": 1775247973312,
|
||||
"tag": "0185_new_gravity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2273,6 +2273,7 @@ export const usageLogSourceEnum = pgEnum('usage_log_source', [
|
||||
'workspace-chat',
|
||||
'mcp_copilot',
|
||||
'mothership_block',
|
||||
'knowledge-base',
|
||||
])
|
||||
|
||||
export const usageLog = pgTable(
|
||||
|
||||
@@ -118,6 +118,15 @@ export {
|
||||
type SerializedConnection,
|
||||
type SerializedWorkflow,
|
||||
} from './serialized-block.factory'
|
||||
export {
|
||||
createTableColumn,
|
||||
createTableRow,
|
||||
type TableColumnFactoryOptions,
|
||||
type TableColumnFixture,
|
||||
type TableColumnType,
|
||||
type TableRowFactoryOptions,
|
||||
type TableRowFixture,
|
||||
} from './table.factory'
|
||||
// Tool mock responses
|
||||
export {
|
||||
mockDriveResponses,
|
||||
@@ -178,3 +187,10 @@ export {
|
||||
type WorkflowFactoryOptions,
|
||||
type WorkflowStateFixture,
|
||||
} from './workflow.factory'
|
||||
export {
|
||||
createWorkflowVariable,
|
||||
createWorkflowVariablesMap,
|
||||
type WorkflowVariableFactoryOptions,
|
||||
type WorkflowVariableFixture,
|
||||
type WorkflowVariableType,
|
||||
} from './workflow-variable.factory'
|
||||
|
||||
12
packages/testing/src/factories/table.factory.test.ts
Normal file
12
packages/testing/src/factories/table.factory.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTableColumn } from './table.factory'
|
||||
|
||||
describe('table factory', () => {
|
||||
it('generates default column names that match table naming rules', () => {
|
||||
const generatedNames = Array.from({ length: 100 }, () => createTableColumn().name)
|
||||
|
||||
for (const name of generatedNames) {
|
||||
expect(name).toMatch(/^[a-z_][a-z0-9_]*$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
62
packages/testing/src/factories/table.factory.ts
Normal file
62
packages/testing/src/factories/table.factory.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { customAlphabet, nanoid } from 'nanoid'
|
||||
|
||||
export type TableColumnType = 'string' | 'number' | 'boolean' | 'date' | 'json'
|
||||
|
||||
export interface TableColumnFixture {
|
||||
name: string
|
||||
type: TableColumnType
|
||||
required?: boolean
|
||||
unique?: boolean
|
||||
}
|
||||
|
||||
export interface TableRowFixture {
|
||||
id: string
|
||||
data: Record<string, unknown>
|
||||
position: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TableColumnFactoryOptions {
|
||||
name?: string
|
||||
type?: TableColumnType
|
||||
required?: boolean
|
||||
unique?: boolean
|
||||
}
|
||||
|
||||
export interface TableRowFactoryOptions {
|
||||
id?: string
|
||||
data?: Record<string, unknown>
|
||||
position?: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const createTableColumnSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789_', 6)
|
||||
|
||||
/**
|
||||
* Creates a table column fixture with sensible defaults.
|
||||
*/
|
||||
export function createTableColumn(options: TableColumnFactoryOptions = {}): TableColumnFixture {
|
||||
return {
|
||||
name: options.name ?? `column_${createTableColumnSuffix()}`,
|
||||
type: options.type ?? 'string',
|
||||
required: options.required,
|
||||
unique: options.unique,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a table row fixture with sensible defaults.
|
||||
*/
|
||||
export function createTableRow(options: TableRowFactoryOptions = {}): TableRowFixture {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
return {
|
||||
id: options.id ?? `row_${nanoid(8)}`,
|
||||
data: options.data ?? {},
|
||||
position: options.position ?? 0,
|
||||
createdAt: options.createdAt ?? timestamp,
|
||||
updatedAt: options.updatedAt ?? timestamp,
|
||||
}
|
||||
}
|
||||
53
packages/testing/src/factories/workflow-variable.factory.ts
Normal file
53
packages/testing/src/factories/workflow-variable.factory.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
export type WorkflowVariableType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'plain'
|
||||
|
||||
export interface WorkflowVariableFixture {
|
||||
id: string
|
||||
name: string
|
||||
type: WorkflowVariableType
|
||||
value: unknown
|
||||
workflowId?: string
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
export interface WorkflowVariableFactoryOptions {
|
||||
id?: string
|
||||
name?: string
|
||||
type?: WorkflowVariableType
|
||||
value?: unknown
|
||||
workflowId?: string
|
||||
validationError?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a workflow variable fixture with sensible defaults.
|
||||
*/
|
||||
export function createWorkflowVariable(
|
||||
options: WorkflowVariableFactoryOptions = {}
|
||||
): WorkflowVariableFixture {
|
||||
const id = options.id ?? `var_${nanoid(8)}`
|
||||
|
||||
return {
|
||||
id,
|
||||
name: options.name ?? `variable_${id.slice(0, 4)}`,
|
||||
type: options.type ?? 'string',
|
||||
value: options.value ?? '',
|
||||
workflowId: options.workflowId,
|
||||
validationError: options.validationError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a variables map keyed by variable id.
|
||||
*/
|
||||
export function createWorkflowVariablesMap(
|
||||
variables: WorkflowVariableFactoryOptions[] = []
|
||||
): Record<string, WorkflowVariableFixture> {
|
||||
return Object.fromEntries(
|
||||
variables.map((variable) => {
|
||||
const fixture = createWorkflowVariable(variable)
|
||||
return [fixture.id, fixture]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -46,10 +46,14 @@ export * from './builders'
|
||||
export * from './factories'
|
||||
export {
|
||||
AuthTypeMock,
|
||||
asyncRouteParams,
|
||||
auditMock,
|
||||
clearRedisMocks,
|
||||
createEditWorkflowRegistryMock,
|
||||
createEnvMock,
|
||||
createFeatureFlagsMock,
|
||||
createMockDb,
|
||||
createMockDeleteChain,
|
||||
createMockFetch,
|
||||
createMockFormDataRequest,
|
||||
createMockGetEnv,
|
||||
@@ -57,15 +61,19 @@ export {
|
||||
createMockRedis,
|
||||
createMockRequest,
|
||||
createMockResponse,
|
||||
createMockSelectChain,
|
||||
createMockSocket,
|
||||
createMockStorage,
|
||||
createMockUpdateChain,
|
||||
databaseMock,
|
||||
defaultMockEnv,
|
||||
defaultMockUser,
|
||||
drizzleOrmMock,
|
||||
envMock,
|
||||
featureFlagsMock,
|
||||
loggerMock,
|
||||
type MockAuthResult,
|
||||
type MockFeatureFlags,
|
||||
type MockFetchResponse,
|
||||
type MockHybridAuthResult,
|
||||
type MockRedis,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user