diff --git a/apps/sim/app/api/logs/route.test.ts b/apps/sim/app/api/logs/route.test.ts index c9d09c87f..662e0971d 100644 --- a/apps/sim/app/api/logs/route.test.ts +++ b/apps/sim/app/api/logs/route.test.ts @@ -175,6 +175,7 @@ describe('Workflow Logs API Route', () => { id: 'workflowLogs.id', workflowId: 'workflowLogs.workflowId', level: 'workflowLogs.level', + trigger: 'workflowLogs.trigger', createdAt: 'workflowLogs.createdAt', message: 'workflowLogs.message', executionId: 'workflowLogs.executionId', @@ -470,5 +471,61 @@ describe('Workflow Logs API Route', () => { expect(data.data).toHaveLength(2) expect(data.data.every((log: any) => log.executionId === 'exec-1')).toBe(true) }) + + it('should filter logs by single trigger type', async () => { + const apiLogs = mockWorkflowLogs.filter((log) => log.trigger === 'api') + setupDatabaseMock({ logs: apiLogs }) + + const url = new URL('http://localhost:3000/api/logs?triggers=api') + const req = new Request(url.toString()) + + const { GET } = await import('./route') + const response = await GET(req as any) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toHaveLength(1) + expect(data.data[0].trigger).toBe('api') + }) + + it('should filter logs by multiple trigger types', async () => { + const manualAndApiLogs = mockWorkflowLogs.filter( + (log) => log.trigger === 'manual' || log.trigger === 'api' + ) + setupDatabaseMock({ logs: manualAndApiLogs }) + + const url = new URL('http://localhost:3000/api/logs?triggers=manual,api') + const req = new Request(url.toString()) + + const { GET } = await import('./route') + const response = await GET(req as any) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toHaveLength(3) + expect(data.data.every((log: any) => ['manual', 'api'].includes(log.trigger))).toBe(true) + }) + + it('should combine trigger filter with other filters', async () => { + const filteredLogs = mockWorkflowLogs.filter( + (log) => log.trigger === 'manual' && log.level === 'info' && log.workflowId === 'workflow-1' + ) + setupDatabaseMock({ logs: filteredLogs }) + + const url = new URL( + 'http://localhost:3000/api/logs?triggers=manual&level=info&workflowIds=workflow-1' + ) + const req = new Request(url.toString()) + + const { GET } = await import('./route') + const response = await GET(req as any) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.data).toHaveLength(1) + expect(data.data[0].trigger).toBe('manual') + expect(data.data[0].level).toBe('info') + expect(data.data[0].workflowId).toBe('workflow-1') + }) }) }) diff --git a/apps/sim/app/api/logs/route.ts b/apps/sim/app/api/logs/route.ts index c453de7e7..2f1626a68 100644 --- a/apps/sim/app/api/logs/route.ts +++ b/apps/sim/app/api/logs/route.ts @@ -17,6 +17,7 @@ const QueryParamsSchema = z.object({ offset: z.coerce.number().optional().default(0), level: z.string().optional(), workflowIds: z.string().optional(), // Comma-separated list of workflow IDs + triggers: z.string().optional(), // Comma-separated list of trigger types startDate: z.string().optional(), endDate: z.string().optional(), search: z.string().optional(), @@ -79,6 +80,18 @@ export async function GET(request: NextRequest) { conditions = and(conditions, eq(workflowLogs.level, params.level)) } + if (params.triggers) { + const triggerTypes = params.triggers.split(',').map((trigger) => trigger.trim()) + if (triggerTypes.length === 1) { + conditions = and(conditions, eq(workflowLogs.trigger, triggerTypes[0])) + } else { + conditions = and( + conditions, + or(...triggerTypes.map((trigger) => eq(workflowLogs.trigger, trigger))) + ) + } + } + if (params.startDate) { const startDate = new Date(params.startDate) conditions = and(conditions, gte(workflowLogs.createdAt, startDate)) diff --git a/apps/sim/app/w/logs/components/filters/components/trigger.tsx b/apps/sim/app/w/logs/components/filters/components/trigger.tsx new file mode 100644 index 000000000..760649d35 --- /dev/null +++ b/apps/sim/app/w/logs/components/filters/components/trigger.tsx @@ -0,0 +1,87 @@ +import { Check, ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useFilterStore } from '@/app/w/logs/stores/store' +import type { TriggerType } from '../../../stores/types' + +export default function Trigger() { + const { triggers, toggleTrigger, setTriggers } = useFilterStore() + const triggerOptions: { value: TriggerType; label: string; color?: string }[] = [ + { value: 'manual', label: 'Manual', color: 'bg-secondary' }, + { value: 'api', label: 'API', color: 'bg-blue-500' }, + { value: 'webhook', label: 'Webhook', color: 'bg-orange-500' }, + { value: 'schedule', label: 'Schedule', color: 'bg-green-500' }, + { value: 'chat', label: 'Chat', color: 'bg-purple-500' }, + ] + + // Get display text for the dropdown button + const getSelectedTriggersText = () => { + if (triggers.length === 0) return 'All triggers' + if (triggers.length === 1) { + const selected = triggerOptions.find((t) => t.value === triggers[0]) + return selected ? selected.label : 'All triggers' + } + return `${triggers.length} triggers selected` + } + + // Check if a trigger is selected + const isTriggerSelected = (trigger: TriggerType) => { + return triggers.includes(trigger) + } + + // Clear all selections + const clearSelections = () => { + setTriggers([]) + } + + return ( + + + + {getSelectedTriggersText()} + + + + + { + e.preventDefault() + clearSelections() + }} + className='flex cursor-pointer items-center justify-between p-2 text-sm' + > + All triggers + {triggers.length === 0 && } + + + + + {triggerOptions.map((triggerItem) => ( + { + e.preventDefault() + toggleTrigger(triggerItem.value) + }} + className='flex cursor-pointer items-center justify-between p-2 text-sm' + > + + {triggerItem.color && ( + + )} + {triggerItem.label} + + {isTriggerSelected(triggerItem.value) && } + + ))} + + + ) +} diff --git a/apps/sim/app/w/logs/components/filters/filters.tsx b/apps/sim/app/w/logs/components/filters/filters.tsx index 705ef2178..04be4538c 100644 --- a/apps/sim/app/w/logs/components/filters/filters.tsx +++ b/apps/sim/app/w/logs/components/filters/filters.tsx @@ -7,6 +7,7 @@ import { useUserSubscription } from '@/hooks/use-user-subscription' import FilterSection from './components/filter-section' import Level from './components/level' import Timeline from './components/timeline' +import Trigger from './components/trigger' import Workflow from './components/workflow' /** @@ -58,6 +59,9 @@ export function Filters() { {/* Level Filter */} } /> + {/* Trigger Filter */} + } /> + {/* Workflow Filter */} } /> diff --git a/apps/sim/app/w/logs/logs.tsx b/apps/sim/app/w/logs/logs.tsx index 39249ff70..b4dc0e1d3 100644 --- a/apps/sim/app/w/logs/logs.tsx +++ b/apps/sim/app/w/logs/logs.tsx @@ -73,6 +73,7 @@ export default function Logs() { level, workflowIds, searchQuery, + triggers, } = useFilterStore() const [selectedLog, setSelectedLog] = useState(null) @@ -225,6 +226,7 @@ export default function Logs() { level, workflowIds, searchQuery, + triggers, setPage, setHasMore, setLoading, diff --git a/apps/sim/app/w/logs/stores/store.ts b/apps/sim/app/w/logs/stores/store.ts index 27eb96475..fe717abe2 100644 --- a/apps/sim/app/w/logs/stores/store.ts +++ b/apps/sim/app/w/logs/stores/store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { FilterState } from './types' +import type { FilterState, TriggerType } from './types' export const useFilterStore = create((set, get) => ({ logs: [], @@ -7,6 +7,7 @@ export const useFilterStore = create((set, get) => ({ level: 'all', workflowIds: [], searchQuery: '', + triggers: [], loading: true, error: null, page: 1, @@ -57,6 +58,25 @@ export const useFilterStore = create((set, get) => ({ get().resetPagination() }, + setTriggers: (triggers: TriggerType[]) => { + set({ triggers }) + get().resetPagination() + }, + + toggleTrigger: (trigger: TriggerType) => { + const currentTriggers = [...get().triggers] + const index = currentTriggers.indexOf(trigger) + + if (index === -1) { + currentTriggers.push(trigger) + } else { + currentTriggers.splice(index, 1) + } + + set({ triggers: currentTriggers }) + get().resetPagination() + }, + setLoading: (loading) => set({ loading }), setError: (error) => set({ error }), @@ -71,7 +91,7 @@ export const useFilterStore = create((set, get) => ({ // Build query parameters for server-side filtering buildQueryParams: (page: number, limit: number) => { - const { timeRange, level, workflowIds, searchQuery } = get() + const { timeRange, level, workflowIds, searchQuery, triggers } = get() const params = new URLSearchParams() params.set('includeWorkflow', 'true') @@ -83,6 +103,11 @@ export const useFilterStore = create((set, get) => ({ params.set('level', level) } + // Add trigger filter + if (triggers.length > 0) { + params.set('triggers', triggers.join(',')) + } + // Add workflow filter if (workflowIds.length > 0) { params.set('workflowIds', workflowIds.join(',')) diff --git a/apps/sim/app/w/logs/stores/types.ts b/apps/sim/app/w/logs/stores/types.ts index 3f9b86013..86b9ce43f 100644 --- a/apps/sim/app/w/logs/stores/types.ts +++ b/apps/sim/app/w/logs/stores/types.ts @@ -83,6 +83,7 @@ export interface LogsResponse { export type TimeRange = 'Past 30 minutes' | 'Past hour' | 'Past 24 hours' | 'All time' export type LogLevel = 'error' | 'info' | 'all' +export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' export interface FilterState { // Original logs from API @@ -93,6 +94,7 @@ export interface FilterState { level: LogLevel workflowIds: string[] searchQuery: string + triggers: TriggerType[] // Loading state loading: boolean @@ -110,6 +112,8 @@ export interface FilterState { setWorkflowIds: (workflowIds: string[]) => void toggleWorkflowId: (workflowId: string) => void setSearchQuery: (query: string) => void + setTriggers: (triggers: TriggerType[]) => void + toggleTrigger: (trigger: TriggerType) => void setLoading: (loading: boolean) => void setError: (error: string | null) => void setPage: (page: number) => void