improvement(logs): added filtering by trigger type

This commit is contained in:
Waleed Latif
2025-05-26 13:10:50 -07:00
parent 38afdeb83a
commit 819c0cc8eb
7 changed files with 194 additions and 2 deletions

View File

@@ -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')
})
})
})

View File

@@ -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))

View File

@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm' className='w-full justify-between font-normal text-sm'>
{getSelectedTriggersText()}
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start' className='w-[180px]'>
<DropdownMenuItem
key='all'
onSelect={(e) => {
e.preventDefault()
clearSelections()
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
>
<span>All triggers</span>
{triggers.length === 0 && <Check className='h-4 w-4 text-primary' />}
</DropdownMenuItem>
<DropdownMenuSeparator />
{triggerOptions.map((triggerItem) => (
<DropdownMenuItem
key={triggerItem.value}
onSelect={(e) => {
e.preventDefault()
toggleTrigger(triggerItem.value)
}}
className='flex cursor-pointer items-center justify-between p-2 text-sm'
>
<div className='flex items-center'>
{triggerItem.color && (
<div className={`mr-2 h-2 w-2 rounded-full ${triggerItem.color}`} />
)}
{triggerItem.label}
</div>
{isTriggerSelected(triggerItem.value) && <Check className='h-4 w-4 text-primary' />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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 */}
<FilterSection title='Level' defaultOpen={true} content={<Level />} />
{/* Trigger Filter */}
<FilterSection title='Trigger' defaultOpen={true} content={<Trigger />} />
{/* Workflow Filter */}
<FilterSection title='Workflow' defaultOpen={true} content={<Workflow />} />
</div>

View File

@@ -73,6 +73,7 @@ export default function Logs() {
level,
workflowIds,
searchQuery,
triggers,
} = useFilterStore()
const [selectedLog, setSelectedLog] = useState<WorkflowLog | null>(null)
@@ -225,6 +226,7 @@ export default function Logs() {
level,
workflowIds,
searchQuery,
triggers,
setPage,
setHasMore,
setLoading,

View File

@@ -1,5 +1,5 @@
import { create } from 'zustand'
import type { FilterState } from './types'
import type { FilterState, TriggerType } from './types'
export const useFilterStore = create<FilterState>((set, get) => ({
logs: [],
@@ -7,6 +7,7 @@ export const useFilterStore = create<FilterState>((set, get) => ({
level: 'all',
workflowIds: [],
searchQuery: '',
triggers: [],
loading: true,
error: null,
page: 1,
@@ -57,6 +58,25 @@ export const useFilterStore = create<FilterState>((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<FilterState>((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<FilterState>((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(','))

View File

@@ -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