mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-06 21:54:01 -05:00
feat(filtering): added the ability to filter logs by date and date range (#2639)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { ArrowUp, Bell, Library, MoreHorizontal, RefreshCw } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import {
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
type ComboboxOption,
|
||||
Loader,
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverScrollArea,
|
||||
PopoverTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { DatePicker } from '@/components/emcn/components/date-picker/date-picker'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
@@ -35,8 +37,31 @@ const TIME_RANGE_OPTIONS: ComboboxOption[] = [
|
||||
{ value: 'Past 7 days', label: 'Past 7 days' },
|
||||
{ value: 'Past 14 days', label: 'Past 14 days' },
|
||||
{ value: 'Past 30 days', label: 'Past 30 days' },
|
||||
{ value: 'Custom range', label: 'Custom range' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Formats a date string (YYYY-MM-DD) for display.
|
||||
*/
|
||||
function formatDateShort(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
]
|
||||
return `${months[date.getMonth()]} ${date.getDate()}`
|
||||
}
|
||||
|
||||
type ViewMode = 'logs' | 'dashboard'
|
||||
|
||||
interface LogsToolbarProps {
|
||||
@@ -153,7 +178,14 @@ export function LogsToolbar({
|
||||
setTriggers,
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
setDateRange,
|
||||
clearDateRange,
|
||||
} = useFilterStore()
|
||||
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false)
|
||||
const [previousTimeRange, setPreviousTimeRange] = useState(timeRange)
|
||||
const folders = useFolderStore((state) => state.folders)
|
||||
|
||||
const allWorkflows = useWorkflowRegistry((state) => state.workflows)
|
||||
@@ -269,8 +301,50 @@ export function LogsToolbar({
|
||||
|
||||
const timeDisplayLabel = useMemo(() => {
|
||||
if (timeRange === 'All time') return 'Time'
|
||||
if (timeRange === 'Custom range' && startDate && endDate) {
|
||||
return `${formatDateShort(startDate)} - ${formatDateShort(endDate)}`
|
||||
}
|
||||
if (timeRange === 'Custom range') return 'Custom range'
|
||||
return timeRange
|
||||
}, [timeRange])
|
||||
}, [timeRange, startDate, endDate])
|
||||
|
||||
/**
|
||||
* Handles time range selection from combobox.
|
||||
* Opens date picker when "Custom range" is selected.
|
||||
*/
|
||||
const handleTimeRangeChange = useCallback(
|
||||
(val: string) => {
|
||||
if (val === 'Custom range') {
|
||||
setPreviousTimeRange(timeRange)
|
||||
setDatePickerOpen(true)
|
||||
} else {
|
||||
clearDateRange()
|
||||
setTimeRange(val as typeof timeRange)
|
||||
}
|
||||
},
|
||||
[timeRange, setTimeRange, clearDateRange]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles date range selection from DatePicker.
|
||||
*/
|
||||
const handleDateRangeApply = useCallback(
|
||||
(start: string, end: string) => {
|
||||
setDateRange(start, end)
|
||||
setDatePickerOpen(false)
|
||||
},
|
||||
[setDateRange]
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles date picker cancel.
|
||||
*/
|
||||
const handleDatePickerCancel = useCallback(() => {
|
||||
if (timeRange === 'Custom range' && !startDate) {
|
||||
setTimeRange(previousTimeRange)
|
||||
}
|
||||
setDatePickerOpen(false)
|
||||
}, [timeRange, startDate, previousTimeRange, setTimeRange])
|
||||
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return (
|
||||
@@ -287,8 +361,8 @@ export function LogsToolbar({
|
||||
setWorkflowIds([])
|
||||
setFolderIds([])
|
||||
setTriggers([])
|
||||
setTimeRange('All time')
|
||||
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, setTimeRange])
|
||||
clearDateRange()
|
||||
}, [setLevel, setWorkflowIds, setFolderIds, setTriggers, clearDateRange])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-[19px]'>
|
||||
@@ -528,7 +602,7 @@ export function LogsToolbar({
|
||||
<Combobox
|
||||
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
|
||||
value={timeRange}
|
||||
onChange={(val) => setTimeRange(val as typeof timeRange)}
|
||||
onChange={handleTimeRangeChange}
|
||||
placeholder='All time'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
@@ -636,18 +710,42 @@ export function LogsToolbar({
|
||||
/>
|
||||
|
||||
{/* Timeline Filter */}
|
||||
<Combobox
|
||||
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
|
||||
value={timeRange}
|
||||
onChange={(val) => setTimeRange(val as typeof timeRange)}
|
||||
placeholder='Time'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>{timeDisplayLabel}</span>
|
||||
}
|
||||
size='sm'
|
||||
align='end'
|
||||
className='h-[32px] w-[120px] rounded-[6px]'
|
||||
/>
|
||||
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<div>
|
||||
<Combobox
|
||||
options={TIME_RANGE_OPTIONS as unknown as ComboboxOption[]}
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
placeholder='Time'
|
||||
overlayContent={
|
||||
<span className='truncate text-[var(--text-primary)]'>
|
||||
{timeDisplayLabel}
|
||||
</span>
|
||||
}
|
||||
size='sm'
|
||||
align='end'
|
||||
className='h-[32px] w-[120px] rounded-[6px]'
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side='bottom'
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
collisionPadding={16}
|
||||
className='w-auto p-0'
|
||||
>
|
||||
<DatePicker
|
||||
mode='range'
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onRangeChange={handleDateRangeApply}
|
||||
onCancel={handleDatePickerCancel}
|
||||
inline
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import { useDashboardLogs, useLogDetail, useLogsList } from '@/hooks/queries/logs'
|
||||
@@ -30,6 +30,8 @@ export default function Logs() {
|
||||
setWorkspaceId,
|
||||
initializeFromURL,
|
||||
timeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
@@ -72,6 +74,8 @@ export default function Logs() {
|
||||
const logFilters = useMemo(
|
||||
() => ({
|
||||
timeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
@@ -79,7 +83,7 @@ export default function Logs() {
|
||||
searchQuery: debouncedSearchQuery,
|
||||
limit: LOGS_PER_PAGE,
|
||||
}),
|
||||
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
|
||||
[timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
|
||||
)
|
||||
|
||||
const logsQuery = useLogsList(workspaceId, logFilters, {
|
||||
@@ -90,13 +94,15 @@ export default function Logs() {
|
||||
const dashboardFilters = useMemo(
|
||||
() => ({
|
||||
timeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
triggers,
|
||||
searchQuery: debouncedSearchQuery,
|
||||
}),
|
||||
[timeRange, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
|
||||
[timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, debouncedSearchQuery]
|
||||
)
|
||||
|
||||
const dashboardLogsQuery = useDashboardLogs(workspaceId, dashboardFilters, {
|
||||
@@ -261,9 +267,14 @@ export default function Logs() {
|
||||
if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(','))
|
||||
if (folderIds.length > 0) params.set('folderIds', folderIds.join(','))
|
||||
|
||||
const startDate = getStartDateFromTimeRange(timeRange)
|
||||
if (startDate) {
|
||||
params.set('startDate', startDate.toISOString())
|
||||
const computedStartDate = getStartDateFromTimeRange(timeRange, startDate)
|
||||
if (computedStartDate) {
|
||||
params.set('startDate', computedStartDate.toISOString())
|
||||
}
|
||||
|
||||
const computedEndDate = getEndDateFromTimeRange(timeRange, endDate)
|
||||
if (computedEndDate) {
|
||||
params.set('endDate', computedEndDate.toISOString())
|
||||
}
|
||||
|
||||
const parsed = parseQuery(debouncedSearchQuery)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
import type { LogsResponse, TimeRange, WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
@@ -16,6 +16,8 @@ export const logKeys = {
|
||||
|
||||
interface LogFilters {
|
||||
timeRange: TimeRange
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
level: string
|
||||
workflowIds: string[]
|
||||
folderIds: string[]
|
||||
@@ -45,11 +47,16 @@ function applyFilterParams(params: URLSearchParams, filters: Omit<LogFilters, 'l
|
||||
params.set('folderIds', filters.folderIds.join(','))
|
||||
}
|
||||
|
||||
const startDate = getStartDateFromTimeRange(filters.timeRange)
|
||||
const startDate = getStartDateFromTimeRange(filters.timeRange, filters.startDate)
|
||||
if (startDate) {
|
||||
params.set('startDate', startDate.toISOString())
|
||||
}
|
||||
|
||||
const endDate = getEndDateFromTimeRange(filters.timeRange, filters.endDate)
|
||||
if (endDate) {
|
||||
params.set('endDate', endDate.toISOString())
|
||||
}
|
||||
|
||||
if (filters.searchQuery.trim()) {
|
||||
const parsedQuery = parseQuery(filters.searchQuery.trim())
|
||||
const searchParams = queryToApiParams(parsedQuery)
|
||||
|
||||
@@ -29,13 +29,24 @@ export type LogFilterParams = z.infer<typeof LogFilterParamsSchema>
|
||||
|
||||
/**
|
||||
* Calculates start date from a time range string.
|
||||
* Returns null for 'All time' to indicate no date filtering.
|
||||
* Returns null for 'All time' or 'Custom range' to indicate the dates
|
||||
* should be handled separately.
|
||||
* @param timeRange - The time range option selected by the user
|
||||
* @param startDate - Optional start date (YYYY-MM-DD) for custom range
|
||||
* @returns Date object for the start of the range, or null for 'All time'
|
||||
*/
|
||||
export function getStartDateFromTimeRange(timeRange: TimeRange): Date | null {
|
||||
export function getStartDateFromTimeRange(timeRange: TimeRange, startDate?: string): Date | null {
|
||||
if (timeRange === 'All time') return null
|
||||
|
||||
if (timeRange === 'Custom range') {
|
||||
if (startDate) {
|
||||
const date = new Date(startDate)
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
switch (timeRange) {
|
||||
@@ -62,6 +73,26 @@ export function getStartDateFromTimeRange(timeRange: TimeRange): Date | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the end date for a time range.
|
||||
* Returns null for preset ranges (uses current time as implicit end).
|
||||
* Returns end of day for custom ranges.
|
||||
* @param timeRange - The time range option selected by the user
|
||||
* @param endDate - Optional end date (YYYY-MM-DD) for custom range
|
||||
* @returns Date object for the end of the range, or null for preset ranges
|
||||
*/
|
||||
export function getEndDateFromTimeRange(timeRange: TimeRange, endDate?: string): Date | null {
|
||||
if (timeRange !== 'Custom range') return null
|
||||
|
||||
if (endDate) {
|
||||
const date = new Date(endDate)
|
||||
date.setHours(23, 59, 59, 999)
|
||||
return date
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
type ComparisonOperator = '=' | '>' | '<' | '>=' | '<=' | '!='
|
||||
|
||||
function buildWorkflowIdsCondition(workflowIds: string): SQL | undefined {
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
/**
|
||||
* Tests for query language parser for logs search
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseQuery, queryToApiParams } from '@/lib/logs/query-parser'
|
||||
|
||||
describe('parseQuery', () => {
|
||||
describe('empty and whitespace input', () => {
|
||||
test('should handle empty string', () => {
|
||||
it.concurrent('should handle empty string', () => {
|
||||
const result = parseQuery('')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('')
|
||||
})
|
||||
|
||||
test('should handle whitespace only', () => {
|
||||
it.concurrent('should handle whitespace only', () => {
|
||||
const result = parseQuery(' ')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
@@ -19,14 +25,14 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('simple text search', () => {
|
||||
test('should parse plain text as textSearch', () => {
|
||||
it.concurrent('should parse plain text as textSearch', () => {
|
||||
const result = parseQuery('hello world')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('hello world')
|
||||
})
|
||||
|
||||
test('should preserve text case', () => {
|
||||
it.concurrent('should preserve text case', () => {
|
||||
const result = parseQuery('Hello World')
|
||||
|
||||
expect(result.textSearch).toBe('Hello World')
|
||||
@@ -34,7 +40,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('level filter', () => {
|
||||
test('should parse level:error filter', () => {
|
||||
it.concurrent('should parse level:error filter', () => {
|
||||
const result = parseQuery('level:error')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -43,7 +49,7 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].operator).toBe('=')
|
||||
})
|
||||
|
||||
test('should parse level:info filter', () => {
|
||||
it.concurrent('should parse level:info filter', () => {
|
||||
const result = parseQuery('level:info')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -53,7 +59,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('status filter (alias for level)', () => {
|
||||
test('should parse status:error filter', () => {
|
||||
it.concurrent('should parse status:error filter', () => {
|
||||
const result = parseQuery('status:error')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -63,7 +69,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('workflow filter', () => {
|
||||
test('should parse workflow filter with quoted value', () => {
|
||||
it.concurrent('should parse workflow filter with quoted value', () => {
|
||||
const result = parseQuery('workflow:"my-workflow"')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -71,7 +77,7 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe('my-workflow')
|
||||
})
|
||||
|
||||
test('should parse workflow filter with unquoted value', () => {
|
||||
it.concurrent('should parse workflow filter with unquoted value', () => {
|
||||
const result = parseQuery('workflow:test-workflow')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -81,7 +87,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('trigger filter', () => {
|
||||
test('should parse trigger:api filter', () => {
|
||||
it.concurrent('should parse trigger:api filter', () => {
|
||||
const result = parseQuery('trigger:api')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -89,25 +95,25 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe('api')
|
||||
})
|
||||
|
||||
test('should parse trigger:webhook filter', () => {
|
||||
it.concurrent('should parse trigger:webhook filter', () => {
|
||||
const result = parseQuery('trigger:webhook')
|
||||
|
||||
expect(result.filters[0].value).toBe('webhook')
|
||||
})
|
||||
|
||||
test('should parse trigger:schedule filter', () => {
|
||||
it.concurrent('should parse trigger:schedule filter', () => {
|
||||
const result = parseQuery('trigger:schedule')
|
||||
|
||||
expect(result.filters[0].value).toBe('schedule')
|
||||
})
|
||||
|
||||
test('should parse trigger:manual filter', () => {
|
||||
it.concurrent('should parse trigger:manual filter', () => {
|
||||
const result = parseQuery('trigger:manual')
|
||||
|
||||
expect(result.filters[0].value).toBe('manual')
|
||||
})
|
||||
|
||||
test('should parse trigger:chat filter', () => {
|
||||
it.concurrent('should parse trigger:chat filter', () => {
|
||||
const result = parseQuery('trigger:chat')
|
||||
|
||||
expect(result.filters[0].value).toBe('chat')
|
||||
@@ -115,7 +121,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('cost filter with operators', () => {
|
||||
test('should parse cost:>0.01 filter', () => {
|
||||
it.concurrent('should parse cost:>0.01 filter', () => {
|
||||
const result = parseQuery('cost:>0.01')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -124,35 +130,35 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe(0.01)
|
||||
})
|
||||
|
||||
test('should parse cost:<0.005 filter', () => {
|
||||
it.concurrent('should parse cost:<0.005 filter', () => {
|
||||
const result = parseQuery('cost:<0.005')
|
||||
|
||||
expect(result.filters[0].operator).toBe('<')
|
||||
expect(result.filters[0].value).toBe(0.005)
|
||||
})
|
||||
|
||||
test('should parse cost:>=0.05 filter', () => {
|
||||
it.concurrent('should parse cost:>=0.05 filter', () => {
|
||||
const result = parseQuery('cost:>=0.05')
|
||||
|
||||
expect(result.filters[0].operator).toBe('>=')
|
||||
expect(result.filters[0].value).toBe(0.05)
|
||||
})
|
||||
|
||||
test('should parse cost:<=0.1 filter', () => {
|
||||
it.concurrent('should parse cost:<=0.1 filter', () => {
|
||||
const result = parseQuery('cost:<=0.1')
|
||||
|
||||
expect(result.filters[0].operator).toBe('<=')
|
||||
expect(result.filters[0].value).toBe(0.1)
|
||||
})
|
||||
|
||||
test('should parse cost:!=0 filter', () => {
|
||||
it.concurrent('should parse cost:!=0 filter', () => {
|
||||
const result = parseQuery('cost:!=0')
|
||||
|
||||
expect(result.filters[0].operator).toBe('!=')
|
||||
expect(result.filters[0].value).toBe(0)
|
||||
})
|
||||
|
||||
test('should parse cost:=0 filter', () => {
|
||||
it.concurrent('should parse cost:=0 filter', () => {
|
||||
const result = parseQuery('cost:=0')
|
||||
|
||||
expect(result.filters[0].operator).toBe('=')
|
||||
@@ -161,7 +167,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('duration filter', () => {
|
||||
test('should parse duration:>5000 (ms) filter', () => {
|
||||
it.concurrent('should parse duration:>5000 (ms) filter', () => {
|
||||
const result = parseQuery('duration:>5000')
|
||||
|
||||
expect(result.filters[0].field).toBe('duration')
|
||||
@@ -169,19 +175,19 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe(5000)
|
||||
})
|
||||
|
||||
test('should parse duration with ms suffix', () => {
|
||||
it.concurrent('should parse duration with ms suffix', () => {
|
||||
const result = parseQuery('duration:>500ms')
|
||||
|
||||
expect(result.filters[0].value).toBe(500)
|
||||
})
|
||||
|
||||
test('should parse duration with s suffix (converts to ms)', () => {
|
||||
it.concurrent('should parse duration with s suffix (converts to ms)', () => {
|
||||
const result = parseQuery('duration:>5s')
|
||||
|
||||
expect(result.filters[0].value).toBe(5000)
|
||||
})
|
||||
|
||||
test('should parse duration:<1s filter', () => {
|
||||
it.concurrent('should parse duration:<1s filter', () => {
|
||||
const result = parseQuery('duration:<1s')
|
||||
|
||||
expect(result.filters[0].operator).toBe('<')
|
||||
@@ -190,7 +196,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('date filter', () => {
|
||||
test('should parse date:today filter', () => {
|
||||
it.concurrent('should parse date:today filter', () => {
|
||||
const result = parseQuery('date:today')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -198,15 +204,67 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe('today')
|
||||
})
|
||||
|
||||
test('should parse date:yesterday filter', () => {
|
||||
it.concurrent('should parse date:yesterday filter', () => {
|
||||
const result = parseQuery('date:yesterday')
|
||||
|
||||
expect(result.filters[0].value).toBe('yesterday')
|
||||
})
|
||||
|
||||
it.concurrent('should parse date:this-week filter', () => {
|
||||
const result = parseQuery('date:this-week')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('date')
|
||||
expect(result.filters[0].value).toBe('this-week')
|
||||
})
|
||||
|
||||
it.concurrent('should parse date:last-week filter', () => {
|
||||
const result = parseQuery('date:last-week')
|
||||
|
||||
expect(result.filters[0].value).toBe('last-week')
|
||||
})
|
||||
|
||||
it.concurrent('should parse date:this-month filter', () => {
|
||||
const result = parseQuery('date:this-month')
|
||||
|
||||
expect(result.filters[0].value).toBe('this-month')
|
||||
})
|
||||
|
||||
it.concurrent('should parse year-only format (YYYY)', () => {
|
||||
const result = parseQuery('date:2024')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('date')
|
||||
expect(result.filters[0].value).toBe('2024')
|
||||
})
|
||||
|
||||
it.concurrent('should parse month-only format (YYYY-MM)', () => {
|
||||
const result = parseQuery('date:2024-12')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('date')
|
||||
expect(result.filters[0].value).toBe('2024-12')
|
||||
})
|
||||
|
||||
it.concurrent('should parse full date format (YYYY-MM-DD)', () => {
|
||||
const result = parseQuery('date:2024-12-25')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('date')
|
||||
expect(result.filters[0].value).toBe('2024-12-25')
|
||||
})
|
||||
|
||||
it.concurrent('should parse date range format (YYYY-MM-DD..YYYY-MM-DD)', () => {
|
||||
const result = parseQuery('date:2024-01-01..2024-01-15')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.filters[0].field).toBe('date')
|
||||
expect(result.filters[0].value).toBe('2024-01-01..2024-01-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('folder filter', () => {
|
||||
test('should parse folder filter with quoted value', () => {
|
||||
it.concurrent('should parse folder filter with quoted value', () => {
|
||||
const result = parseQuery('folder:"My Folder"')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -216,7 +274,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('ID filters', () => {
|
||||
test('should parse executionId filter', () => {
|
||||
it.concurrent('should parse executionId filter', () => {
|
||||
const result = parseQuery('executionId:exec-123-abc')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -224,7 +282,7 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe('exec-123-abc')
|
||||
})
|
||||
|
||||
test('should parse workflowId filter', () => {
|
||||
it.concurrent('should parse workflowId filter', () => {
|
||||
const result = parseQuery('workflowId:wf-456-def')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -232,7 +290,7 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe('wf-456-def')
|
||||
})
|
||||
|
||||
test('should parse execution filter (alias)', () => {
|
||||
it.concurrent('should parse execution filter (alias)', () => {
|
||||
const result = parseQuery('execution:exec-789')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -240,7 +298,7 @@ describe('parseQuery', () => {
|
||||
expect(result.filters[0].value).toBe('exec-789')
|
||||
})
|
||||
|
||||
test('should parse id filter', () => {
|
||||
it.concurrent('should parse id filter', () => {
|
||||
const result = parseQuery('id:some-id-123')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -249,7 +307,7 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('combined filters and text', () => {
|
||||
test('should parse multiple filters', () => {
|
||||
it.concurrent('should parse multiple filters', () => {
|
||||
const result = parseQuery('level:error trigger:api')
|
||||
|
||||
expect(result.filters).toHaveLength(2)
|
||||
@@ -258,7 +316,7 @@ describe('parseQuery', () => {
|
||||
expect(result.textSearch).toBe('')
|
||||
})
|
||||
|
||||
test('should parse filters with text search', () => {
|
||||
it.concurrent('should parse filters with text search', () => {
|
||||
const result = parseQuery('level:error some search text')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
@@ -266,14 +324,14 @@ describe('parseQuery', () => {
|
||||
expect(result.textSearch).toBe('some search text')
|
||||
})
|
||||
|
||||
test('should parse text before and after filters', () => {
|
||||
it.concurrent('should parse text before and after filters', () => {
|
||||
const result = parseQuery('before level:error after')
|
||||
|
||||
expect(result.filters).toHaveLength(1)
|
||||
expect(result.textSearch).toBe('before after')
|
||||
})
|
||||
|
||||
test('should parse complex query with multiple filters and text', () => {
|
||||
it.concurrent('should parse complex query with multiple filters and text', () => {
|
||||
const result = parseQuery(
|
||||
'level:error trigger:api cost:>0.01 workflow:"my-workflow" search text'
|
||||
)
|
||||
@@ -284,21 +342,21 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('invalid filters', () => {
|
||||
test('should treat unknown field as text', () => {
|
||||
it.concurrent('should treat unknown field as text', () => {
|
||||
const result = parseQuery('unknownfield:value')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('unknownfield:value')
|
||||
})
|
||||
|
||||
test('should handle invalid number for cost', () => {
|
||||
it.concurrent('should handle invalid number for cost', () => {
|
||||
const result = parseQuery('cost:>abc')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
expect(result.textSearch).toBe('cost:>abc')
|
||||
})
|
||||
|
||||
test('should handle invalid number for duration', () => {
|
||||
it.concurrent('should handle invalid number for duration', () => {
|
||||
const result = parseQuery('duration:>notanumber')
|
||||
|
||||
expect(result.filters).toHaveLength(0)
|
||||
@@ -307,77 +365,77 @@ describe('parseQuery', () => {
|
||||
})
|
||||
|
||||
describe('queryToApiParams', () => {
|
||||
test('should return empty object for empty query', () => {
|
||||
it.concurrent('should return empty object for empty query', () => {
|
||||
const parsed = parseQuery('')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(Object.keys(params)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('should set search param for text search', () => {
|
||||
it.concurrent('should set search param for text search', () => {
|
||||
const parsed = parseQuery('hello world')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.search).toBe('hello world')
|
||||
})
|
||||
|
||||
test('should set level param for level filter', () => {
|
||||
it.concurrent('should set level param for level filter', () => {
|
||||
const parsed = parseQuery('level:error')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.level).toBe('error')
|
||||
})
|
||||
|
||||
test('should combine multiple level filters with comma', () => {
|
||||
it.concurrent('should combine multiple level filters with comma', () => {
|
||||
const parsed = parseQuery('level:error level:info')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.level).toBe('error,info')
|
||||
})
|
||||
|
||||
test('should set triggers param for trigger filter', () => {
|
||||
it.concurrent('should set triggers param for trigger filter', () => {
|
||||
const parsed = parseQuery('trigger:api')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.triggers).toBe('api')
|
||||
})
|
||||
|
||||
test('should combine multiple trigger filters', () => {
|
||||
it.concurrent('should combine multiple trigger filters', () => {
|
||||
const parsed = parseQuery('trigger:api trigger:webhook')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.triggers).toBe('api,webhook')
|
||||
})
|
||||
|
||||
test('should set workflowName param for workflow filter', () => {
|
||||
it.concurrent('should set workflowName param for workflow filter', () => {
|
||||
const parsed = parseQuery('workflow:"my-workflow"')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.workflowName).toBe('my-workflow')
|
||||
})
|
||||
|
||||
test('should set folderName param for folder filter', () => {
|
||||
it.concurrent('should set folderName param for folder filter', () => {
|
||||
const parsed = parseQuery('folder:"My Folder"')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.folderName).toBe('My Folder')
|
||||
})
|
||||
|
||||
test('should set workflowIds param for workflowId filter', () => {
|
||||
it.concurrent('should set workflowIds param for workflowId filter', () => {
|
||||
const parsed = parseQuery('workflowId:wf-123')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.workflowIds).toBe('wf-123')
|
||||
})
|
||||
|
||||
test('should set executionId param for executionId filter', () => {
|
||||
it.concurrent('should set executionId param for executionId filter', () => {
|
||||
const parsed = parseQuery('executionId:exec-456')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.executionId).toBe('exec-456')
|
||||
})
|
||||
|
||||
test('should set cost params with operator', () => {
|
||||
it.concurrent('should set cost params with operator', () => {
|
||||
const parsed = parseQuery('cost:>0.01')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
@@ -385,7 +443,7 @@ describe('queryToApiParams', () => {
|
||||
expect(params.costValue).toBe('0.01')
|
||||
})
|
||||
|
||||
test('should set duration params with operator', () => {
|
||||
it.concurrent('should set duration params with operator', () => {
|
||||
const parsed = parseQuery('duration:>5s')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
@@ -393,7 +451,7 @@ describe('queryToApiParams', () => {
|
||||
expect(params.durationValue).toBe('5000')
|
||||
})
|
||||
|
||||
test('should set startDate for date:today', () => {
|
||||
it('should set startDate for date:today', () => {
|
||||
const parsed = parseQuery('date:today')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
@@ -404,7 +462,7 @@ describe('queryToApiParams', () => {
|
||||
expect(startDate.getTime()).toBe(today.getTime())
|
||||
})
|
||||
|
||||
test('should set startDate and endDate for date:yesterday', () => {
|
||||
it('should set startDate and endDate for date:yesterday', () => {
|
||||
const parsed = parseQuery('date:yesterday')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
@@ -412,7 +470,112 @@ describe('queryToApiParams', () => {
|
||||
expect(params.endDate).toBeDefined()
|
||||
})
|
||||
|
||||
test('should combine execution filter with text search', () => {
|
||||
it('should set startDate for date:this-week', () => {
|
||||
const parsed = parseQuery('date:this-week')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
const startDate = new Date(params.startDate)
|
||||
expect(startDate.getDay()).toBe(0)
|
||||
})
|
||||
|
||||
it('should set startDate and endDate for date:last-week', () => {
|
||||
const parsed = parseQuery('date:last-week')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
expect(params.endDate).toBeDefined()
|
||||
})
|
||||
|
||||
it('should set startDate for date:this-month', () => {
|
||||
const parsed = parseQuery('date:this-month')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
const startDate = new Date(params.startDate)
|
||||
expect(startDate.getDate()).toBe(1)
|
||||
})
|
||||
|
||||
it.concurrent('should set startDate and endDate for year-only (date:2024)', () => {
|
||||
const parsed = parseQuery('date:2024')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
expect(params.endDate).toBeDefined()
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
|
||||
expect(startDate.getFullYear()).toBe(2024)
|
||||
expect(startDate.getMonth()).toBe(0)
|
||||
expect(startDate.getDate()).toBe(1)
|
||||
|
||||
expect(endDate.getFullYear()).toBe(2024)
|
||||
expect(endDate.getMonth()).toBe(11)
|
||||
expect(endDate.getDate()).toBe(31)
|
||||
})
|
||||
|
||||
it.concurrent('should set startDate and endDate for month-only (date:2024-12)', () => {
|
||||
const parsed = parseQuery('date:2024-12')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
expect(params.endDate).toBeDefined()
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
|
||||
expect(startDate.getFullYear()).toBe(2024)
|
||||
expect(startDate.getMonth()).toBe(11)
|
||||
expect(startDate.getDate()).toBe(1)
|
||||
|
||||
expect(endDate.getFullYear()).toBe(2024)
|
||||
expect(endDate.getMonth()).toBe(11)
|
||||
expect(endDate.getDate()).toBe(31)
|
||||
})
|
||||
|
||||
it.concurrent('should set startDate and endDate for full date (date:2024-12-25)', () => {
|
||||
const parsed = parseQuery('date:2024-12-25')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
expect(params.endDate).toBeDefined()
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
|
||||
expect(startDate.getFullYear()).toBe(2024)
|
||||
expect(startDate.getMonth()).toBe(11)
|
||||
expect(startDate.getDate()).toBe(25)
|
||||
|
||||
expect(endDate.getFullYear()).toBe(2024)
|
||||
expect(endDate.getMonth()).toBe(11)
|
||||
expect(endDate.getDate()).toBe(25)
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should set startDate and endDate for date range (date:2024-01-01..2024-01-15)',
|
||||
() => {
|
||||
const parsed = parseQuery('date:2024-01-01..2024-01-15')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
expect(params.startDate).toBeDefined()
|
||||
expect(params.endDate).toBeDefined()
|
||||
|
||||
const startDate = new Date(params.startDate)
|
||||
const endDate = new Date(params.endDate)
|
||||
|
||||
expect(startDate.getFullYear()).toBe(2024)
|
||||
expect(startDate.getMonth()).toBe(0)
|
||||
expect(startDate.getDate()).toBe(1)
|
||||
|
||||
expect(endDate.getFullYear()).toBe(2024)
|
||||
expect(endDate.getMonth()).toBe(0)
|
||||
expect(endDate.getDate()).toBe(15)
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should combine execution filter with text search', () => {
|
||||
const parsed = {
|
||||
filters: [
|
||||
{
|
||||
@@ -429,7 +592,7 @@ describe('queryToApiParams', () => {
|
||||
expect(params.search).toBe('some text exec-123')
|
||||
})
|
||||
|
||||
test('should handle complex query with all params', () => {
|
||||
it.concurrent('should handle complex query with all params', () => {
|
||||
const parsed = parseQuery('level:error trigger:api cost:>0.01 workflow:"test"')
|
||||
const params = queryToApiParams(parsed)
|
||||
|
||||
|
||||
@@ -200,19 +200,90 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
|
||||
break
|
||||
|
||||
case 'date':
|
||||
if (filter.operator === '=' && filter.value === 'today') {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
params.startDate = today.toISOString()
|
||||
} else if (filter.operator === '=' && filter.value === 'yesterday') {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
yesterday.setHours(0, 0, 0, 0)
|
||||
params.startDate = yesterday.toISOString()
|
||||
if (filter.operator === '=') {
|
||||
const dateValue = String(filter.value)
|
||||
|
||||
const endOfYesterday = new Date(yesterday)
|
||||
endOfYesterday.setHours(23, 59, 59, 999)
|
||||
params.endDate = endOfYesterday.toISOString()
|
||||
// Handle range syntax: date:2024-01-01..2024-01-15
|
||||
if (dateValue.includes('..')) {
|
||||
const [startStr, endStr] = dateValue.split('..')
|
||||
if (startStr && /^\d{4}-\d{2}-\d{2}$/.test(startStr)) {
|
||||
const [year, month, day] = startStr.split('-').map(Number)
|
||||
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||
params.startDate = startDate.toISOString()
|
||||
}
|
||||
if (endStr && /^\d{4}-\d{2}-\d{2}$/.test(endStr)) {
|
||||
const [year, month, day] = endStr.split('-').map(Number)
|
||||
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999)
|
||||
params.endDate = endDate.toISOString()
|
||||
}
|
||||
} else if (dateValue === 'today') {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
params.startDate = today.toISOString()
|
||||
} else if (dateValue === 'yesterday') {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
yesterday.setHours(0, 0, 0, 0)
|
||||
params.startDate = yesterday.toISOString()
|
||||
|
||||
const endOfYesterday = new Date(yesterday)
|
||||
endOfYesterday.setHours(23, 59, 59, 999)
|
||||
params.endDate = endOfYesterday.toISOString()
|
||||
} else if (dateValue === 'this-week') {
|
||||
const now = new Date()
|
||||
const dayOfWeek = now.getDay()
|
||||
const startOfWeek = new Date(now)
|
||||
startOfWeek.setDate(now.getDate() - dayOfWeek)
|
||||
startOfWeek.setHours(0, 0, 0, 0)
|
||||
params.startDate = startOfWeek.toISOString()
|
||||
} else if (dateValue === 'last-week') {
|
||||
const now = new Date()
|
||||
const dayOfWeek = now.getDay()
|
||||
const startOfThisWeek = new Date(now)
|
||||
startOfThisWeek.setDate(now.getDate() - dayOfWeek)
|
||||
startOfThisWeek.setHours(0, 0, 0, 0)
|
||||
|
||||
const startOfLastWeek = new Date(startOfThisWeek)
|
||||
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7)
|
||||
params.startDate = startOfLastWeek.toISOString()
|
||||
|
||||
const endOfLastWeek = new Date(startOfThisWeek)
|
||||
endOfLastWeek.setMilliseconds(-1)
|
||||
params.endDate = endOfLastWeek.toISOString()
|
||||
} else if (dateValue === 'this-month') {
|
||||
const now = new Date()
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
startOfMonth.setHours(0, 0, 0, 0)
|
||||
params.startDate = startOfMonth.toISOString()
|
||||
} else if (/^\d{4}$/.test(dateValue)) {
|
||||
// Year only: YYYY (e.g., 2024)
|
||||
const year = Number.parseInt(dateValue, 10)
|
||||
const startOfYear = new Date(year, 0, 1)
|
||||
startOfYear.setHours(0, 0, 0, 0)
|
||||
params.startDate = startOfYear.toISOString()
|
||||
|
||||
const endOfYear = new Date(year, 11, 31)
|
||||
endOfYear.setHours(23, 59, 59, 999)
|
||||
params.endDate = endOfYear.toISOString()
|
||||
} else if (/^\d{4}-\d{2}$/.test(dateValue)) {
|
||||
// Month only: YYYY-MM (e.g., 2024-12)
|
||||
const [year, month] = dateValue.split('-').map(Number)
|
||||
const startOfMonth = new Date(year, month - 1, 1)
|
||||
startOfMonth.setHours(0, 0, 0, 0)
|
||||
params.startDate = startOfMonth.toISOString()
|
||||
|
||||
const endOfMonth = new Date(year, month, 0) // Day 0 of next month = last day of this month
|
||||
endOfMonth.setHours(23, 59, 59, 999)
|
||||
params.endDate = endOfMonth.toISOString()
|
||||
} else if (/^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
|
||||
// Parse as a single date (YYYY-MM-DD) using local timezone
|
||||
const [year, month, day] = dateValue.split('-').map(Number)
|
||||
const startDate = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||
params.startDate = startDate.toISOString()
|
||||
|
||||
const endDate = new Date(year, month - 1, day, 23, 59, 59, 999)
|
||||
params.endDate = endDate.toISOString()
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
/**
|
||||
* Tests for search suggestions functionality in logs search
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FILTER_DEFINITIONS,
|
||||
type FolderData,
|
||||
@@ -8,7 +13,7 @@ import {
|
||||
} from '@/lib/logs/search-suggestions'
|
||||
|
||||
describe('FILTER_DEFINITIONS', () => {
|
||||
test('should have level filter definition', () => {
|
||||
it.concurrent('should have level filter definition', () => {
|
||||
const levelFilter = FILTER_DEFINITIONS.find((f) => f.key === 'level')
|
||||
|
||||
expect(levelFilter).toBeDefined()
|
||||
@@ -18,7 +23,7 @@ describe('FILTER_DEFINITIONS', () => {
|
||||
expect(levelFilter?.options.map((o) => o.value)).toContain('info')
|
||||
})
|
||||
|
||||
test('should have cost filter definition with multiple options', () => {
|
||||
it.concurrent('should have cost filter definition with multiple options', () => {
|
||||
const costFilter = FILTER_DEFINITIONS.find((f) => f.key === 'cost')
|
||||
|
||||
expect(costFilter).toBeDefined()
|
||||
@@ -28,7 +33,7 @@ describe('FILTER_DEFINITIONS', () => {
|
||||
expect(costFilter?.options.map((o) => o.value)).toContain('<0.005')
|
||||
})
|
||||
|
||||
test('should have date filter definition', () => {
|
||||
it.concurrent('should have date filter definition', () => {
|
||||
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
|
||||
|
||||
expect(dateFilter).toBeDefined()
|
||||
@@ -37,7 +42,39 @@ describe('FILTER_DEFINITIONS', () => {
|
||||
expect(dateFilter?.options.map((o) => o.value)).toContain('yesterday')
|
||||
})
|
||||
|
||||
test('should have duration filter definition', () => {
|
||||
it.concurrent('should have date filter with all keyword options', () => {
|
||||
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
|
||||
const values = dateFilter?.options.map((o) => o.value) || []
|
||||
|
||||
expect(values).toContain('today')
|
||||
expect(values).toContain('yesterday')
|
||||
expect(values).toContain('this-week')
|
||||
expect(values).toContain('last-week')
|
||||
expect(values).toContain('this-month')
|
||||
})
|
||||
|
||||
it.concurrent('should have dynamic date examples in date filter', () => {
|
||||
const dateFilter = FILTER_DEFINITIONS.find((f) => f.key === 'date')
|
||||
const options = dateFilter?.options || []
|
||||
|
||||
const specificDate = options.find((o) => o.label === 'Specific date')
|
||||
expect(specificDate).toBeDefined()
|
||||
expect(specificDate?.value).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
const specificMonth = options.find((o) => o.label === 'Specific month')
|
||||
expect(specificMonth).toBeDefined()
|
||||
expect(specificMonth?.value).toMatch(/^\d{4}-\d{2}$/)
|
||||
|
||||
const specificYear = options.find((o) => o.label === 'Specific year')
|
||||
expect(specificYear).toBeDefined()
|
||||
expect(specificYear?.value).toMatch(/^\d{4}$/)
|
||||
|
||||
const dateRange = options.find((o) => o.label === 'Date range')
|
||||
expect(dateRange).toBeDefined()
|
||||
expect(dateRange?.value).toMatch(/^\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}$/)
|
||||
})
|
||||
|
||||
it.concurrent('should have duration filter definition', () => {
|
||||
const durationFilter = FILTER_DEFINITIONS.find((f) => f.key === 'duration')
|
||||
|
||||
expect(durationFilter).toBeDefined()
|
||||
@@ -69,19 +106,19 @@ describe('SearchSuggestions', () => {
|
||||
]
|
||||
|
||||
describe('constructor', () => {
|
||||
test('should create instance with empty data', () => {
|
||||
it.concurrent('should create instance with empty data', () => {
|
||||
const suggestions = new SearchSuggestions()
|
||||
expect(suggestions).toBeDefined()
|
||||
})
|
||||
|
||||
test('should create instance with provided data', () => {
|
||||
it.concurrent('should create instance with provided data', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
expect(suggestions).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateData', () => {
|
||||
test('should update internal data', () => {
|
||||
it.concurrent('should update internal data', () => {
|
||||
const suggestions = new SearchSuggestions()
|
||||
suggestions.updateData(mockWorkflows, mockFolders, mockTriggers)
|
||||
|
||||
@@ -92,7 +129,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - empty input', () => {
|
||||
test('should return filter keys list for empty input', () => {
|
||||
it.concurrent('should return filter keys list for empty input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
@@ -101,7 +138,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should include core filter keys', () => {
|
||||
it.concurrent('should include core filter keys', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
@@ -113,7 +150,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(filterValues).toContain('trigger:')
|
||||
})
|
||||
|
||||
test('should include workflow filter when workflows exist', () => {
|
||||
it.concurrent('should include workflow filter when workflows exist', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
@@ -121,7 +158,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(filterValues).toContain('workflow:')
|
||||
})
|
||||
|
||||
test('should include folder filter when folders exist', () => {
|
||||
it.concurrent('should include folder filter when folders exist', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
@@ -129,7 +166,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(filterValues).toContain('folder:')
|
||||
})
|
||||
|
||||
test('should not include workflow filter when no workflows', () => {
|
||||
it.concurrent('should not include workflow filter when no workflows', () => {
|
||||
const suggestions = new SearchSuggestions([], mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
@@ -139,7 +176,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - filter values (ending with colon)', () => {
|
||||
test('should return level filter values', () => {
|
||||
it.concurrent('should return level filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('level:')
|
||||
|
||||
@@ -149,7 +186,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return cost filter values', () => {
|
||||
it.concurrent('should return cost filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('cost:')
|
||||
|
||||
@@ -158,7 +195,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.value === 'cost:>0.01')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return trigger filter values', () => {
|
||||
it.concurrent('should return trigger filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('trigger:')
|
||||
|
||||
@@ -168,7 +205,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:manual')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return workflow filter values', () => {
|
||||
it.concurrent('should return workflow filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('workflow:')
|
||||
|
||||
@@ -177,7 +214,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.label === 'Test Workflow')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return folder filter values', () => {
|
||||
it.concurrent('should return folder filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('folder:')
|
||||
|
||||
@@ -186,7 +223,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return null for unknown filter key', () => {
|
||||
it.concurrent('should return null for unknown filter key', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('unknown:')
|
||||
|
||||
@@ -195,7 +232,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - partial filter values', () => {
|
||||
test('should filter level values by partial input', () => {
|
||||
it.concurrent('should filter level values by partial input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('level:err')
|
||||
|
||||
@@ -204,7 +241,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:info')).toBe(false)
|
||||
})
|
||||
|
||||
test('should filter workflow values by partial input', () => {
|
||||
it.concurrent('should filter workflow values by partial input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('workflow:test')
|
||||
|
||||
@@ -213,7 +250,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(false)
|
||||
})
|
||||
|
||||
test('should filter trigger values by partial input', () => {
|
||||
it.concurrent('should filter trigger values by partial input', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('trigger:sch')
|
||||
|
||||
@@ -221,7 +258,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:schedule')).toBe(true)
|
||||
})
|
||||
|
||||
test('should return null when no matches found', () => {
|
||||
it.concurrent('should return null when no matches found', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('level:xyz')
|
||||
|
||||
@@ -230,7 +267,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - plain text search (multi-section)', () => {
|
||||
test('should return multi-section results for plain text', () => {
|
||||
it.concurrent('should return multi-section results for plain text', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('test')
|
||||
|
||||
@@ -238,49 +275,49 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.type).toBe('multi-section')
|
||||
})
|
||||
|
||||
test('should include show-all suggestion', () => {
|
||||
it.concurrent('should include show-all suggestion', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('test')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.category === 'show-all')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match workflows by name', () => {
|
||||
it.concurrent('should match workflows by name', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('production')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.label === 'Production Pipeline')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match workflows by description', () => {
|
||||
it.concurrent('should match workflows by description', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('API requests')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.label === 'API Handler')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match folders by name', () => {
|
||||
it.concurrent('should match folders by name', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('development')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.label === 'Development')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match triggers by label', () => {
|
||||
it.concurrent('should match triggers by label', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('slack')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.value === 'trigger:slack')).toBe(true)
|
||||
})
|
||||
|
||||
test('should match filter values', () => {
|
||||
it.concurrent('should match filter values', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('error')
|
||||
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:error')).toBe(true)
|
||||
})
|
||||
|
||||
test('should show suggested filters when no matches found', () => {
|
||||
it.concurrent('should show suggested filters when no matches found', () => {
|
||||
const suggestions = new SearchSuggestions([], [], [])
|
||||
const result = suggestions.getSuggestions('xyz123')
|
||||
|
||||
@@ -290,7 +327,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - case insensitivity', () => {
|
||||
test('should match regardless of case', () => {
|
||||
it.concurrent('should match regardless of case', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
|
||||
const lowerResult = suggestions.getSuggestions('test')
|
||||
@@ -304,7 +341,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - sorting', () => {
|
||||
test('should sort exact matches first', () => {
|
||||
it.concurrent('should sort exact matches first', () => {
|
||||
const workflows: WorkflowData[] = [
|
||||
{ id: '1', name: 'API Handler' },
|
||||
{ id: '2', name: 'API' },
|
||||
@@ -317,7 +354,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(workflowSuggestions?.[0]?.label).toBe('API')
|
||||
})
|
||||
|
||||
test('should sort prefix matches before substring matches', () => {
|
||||
it.concurrent('should sort prefix matches before substring matches', () => {
|
||||
const workflows: WorkflowData[] = [
|
||||
{ id: '1', name: 'Contains Test Inside' },
|
||||
{ id: '2', name: 'Test First' },
|
||||
@@ -331,7 +368,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - result limits', () => {
|
||||
test('should limit workflow results to 8', () => {
|
||||
it.concurrent('should limit workflow results to 8', () => {
|
||||
const manyWorkflows = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `wf-${i}`,
|
||||
name: `Test Workflow ${i}`,
|
||||
@@ -343,9 +380,9 @@ describe('SearchSuggestions', () => {
|
||||
expect(workflowSuggestions?.length).toBeLessThanOrEqual(8)
|
||||
})
|
||||
|
||||
test('should limit filter value results to 5', () => {
|
||||
it.concurrent('should limit filter value results to 5', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('o') // Matches multiple filter values
|
||||
const result = suggestions.getSuggestions('o')
|
||||
|
||||
const filterSuggestions = result?.suggestions.filter(
|
||||
(s) =>
|
||||
@@ -359,7 +396,7 @@ describe('SearchSuggestions', () => {
|
||||
})
|
||||
|
||||
describe('getSuggestions - suggestion structure', () => {
|
||||
test('should include correct properties for filter key suggestions', () => {
|
||||
it.concurrent('should include correct properties for filter key suggestions', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('')
|
||||
|
||||
@@ -370,7 +407,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(suggestion).toHaveProperty('category')
|
||||
})
|
||||
|
||||
test('should include color for trigger suggestions', () => {
|
||||
it.concurrent('should include color for trigger suggestions', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('trigger:')
|
||||
|
||||
@@ -378,7 +415,7 @@ describe('SearchSuggestions', () => {
|
||||
expect(triggerSuggestion?.color).toBeDefined()
|
||||
})
|
||||
|
||||
test('should quote workflow names in value', () => {
|
||||
it.concurrent('should quote workflow names in value', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('workflow:')
|
||||
|
||||
@@ -386,4 +423,73 @@ describe('SearchSuggestions', () => {
|
||||
expect(workflowSuggestion?.value).toBe('workflow:"Test Workflow"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSuggestions - date filter values', () => {
|
||||
it.concurrent('should return date filter keyword options', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('date:')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('filter-values')
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:today')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:last-week')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should suggest year format when typing a year', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('date:2024')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:2024')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.label === 'Year 2024')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should suggest month format when typing YYYY-MM', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('date:2024-12')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:2024-12')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.label === 'Dec 2024')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should suggest single date and range start when typing full date', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('date:2024-12-25')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:2024-12-25..')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should suggest completing range when typing date..', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('date:2024-01-01..')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.description?.includes('Type end date'))).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should suggest complete range when both dates provided', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('date:2024-01-01..2024-01-15')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:2024-01-01..2024-01-15')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.description === 'Custom date range')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should filter date options by partial keyword match', () => {
|
||||
const suggestions = new SearchSuggestions(mockWorkflows, mockFolders, mockTriggers)
|
||||
const result = suggestions.getSuggestions('date:this')
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:this-week')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:this-month')).toBe(true)
|
||||
expect(result?.suggestions.some((s) => s.value === 'date:yesterday')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface FilterDefinition {
|
||||
label: string
|
||||
description?: string
|
||||
}>
|
||||
acceptsCustomValue?: boolean
|
||||
customValueHint?: string
|
||||
}
|
||||
|
||||
export interface WorkflowData {
|
||||
@@ -28,6 +30,20 @@ export interface TriggerData {
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates current date examples for the date filter options.
|
||||
*/
|
||||
function getDateExamples() {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
const firstOfMonth = `${year}-${month}-01`
|
||||
const today = `${year}-${month}-${day}`
|
||||
const yearMonth = `${year}-${month}`
|
||||
return { today, firstOfMonth, year: String(year), yearMonth }
|
||||
}
|
||||
|
||||
export const FILTER_DEFINITIONS: FilterDefinition[] = [
|
||||
{
|
||||
key: 'level',
|
||||
@@ -58,13 +74,24 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
|
||||
key: 'date',
|
||||
label: 'Date',
|
||||
description: 'Filter by date range',
|
||||
options: [
|
||||
{ value: 'today', label: 'Today', description: "Today's logs" },
|
||||
{ value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" },
|
||||
{ value: 'this-week', label: 'This week', description: "This week's logs" },
|
||||
{ value: 'last-week', label: 'Last week', description: "Last week's logs" },
|
||||
{ value: 'this-month', label: 'This month', description: "This month's logs" },
|
||||
],
|
||||
options: (() => {
|
||||
const { today, firstOfMonth, year, yearMonth } = getDateExamples()
|
||||
return [
|
||||
{ value: 'today', label: 'Today', description: "Today's logs" },
|
||||
{ value: 'yesterday', label: 'Yesterday', description: "Yesterday's logs" },
|
||||
{ value: 'this-week', label: 'This week', description: "This week's logs" },
|
||||
{ value: 'last-week', label: 'Last week', description: "Last week's logs" },
|
||||
{ value: 'this-month', label: 'This month', description: "This month's logs" },
|
||||
{ value: today, label: 'Specific date', description: 'YYYY-MM-DD' },
|
||||
{ value: yearMonth, label: 'Specific month', description: 'YYYY-MM' },
|
||||
{ value: year, label: 'Specific year', description: 'YYYY' },
|
||||
{
|
||||
value: `${firstOfMonth}..${today}`,
|
||||
label: 'Date range',
|
||||
description: 'YYYY-MM-DD..YYYY-MM-DD',
|
||||
},
|
||||
]
|
||||
})(),
|
||||
},
|
||||
{
|
||||
key: 'duration',
|
||||
@@ -208,7 +235,7 @@ export class SearchSuggestions {
|
||||
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === key)
|
||||
|
||||
if (filterDef) {
|
||||
const suggestions = filterDef.options
|
||||
const suggestions: Suggestion[] = filterDef.options
|
||||
.filter(
|
||||
(opt) =>
|
||||
!partial ||
|
||||
@@ -220,9 +247,17 @@ export class SearchSuggestions {
|
||||
value: `${key}:${opt.value}`,
|
||||
label: opt.label,
|
||||
description: opt.description,
|
||||
category: key as any,
|
||||
category: key as Suggestion['category'],
|
||||
}))
|
||||
|
||||
// Handle custom date input
|
||||
if (key === 'date' && partial) {
|
||||
const dateSuggestions = this.getDateSuggestions(partial)
|
||||
if (dateSuggestions.length > 0) {
|
||||
suggestions.unshift(...dateSuggestions)
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions.length > 0
|
||||
? {
|
||||
type: 'filter-values',
|
||||
@@ -372,6 +407,140 @@ export class SearchSuggestions {
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions for custom date input
|
||||
*/
|
||||
private getDateSuggestions(partial: string): Suggestion[] {
|
||||
const suggestions: Suggestion[] = []
|
||||
|
||||
// Pattern for year only: YYYY
|
||||
const yearPattern = /^\d{4}$/
|
||||
// Pattern for month only: YYYY-MM
|
||||
const monthPattern = /^\d{4}-\d{2}$/
|
||||
// Pattern for full date: YYYY-MM-DD
|
||||
const fullDatePattern = /^\d{4}-\d{2}-\d{2}$/
|
||||
// Pattern for partial date being typed
|
||||
const partialDatePattern = /^\d{4}(-\d{0,2})?(-\d{0,2})?$/
|
||||
// Pattern for date range: YYYY-MM-DD..YYYY-MM-DD (complete or partial)
|
||||
const rangePattern = /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
|
||||
const partialRangePattern = /^(\d{4}-\d{2}-\d{2})\.\.?$/
|
||||
|
||||
// Check if it's a complete date range
|
||||
if (rangePattern.test(partial)) {
|
||||
const [startDate, endDate] = partial.split('..')
|
||||
suggestions.push({
|
||||
id: `date-range-${partial}`,
|
||||
value: `date:${partial}`,
|
||||
label: `${this.formatDateLabel(startDate)} to ${this.formatDateLabel(endDate)}`,
|
||||
description: 'Custom date range',
|
||||
category: 'date' as any,
|
||||
})
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// Check if it's a partial date range (has ..)
|
||||
if (partialRangePattern.test(partial)) {
|
||||
const startDate = partial.replace(/\.+$/, '')
|
||||
suggestions.push({
|
||||
id: `date-range-hint-${partial}`,
|
||||
value: `date:${startDate}..`,
|
||||
label: `${this.formatDateLabel(startDate)} to ...`,
|
||||
description: 'Type end date (YYYY-MM-DD)',
|
||||
category: 'date' as any,
|
||||
})
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// Check if it's a year only (YYYY)
|
||||
if (yearPattern.test(partial)) {
|
||||
suggestions.push({
|
||||
id: `date-year-${partial}`,
|
||||
value: `date:${partial}`,
|
||||
label: `Year ${partial}`,
|
||||
description: 'All logs from this year',
|
||||
category: 'date' as any,
|
||||
})
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// Check if it's a month only (YYYY-MM)
|
||||
if (monthPattern.test(partial)) {
|
||||
const [year, month] = partial.split('-')
|
||||
const monthNames = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
]
|
||||
const monthName = monthNames[Number.parseInt(month, 10) - 1] || month
|
||||
suggestions.push({
|
||||
id: `date-month-${partial}`,
|
||||
value: `date:${partial}`,
|
||||
label: `${monthName} ${year}`,
|
||||
description: 'All logs from this month',
|
||||
category: 'date' as any,
|
||||
})
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// Check if it's a complete single date
|
||||
if (fullDatePattern.test(partial)) {
|
||||
const date = new Date(partial)
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
suggestions.push({
|
||||
id: `date-single-${partial}`,
|
||||
value: `date:${partial}`,
|
||||
label: this.formatDateLabel(partial),
|
||||
description: 'Single date',
|
||||
category: 'date' as any,
|
||||
})
|
||||
// Also suggest starting a range
|
||||
suggestions.push({
|
||||
id: `date-range-start-${partial}`,
|
||||
value: `date:${partial}..`,
|
||||
label: `${this.formatDateLabel(partial)} to ...`,
|
||||
description: 'Start a date range',
|
||||
category: 'date' as any,
|
||||
})
|
||||
}
|
||||
return suggestions
|
||||
}
|
||||
|
||||
// Check if user is typing a date pattern
|
||||
if (partialDatePattern.test(partial) && partial.length >= 4) {
|
||||
suggestions.push({
|
||||
id: 'date-custom-hint',
|
||||
value: `date:${partial}`,
|
||||
label: partial,
|
||||
description: 'Continue typing: YYYY, YYYY-MM, or YYYY-MM-DD',
|
||||
category: 'date' as any,
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string for display
|
||||
*/
|
||||
private formatDateLabel(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Match filter values across all definitions
|
||||
*/
|
||||
|
||||
@@ -37,6 +37,8 @@ const parseTimeRangeFromURL = (value: string | null): TimeRange => {
|
||||
return 'Past 14 days'
|
||||
case 'past-30-days':
|
||||
return 'Past 30 days'
|
||||
case 'custom':
|
||||
return 'Custom range'
|
||||
default:
|
||||
return DEFAULT_TIME_RANGE
|
||||
}
|
||||
@@ -85,6 +87,8 @@ const timeRangeToURL = (timeRange: TimeRange): string => {
|
||||
return 'past-14-days'
|
||||
case 'Past 30 days':
|
||||
return 'past-30-days'
|
||||
case 'Custom range':
|
||||
return 'custom'
|
||||
default:
|
||||
return 'all-time'
|
||||
}
|
||||
@@ -94,6 +98,8 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
||||
workspaceId: '',
|
||||
viewMode: 'logs',
|
||||
timeRange: DEFAULT_TIME_RANGE,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
level: 'all',
|
||||
workflowIds: [],
|
||||
folderIds: [],
|
||||
@@ -112,6 +118,28 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setDateRange: (start, end) => {
|
||||
set({
|
||||
timeRange: 'Custom range',
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
})
|
||||
if (!get().isInitializing) {
|
||||
get().syncWithURL()
|
||||
}
|
||||
},
|
||||
|
||||
clearDateRange: () => {
|
||||
set({
|
||||
timeRange: DEFAULT_TIME_RANGE,
|
||||
startDate: undefined,
|
||||
endDate: undefined,
|
||||
})
|
||||
if (!get().isInitializing) {
|
||||
get().syncWithURL()
|
||||
}
|
||||
},
|
||||
|
||||
setLevel: (level) => {
|
||||
set({ level })
|
||||
if (!get().isInitializing) {
|
||||
@@ -205,9 +233,13 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
||||
const folderIds = parseStringArrayFromURL(params.get('folderIds'))
|
||||
const triggers = parseTriggerArrayFromURL(params.get('triggers'))
|
||||
const searchQuery = params.get('search') || ''
|
||||
const startDate = params.get('startDate') || undefined
|
||||
const endDate = params.get('endDate') || undefined
|
||||
|
||||
set({
|
||||
timeRange,
|
||||
startDate,
|
||||
endDate,
|
||||
level,
|
||||
workflowIds,
|
||||
folderIds,
|
||||
@@ -218,13 +250,23 @@ export const useFilterStore = create<FilterState>((set, get) => ({
|
||||
},
|
||||
|
||||
syncWithURL: () => {
|
||||
const { timeRange, level, workflowIds, folderIds, triggers, searchQuery } = get()
|
||||
const { timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery } =
|
||||
get()
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (timeRange !== DEFAULT_TIME_RANGE) {
|
||||
params.set('timeRange', timeRangeToURL(timeRange))
|
||||
}
|
||||
|
||||
if (timeRange === 'Custom range') {
|
||||
if (startDate) {
|
||||
params.set('startDate', startDate)
|
||||
}
|
||||
if (endDate) {
|
||||
params.set('endDate', endDate)
|
||||
}
|
||||
}
|
||||
|
||||
if (level !== 'all') {
|
||||
params.set('level', level)
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export type TimeRange =
|
||||
| 'Past 14 days'
|
||||
| 'Past 30 days'
|
||||
| 'All time'
|
||||
| 'Custom range'
|
||||
|
||||
export type LogLevel = 'error' | 'info' | 'running' | 'pending' | 'all' | (string & {})
|
||||
export type TriggerType = 'chat' | 'api' | 'webhook' | 'manual' | 'schedule' | 'all' | string
|
||||
@@ -179,6 +180,8 @@ export interface FilterState {
|
||||
workspaceId: string
|
||||
viewMode: 'logs' | 'dashboard'
|
||||
timeRange: TimeRange
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
level: LogLevel
|
||||
workflowIds: string[]
|
||||
folderIds: string[]
|
||||
@@ -189,6 +192,8 @@ export interface FilterState {
|
||||
setWorkspaceId: (workspaceId: string) => void
|
||||
setViewMode: (viewMode: 'logs' | 'dashboard') => void
|
||||
setTimeRange: (timeRange: TimeRange) => void
|
||||
setDateRange: (startDate: string | undefined, endDate: string | undefined) => void
|
||||
clearDateRange: () => void
|
||||
setLevel: (level: LogLevel) => void
|
||||
setWorkflowIds: (workflowIds: string[]) => void
|
||||
toggleWorkflowId: (workflowId: string) => void
|
||||
|
||||
Reference in New Issue
Block a user