Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/i18n.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GH_PAT }}
fetch-depth: 0

- name: Setup Bun
Expand Down Expand Up @@ -53,7 +53,7 @@ jobs:
if: steps.changes.outputs.changes == 'true'
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GH_PAT }}
commit-message: "feat(i18n): update translations"
title: "🌐 Auto-update translations"
body: |
Expand Down
200 changes: 200 additions & 0 deletions apps/sim/app/api/logs/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { db } from '@sim/db'
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console/logger'

const logger = createLogger('LogsExportAPI')

export const revalidate = 0

const ExportParamsSchema = z.object({
level: z.string().optional(),
workflowIds: z.string().optional(),
folderIds: z.string().optional(),
triggers: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
workspaceId: z.string(),
})

function escapeCsv(value: any): string {
if (value === null || value === undefined) return ''
const str = String(value)
if (/[",\n]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}

export async function GET(request: NextRequest) {
try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const userId = session.user.id
const { searchParams } = new URL(request.url)
const params = ExportParamsSchema.parse(Object.fromEntries(searchParams.entries()))

const selectColumns = {
id: workflowExecutionLogs.id,
workflowId: workflowExecutionLogs.workflowId,
executionId: workflowExecutionLogs.executionId,
level: workflowExecutionLogs.level,
trigger: workflowExecutionLogs.trigger,
startedAt: workflowExecutionLogs.startedAt,
endedAt: workflowExecutionLogs.endedAt,
totalDurationMs: workflowExecutionLogs.totalDurationMs,
cost: workflowExecutionLogs.cost,
executionData: workflowExecutionLogs.executionData,
workflowName: workflow.name,
}

let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)

if (params.level && params.level !== 'all') {
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
}

if (params.workflowIds) {
const workflowIds = params.workflowIds.split(',').filter(Boolean)
if (workflowIds.length > 0) conditions = and(conditions, inArray(workflow.id, workflowIds))
}

if (params.folderIds) {
const folderIds = params.folderIds.split(',').filter(Boolean)
if (folderIds.length > 0) conditions = and(conditions, inArray(workflow.folderId, folderIds))
}

if (params.triggers) {
const triggers = params.triggers.split(',').filter(Boolean)
if (triggers.length > 0 && !triggers.includes('all')) {
conditions = and(conditions, inArray(workflowExecutionLogs.trigger, triggers))
}
}

if (params.startDate) {
conditions = and(conditions, gte(workflowExecutionLogs.startedAt, new Date(params.startDate)))
}
if (params.endDate) {
conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate)))
}

if (params.search) {
const term = `%${params.search}%`
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${term}`)
}
if (params.workflowName) {
const nameTerm = `%${params.workflowName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
}
if (params.folderName) {
const folderTerm = `%${params.folderName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
}

const header = [
'startedAt',
'level',
'workflow',
'trigger',
'durationMs',
'costTotal',
'workflowId',
'executionId',
'message',
'traceSpans',
].join(',')

const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
start: async (controller) => {
controller.enqueue(encoder.encode(`${header}\n`))
const pageSize = 1000
let offset = 0
try {
while (true) {
const rows = await db
.select(selectColumns)
.from(workflowExecutionLogs)
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
.innerJoin(
permissions,
and(
eq(permissions.entityType, 'workspace'),
eq(permissions.entityId, workflow.workspaceId),
eq(permissions.userId, userId)
)
)
.where(conditions)
.orderBy(desc(workflowExecutionLogs.startedAt))
.limit(pageSize)
.offset(offset)

if (!rows.length) break

for (const r of rows as any[]) {
let message = ''
let traces: any = null
try {
const ed = (r as any).executionData
if (ed) {
if (ed.finalOutput)
message =
typeof ed.finalOutput === 'string'
? ed.finalOutput
: JSON.stringify(ed.finalOutput)
if (ed.message) message = ed.message
if (ed.traceSpans) traces = ed.traceSpans
}
} catch {}
const line = [
escapeCsv(r.startedAt?.toISOString?.() || r.startedAt),
escapeCsv(r.level),
escapeCsv(r.workflowName),
escapeCsv(r.trigger),
escapeCsv(r.totalDurationMs ?? ''),
escapeCsv(r.cost?.total ?? r.cost?.value?.total ?? ''),
escapeCsv(r.workflowId ?? ''),
escapeCsv(r.executionId ?? ''),
escapeCsv(message),
escapeCsv(traces ? JSON.stringify(traces) : ''),
].join(',')
controller.enqueue(encoder.encode(`${line}\n`))
}

offset += pageSize
}
controller.close()
} catch (e: any) {
logger.error('Export stream error', { error: e?.message })
try {
controller.error(e)
} catch {}
}
},
})

const ts = new Date().toISOString().replace(/[:.]/g, '-')
const filename = `logs-${ts}.csv`

return new NextResponse(stream as any, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Cache-Control': 'no-cache',
},
})
} catch (error: any) {
logger.error('Export error', { error: error?.message })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
14 changes: 14 additions & 0 deletions apps/sim/app/api/logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const QueryParamsSchema = z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
search: z.string().optional(),
workflowName: z.string().optional(),
folderName: z.string().optional(),
workspaceId: z.string(),
})

Expand Down Expand Up @@ -155,6 +157,18 @@ export async function GET(request: NextRequest) {
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
}

// Filter by workflow name (from advanced search input)
if (params.workflowName) {
const nameTerm = `%${params.workflowName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
}

// Filter by folder name (best-effort text match when present on workflows)
if (params.folderName) {
const folderTerm = `%${params.folderName}%`
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
}

// Execute the query using the optimized join
const logs = await baseQuery
.where(conditions)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Button } from '@/components/ui/button'
import {
Command,
Expand All @@ -26,20 +27,27 @@ interface WorkflowOption {
}

export default function Workflow() {
const { workflowIds, toggleWorkflowId, setWorkflowIds } = useFilterStore()
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
const params = useParams()
const workspaceId = params?.workspaceId as string | undefined
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')

// Fetch all available workflows from the API
useEffect(() => {
const fetchWorkflows = async () => {
try {
setLoading(true)
const response = await fetch('/api/workflows')
const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''
const response = await fetch(`/api/workflows${query}`)
if (response.ok) {
const { data } = await response.json()
const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({
const scoped = Array.isArray(data)
? folderIds.length > 0
? data.filter((w: any) => (w.folderId ? folderIds.includes(w.folderId) : false))
: data
: []
const workflowOptions: WorkflowOption[] = scoped.map((workflow: any) => ({
id: workflow.id,
name: workflow.name,
color: workflow.color || '#3972F6',
Expand All @@ -54,7 +62,7 @@ export default function Workflow() {
}

fetchWorkflows()
}, [])
}, [workspaceId, folderIds])

const getSelectedWorkflowsText = () => {
if (workflowIds.length === 0) return 'All workflows'
Expand Down
Loading