improvement(logs): improved logs search (#1985)

* improvement(logs): improved logs search

* more

* ack PR comments
This commit is contained in:
Waleed
2025-11-14 01:14:20 -08:00
committed by GitHub
parent 948b6575dc
commit 1082e55871
17 changed files with 1254 additions and 1377 deletions

View File

@@ -60,7 +60,12 @@ export async function GET(request: NextRequest) {
let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)
if (params.level && params.level !== 'all') {
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
const levels = params.level.split(',').filter(Boolean)
if (levels.length === 1) {
conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0]))
} else if (levels.length > 1) {
conditions = and(conditions, inArray(workflowExecutionLogs.level, levels))
}
}
if (params.workflowIds) {

View File

@@ -126,9 +126,14 @@ export async function GET(request: NextRequest) {
// Build additional conditions for the query
let conditions: SQL | undefined
// Filter by level
// Filter by level (supports comma-separated for OR conditions)
if (params.level && params.level !== 'all') {
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
const levels = params.level.split(',').filter(Boolean)
if (levels.length === 1) {
conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0]))
} else if (levels.length > 1) {
conditions = and(conditions, inArray(workflowExecutionLogs.level, levels))
}
}
// Filter by specific workflow IDs

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from 'react'
import { Loader2, RefreshCw, Search } from 'lucide-react'
import { ArrowUp, Loader2, RefreshCw, Search } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
@@ -16,7 +16,6 @@ export function Controls({
viewMode,
setViewMode,
searchComponent,
showExport = true,
onExport,
}: {
searchQuery?: string
@@ -72,6 +71,23 @@ export function Controls({
)}
<div className='ml-auto flex flex-shrink-0 items-center gap-3'>
{viewMode !== 'dashboard' && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onExport}
className='h-9 w-9 p-0 hover:bg-secondary'
aria-label='Export CSV'
>
<ArrowUp className='h-4 w-4' />
<span className='sr-only'>Export CSV</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Export CSV</Tooltip.Content>
</Tooltip.Root>
)}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
@@ -81,9 +97,9 @@ export function Controls({
disabled={isRefetching}
>
{isRefetching ? (
<Loader2 className='h-5 w-5 animate-spin' />
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<RefreshCw className='h-5 w-5' />
<RefreshCw className='h-4 w-4' />
)}
<span className='sr-only'>Refresh</span>
</Button>
@@ -91,32 +107,6 @@ export function Controls({
<Tooltip.Content>{isRefetching ? 'Refreshing...' : 'Refresh'}</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onExport}
className='h-9 w-9 p-0 hover:bg-secondary'
aria-label='Export CSV'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeWidth='2'
className='h-5 w-5'
>
<path d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4' />
<polyline points='7 10 12 15 17 10' />
<line x1='12' y1='15' x2='12' y2='3' />
</svg>
<span className='sr-only'>Export CSV</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>Export CSV</Tooltip.Content>
</Tooltip.Root>
<div className='inline-flex h-9 items-center rounded-[11px] border bg-card p-1 shadow-sm'>
<Button
variant='ghost'

View File

@@ -9,25 +9,25 @@ export interface AggregateMetrics {
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
return (
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
<div className='border bg-card p-4 shadow-sm'>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Total executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.totalExecutions.toLocaleString()}
</div>
</div>
<div className='border bg-card p-4 shadow-sm'>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Success rate</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.successRate.toFixed(1)}%
</div>
</div>
<div className='border bg-card p-4 shadow-sm'>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Failed executions</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>
{aggregate.failedExecutions.toLocaleString()}
</div>
</div>
<div className='border bg-card p-4 shadow-sm'>
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
<div className='text-muted-foreground text-xs'>Active workflows</div>
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
</div>

View File

@@ -174,55 +174,48 @@ export function LineChart({
ref={containerRef}
className='w-full overflow-hidden rounded-[11px] border bg-card p-4 shadow-sm'
>
<div className='mb-3 flex items-center justify-between'>
<div className='flex items-center gap-3'>
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
{allSeries.length > 1 && (
<div className='flex items-center gap-2'>
{scaledSeries.slice(1).map((s) => {
const isActive = activeSeriesId ? activeSeriesId === s.id : true
const isHovered = hoverSeriesId === s.id
const dimmed = activeSeriesId ? !isActive : false
return (
<button
key={`legend-${s.id}`}
type='button'
aria-pressed={activeSeriesId === s.id}
aria-label={`Toggle ${s.label}`}
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
style={{
color: s.color,
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
border: '1px solid hsl(var(--border))',
background: 'transparent',
}}
onMouseEnter={() => setHoverSeriesId(s.id || null)}
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
}
}}
onClick={() =>
<div className='mb-3 flex items-center gap-3'>
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
{allSeries.length > 1 && (
<div className='flex items-center gap-2'>
{scaledSeries.slice(1).map((s) => {
const isActive = activeSeriesId ? activeSeriesId === s.id : true
const isHovered = hoverSeriesId === s.id
const dimmed = activeSeriesId ? !isActive : false
return (
<button
key={`legend-${s.id}`}
type='button'
aria-pressed={activeSeriesId === s.id}
aria-label={`Toggle ${s.label}`}
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
style={{
color: s.color,
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
border: '1px solid hsl(var(--border))',
background: 'transparent',
}}
onMouseEnter={() => setHoverSeriesId(s.id || null)}
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
}
>
<span
aria-hidden='true'
className='inline-block h-[6px] w-[6px] rounded-full'
style={{ backgroundColor: s.color }}
/>
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
</button>
)
})}
</div>
)}
</div>
{currentHoverDate ? (
<div className='text-[10px] text-muted-foreground'>{currentHoverDate}</div>
) : null}
}}
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
>
<span
aria-hidden='true'
className='inline-block h-[6px] w-[6px] rounded-full'
style={{ backgroundColor: s.color }}
/>
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
</button>
)
})}
</div>
)}
</div>
<div className='relative' style={{ width, height }}>
<svg
@@ -556,6 +549,9 @@ export function LineChart({
className='pointer-events-none absolute rounded-md bg-background/80 px-2 py-1 font-medium text-[11px] shadow-sm ring-1 ring-border backdrop-blur'
style={{ left, top }}
>
{currentHoverDate && (
<div className='mb-1 text-[10px] text-muted-foreground'>{currentHoverDate}</div>
)}
{toDisplay.map((s) => {
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value

View File

@@ -2,13 +2,20 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUpRight, Info, Loader2 } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-python'
import 'prismjs/components/prism-json'
import { CopyButton } from '@/components/ui/copy-button'
import { cn } from '@/lib/utils'
import LineChart, {
type LineChartPoint,
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/line-chart'
import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils'
import LogMarkdownRenderer from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import '@/components/emcn/components/code/code.css'
export interface ExecutionLogItem {
id: string
@@ -31,6 +38,27 @@ export interface ExecutionLogItem {
hasPendingPause?: boolean
}
/**
* Tries to parse a string as JSON and prettify it
*/
const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string } => {
try {
const trimmed = content.trim()
if (
!(trimmed.startsWith('{') || trimmed.startsWith('[')) ||
!(trimmed.endsWith('}') || trimmed.endsWith(']'))
) {
return { isJson: false, formatted: content }
}
const parsed = JSON.parse(trimmed)
const prettified = JSON.stringify(parsed, null, 2)
return { isJson: true, formatted: prettified }
} catch (_e) {
return { isJson: false, formatted: content }
}
}
export interface WorkflowDetailsData {
errorRates: LineChartPoint[]
durations?: LineChartPoint[]
@@ -50,6 +78,9 @@ export function WorkflowDetails({
details,
selectedSegmentIndex,
selectedSegment,
selectedSegmentTimeRange,
selectedWorkflowNames,
segmentDurationMs,
clearSegmentSelection,
formatCost,
onLoadMore,
@@ -63,6 +94,9 @@ export function WorkflowDetails({
details: WorkflowDetailsData | undefined
selectedSegmentIndex: number[] | null
selectedSegment: { timestamp: string; totalExecutions: number } | null
selectedSegmentTimeRange?: { start: Date; end: Date } | null
selectedWorkflowNames?: string[]
segmentDurationMs?: number
clearSegmentSelection: () => void
formatCost: (n: number) => string
onLoadMore?: () => void
@@ -128,29 +162,111 @@ export function WorkflowDetails({
<div className='border-b bg-muted/30 px-4 py-2.5'>
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<button
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
className='group inline-flex items-center gap-2 text-left'
>
<span
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflowColor }}
/>
<span className='font-[480] text-sm tracking-tight group-hover:text-primary dark:font-[560]'>
{workflowName}
</span>
</button>
{expandedWorkflowId !== 'all' && expandedWorkflowId !== '__multi__' ? (
<button
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
className='group inline-flex items-center gap-2 text-left transition-opacity hover:opacity-70'
>
<span
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflowColor }}
/>
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
{workflowName}
</span>
</button>
) : (
<div className='inline-flex items-center gap-2'>
<span
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{ backgroundColor: workflowColor }}
/>
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
{workflowName}
</span>
</div>
)}
{Array.isArray(selectedSegmentIndex) &&
selectedSegmentIndex.length > 0 &&
(selectedSegment || selectedSegmentTimeRange || expandedWorkflowId === '__multi__') &&
(() => {
let tsLabel = 'Selected segment'
if (selectedSegmentTimeRange) {
const start = selectedSegmentTimeRange.start
const end = selectedSegmentTimeRange.end
const startFormatted = start.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
const endFormatted = end.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
tsLabel = `${startFormatted} ${endFormatted}`
} else if (selectedSegment?.timestamp) {
const tsObj = new Date(selectedSegment.timestamp)
if (!Number.isNaN(tsObj.getTime())) {
tsLabel = tsObj.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
}
}
const isMultiWorkflow =
expandedWorkflowId === '__multi__' &&
selectedWorkflowNames &&
selectedWorkflowNames.length > 0
const workflowLabel = isMultiWorkflow
? selectedWorkflowNames.length <= 2
? selectedWorkflowNames.join(', ')
: `${selectedWorkflowNames.slice(0, 2).join(', ')} +${selectedWorkflowNames.length - 2}`
: null
return (
<div className='inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/50 px-2.5'>
{isMultiWorkflow && workflowLabel && (
<span className='font-medium text-[11px] text-muted-foreground'>
{workflowLabel}
</span>
)}
<span className='font-medium text-[11px] text-foreground'>
{tsLabel}
{selectedSegmentIndex.length > 1 && !isMultiWorkflow
? ` (+${selectedSegmentIndex.length - 1})`
: ''}
</span>
<button
onClick={(e) => {
e.stopPropagation()
clearSegmentSelection()
}}
className='ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40'
aria-label='Clear filter'
>
×
</button>
</div>
)
})()}
</div>
<div className='flex items-center gap-2'>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Executions</span>
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
</div>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Success</span>
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
</div>
<div className='inline-flex h-7 items-center gap-2 border px-2.5'>
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
<span className='text-[11px] text-muted-foreground'>Failures</span>
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
</div>
@@ -160,53 +276,14 @@ export function WorkflowDetails({
<div className='p-4'>
{details ? (
<>
{Array.isArray(selectedSegmentIndex) &&
selectedSegmentIndex.length > 0 &&
selectedSegment &&
(() => {
const tsObj = selectedSegment?.timestamp
? new Date(selectedSegment.timestamp)
: null
const tsLabel =
tsObj && !Number.isNaN(tsObj.getTime())
? tsObj.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
})
: 'Selected segment'
return (
<div className='mb-4 flex items-center justify-between border bg-muted/30 px-3 py-2 text-[13px] text-foreground'>
<div className='flex items-center gap-2'>
<div className='h-1.5 w-1.5 rounded-full bg-primary ring-2 ring-primary/30' />
<span className='font-medium'>
Filtered to {tsLabel}
{selectedSegmentIndex.length > 1
? ` (+${selectedSegmentIndex.length - 1} more segment${selectedSegmentIndex.length - 1 > 1 ? 's' : ''})`
: ''}
{selectedSegment.totalExecutions} execution
{selectedSegment.totalExecutions !== 1 ? 's' : ''}
</span>
</div>
<button
onClick={clearSegmentSelection}
className='rounded px-2 py-1 text-foreground text-xs hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/40'
>
Clear filter
</button>
</div>
)
})()}
{(() => {
const hasDuration = Array.isArray(details.durations) && details.durations.length > 0
const gridCols = hasDuration
? 'md:grid-cols-2 xl:grid-cols-4'
: 'md:grid-cols-2 xl:grid-cols-3'
const gridGap = hasDuration ? 'gap-2 xl:gap-2.5' : 'gap-3'
return (
<div className={`mb-3 grid grid-cols-1 gap-3 ${gridCols}`}>
<div className={`mb-3 grid grid-cols-1 ${gridGap} ${gridCols}`}>
<LineChart
data={details.errorRates}
label='Error Rate'
@@ -431,7 +508,7 @@ export function WorkflowDetails({
{log.workflowName ? (
<div className='inline-flex items-center gap-2'>
<span
className='h-3.5 w-3.5'
className='h-3.5 w-3.5 flex-shrink-0 rounded'
style={{ backgroundColor: log.workflowColor || '#64748b' }}
/>
<span
@@ -483,10 +560,31 @@ export function WorkflowDetails({
</div>
{isExpanded && (
<div className='px-2 pt-0 pb-4'>
<div className='border bg-muted/30 p-2'>
<pre className='max-h-60 overflow-auto whitespace-pre-wrap break-words text-xs'>
{log.level === 'error' && errorStr ? errorStr : outputsStr}
</pre>
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
<CopyButton
text={log.level === 'error' && errorStr ? errorStr : outputsStr}
className='z-10 h-7 w-7'
/>
{(() => {
const content =
log.level === 'error' && errorStr ? errorStr : outputsStr
const { isJson, formatted } = tryPrettifyJson(content)
return isJson ? (
<div className='code-editor-theme'>
<pre
className='max-h-[300px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
dangerouslySetInnerHTML={{
__html: highlight(formatted, languages.json, 'json'),
}}
/>
</div>
) : (
<div className='max-h-[300px] overflow-y-auto'>
<LogMarkdownRenderer content={formatted} />
</div>
)
})()}
</div>
</div>
)}

View File

@@ -59,7 +59,7 @@ export function WorkflowsList({
}
return (
<div
className='overflow-hidden border bg-card shadow-sm'
className='overflow-hidden rounded-[11px] border bg-card shadow-sm'
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
>
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
@@ -97,7 +97,7 @@ export function WorkflowsList({
<div className='w-52 min-w-0 flex-shrink-0'>
<div className='flex items-center gap-2'>
<div
className='h-[14px] w-[14px] flex-shrink-0'
className='h-[14px] w-[14px] flex-shrink-0 rounded'
style={{
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
}}

View File

@@ -1,21 +1,23 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { Loader2, Search, X } from 'lucide-react'
import { Button } from '@/components/emcn'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { parseQuery } from '@/lib/logs/query-parser'
import { SearchSuggestions } from '@/lib/logs/search-suggestions'
import { Search, X } from 'lucide-react'
import { Button, Popover, PopoverAnchor, PopoverContent } from '@/components/emcn'
import { type ParsedFilter, parseQuery } from '@/lib/logs/query-parser'
import {
type FolderData,
SearchSuggestions,
type WorkflowData,
} from '@/lib/logs/search-suggestions'
import { cn } from '@/lib/utils'
import { useAutocomplete } from '@/app/workspace/[workspaceId]/logs/hooks/use-autocomplete'
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
import { useFolderStore } from '@/stores/folders/store'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface AutocompleteSearchProps {
value: string
onChange: (value: string) => void
placeholder?: string
availableWorkflows?: string[]
availableFolders?: string[]
className?: string
onOpenChange?: (open: boolean) => void
}
@@ -24,301 +26,307 @@ export function AutocompleteSearch({
value,
onChange,
placeholder = 'Search logs...',
availableWorkflows = [],
availableFolders = [],
className,
onOpenChange,
}: AutocompleteSearchProps) {
const workflows = useWorkflowRegistry((state) => state.workflows)
const folders = useFolderStore((state) => state.folders)
const workflowsData = useMemo<WorkflowData[]>(() => {
return Object.values(workflows).map((w) => ({
id: w.id,
name: w.name,
description: w.description,
}))
}, [workflows])
const foldersData = useMemo<FolderData[]>(() => {
return Object.values(folders).map((f) => ({
id: f.id,
name: f.name,
}))
}, [folders])
const suggestionEngine = useMemo(() => {
return new SearchSuggestions(availableWorkflows, availableFolders)
}, [availableWorkflows, availableFolders])
return new SearchSuggestions(workflowsData, foldersData)
}, [workflowsData, foldersData])
const handleFiltersChange = (filters: ParsedFilter[], textSearch: string) => {
const filterStrings = filters.map(
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
)
const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ')
onChange(fullQuery)
}
const {
state,
appliedFilters,
currentInput,
textSearch,
isOpen,
suggestions,
sections,
highlightedIndex,
highlightedBadgeIndex,
inputRef,
dropdownRef,
handleInputChange,
handleCursorChange,
handleSuggestionHover,
handleSuggestionSelect,
handleKeyDown,
handleFocus,
handleBlur,
reset: resetAutocomplete,
closeDropdown,
} = useAutocomplete({
getSuggestions: (inputValue, cursorPos) =>
suggestionEngine.getSuggestions(inputValue, cursorPos),
generatePreview: (suggestion, inputValue, cursorPos) =>
suggestionEngine.generatePreview(suggestion, inputValue, cursorPos),
onQueryChange: onChange,
validateQuery: (query) => suggestionEngine.validateQuery(query),
debounceMs: 100,
removeBadge,
clearAll,
setHighlightedIndex,
initializeFromQuery,
} = useSearchState({
onFiltersChange: handleFiltersChange,
getSuggestions: (input) => suggestionEngine.getSuggestions(input),
})
const clearAll = () => {
resetAutocomplete()
closeDropdown()
onChange('')
if (inputRef.current) {
inputRef.current.focus()
// Initialize from external value (URL params) - only on mount
useEffect(() => {
if (value) {
const parsed = parseQuery(value)
initializeFromQuery(parsed.textSearch, parsed.filters)
}
}
const parsedQuery = parseQuery(value)
const hasFilters = parsedQuery.filters.length > 0
const hasTextSearch = parsedQuery.textSearch.length > 0
const listboxId = 'logs-search-listbox'
const inputId = 'logs-search-input'
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [dropdownWidth, setDropdownWidth] = useState(500)
useEffect(() => {
onOpenChange?.(state.isOpen)
}, [state.isOpen, onOpenChange])
useEffect(() => {
if (!state.isOpen || state.highlightedIndex < 0) return
const container = dropdownRef.current
const optionEl = document.getElementById(`${listboxId}-option-${state.highlightedIndex}`)
if (container && optionEl) {
try {
optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
} catch {
optionEl.scrollIntoView({ block: 'nearest' })
const measure = () => {
if (inputRef.current) {
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 500)
}
}
}, [state.isOpen, state.highlightedIndex])
measure()
window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure)
}, [])
const [showSpinner, setShowSpinner] = useState(false)
useEffect(() => {
if (!state.pendingQuery) {
setShowSpinner(false)
return
onOpenChange?.(isOpen)
}, [isOpen, onOpenChange])
useEffect(() => {
if (!isOpen || highlightedIndex < 0) return
const container = dropdownRef.current
const optionEl = container?.querySelector(`[data-index="${highlightedIndex}"]`)
if (container && optionEl) {
optionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
const t = setTimeout(() => setShowSpinner(true), 200)
return () => clearTimeout(t)
}, [state.pendingQuery])
}, [isOpen, highlightedIndex])
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value
const cursorPos = e.target.selectionStart || 0
handleInputChange(newValue, cursorPos)
}
const updateCursorPosition = (element: HTMLInputElement) => {
const cursorPos = element.selectionStart || 0
handleCursorChange(cursorPos)
}
const removeFilter = (filterToRemove: (typeof parsedQuery.filters)[0]) => {
const remainingFilters = parsedQuery.filters.filter(
(f) => !(f.field === filterToRemove.field && f.value === filterToRemove.value)
)
const filterStrings = remainingFilters.map(
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
)
const newQuery = [...filterStrings, parsedQuery.textSearch].filter(Boolean).join(' ')
handleInputChange(newQuery, newQuery.length)
if (inputRef.current) {
inputRef.current.focus()
}
}
const hasFilters = appliedFilters.length > 0
const hasTextSearch = textSearch.length > 0
const suggestionType =
sections.length > 0 ? 'multi-section' : suggestions.length > 0 ? suggestions[0]?.category : null
return (
<div className={cn('relative', className)}>
{/* Search Input */}
<div
className={cn(
'relative flex items-center gap-2 border bg-background pr-2 pl-3 transition-all duration-200',
'h-9 w-full min-w-[600px] max-w-[800px]',
state.isOpen && 'ring-1 ring-ring'
)}
{/* Search Input with Inline Badges */}
<Popover
open={isOpen}
onOpenChange={(open) => {
if (!open) {
setHighlightedIndex(-1)
}
}}
>
{showSpinner ? (
<Loader2 className='h-4 w-4 flex-shrink-0 animate-spin text-muted-foreground' />
) : (
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
)}
<PopoverAnchor asChild>
<div className='relative flex h-9 w-[500px] items-center rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] transition-colors focus-within:border-[var(--surface-14)] focus-within:ring-1 focus-within:ring-ring hover:border-[var(--surface-14)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)]'>
{/* Search Icon */}
<Search
className='ml-2.5 h-4 w-4 flex-shrink-0 text-muted-foreground'
strokeWidth={2}
/>
{/* Text display with ghost text */}
<div className='relative flex-1 font-[380] font-sans text-base leading-none'>
{/* Invisible input for cursor and interactions */}
<Input
ref={inputRef}
id={inputId}
placeholder={state.inputValue ? '' : placeholder}
value={state.inputValue}
onChange={onInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={(e) => updateCursorPosition(e.currentTarget)}
onKeyDown={handleKeyDown}
onSelect={(e) => updateCursorPosition(e.currentTarget)}
className='relative z-10 w-full border-0 bg-transparent p-0 font-[380] font-sans text-base text-transparent leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0'
style={{ background: 'transparent' }}
role='combobox'
aria-expanded={state.isOpen}
aria-controls={state.isOpen ? listboxId : undefined}
aria-autocomplete='list'
aria-activedescendant={
state.isOpen && state.highlightedIndex >= 0
? `${listboxId}-option-${state.highlightedIndex}`
: undefined
}
/>
{/* Always-visible text overlay */}
<div className='pointer-events-none absolute inset-0 flex items-center'>
<span className='whitespace-pre font-[380] font-sans text-base leading-none'>
<span className='text-foreground'>{state.inputValue}</span>
{state.showPreview &&
state.previewValue &&
state.previewValue !== state.inputValue &&
state.inputValue && (
<span className='text-muted-foreground/50'>
{state.previewValue.slice(state.inputValue.length)}
{/* Scrollable container for badges */}
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
{/* Applied Filter Badges */}
{appliedFilters.map((filter, index) => (
<Button
key={`${filter.field}-${filter.value}-${index}`}
variant='outline'
className={cn(
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
highlightedBadgeIndex === index && 'border-white dark:border-white'
)}
onClick={(e) => {
e.preventDefault()
removeBadge(index)
}}
>
<span className='text-[var(--text-muted)]'>{filter.field}:</span>
<span className='text-[var(--text-primary)]'>
{filter.operator !== '=' && filter.operator}
{filter.originalValue}
</span>
)}
</span>
</div>
</div>
<X className='h-3 w-3' />
</Button>
))}
{/* Clear all button */}
{(hasFilters || hasTextSearch) && (
<Button
type='button'
variant='ghost'
className='h-6 w-6 p-0 hover:bg-muted/50'
onMouseDown={(e) => {
e.preventDefault()
clearAll()
}}
>
<X className='h-3 w-3' />
</Button>
)}
</div>
{/* Text Search Badge (if present) */}
{hasTextSearch && (
<Button
variant='outline'
className='h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]'
onClick={(e) => {
e.preventDefault()
handleFiltersChange(appliedFilters, '')
}}
>
<span className='text-[var(--text-primary)]'>"{textSearch}"</span>
<X className='h-3 w-3' />
</Button>
)}
{/* Suggestions Dropdown */}
{state.isOpen && state.suggestions.length > 0 && (
<div
ref={dropdownRef}
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden border bg-popover shadow-md'
id={listboxId}
role='listbox'
aria-labelledby={inputId}
>
<div className='max-h-96 overflow-y-auto py-1'>
{state.suggestionType === 'filter-keys' && (
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
SUGGESTED FILTERS
</div>
)}
{state.suggestionType === 'filter-values' && (
<div className='border-border/50 border-b px-3 py-1 font-medium text-muted-foreground/70 text-xs uppercase tracking-wide'>
{state.suggestions[0]?.category?.toUpperCase() || 'VALUES'}
</div>
)}
{/* Input - only current typing */}
<input
ref={inputRef}
type='text'
placeholder={hasFilters || hasTextSearch ? '' : placeholder}
value={currentInput}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
className='min-w-[100px] flex-1 border-0 bg-transparent font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)]'
/>
</div>
{state.suggestions.map((suggestion, index) => (
{/* Clear All Button */}
{(hasFilters || hasTextSearch) && (
<button
key={suggestion.id}
className={cn(
'w-full px-3 py-2 text-left text-sm',
'focus:bg-accent focus:text-accent-foreground focus:outline-none',
'transition-colors hover:bg-accent hover:text-accent-foreground',
index === state.highlightedIndex && 'bg-accent text-accent-foreground'
)}
onMouseEnter={() => {
if (typeof window !== 'undefined' && (window as any).__logsKeyboardNavActive) {
return
}
handleSuggestionHover(index)
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleSuggestionSelect(suggestion)
}}
id={`${listboxId}-option-${index}`}
role='option'
aria-selected={index === state.highlightedIndex}
>
<div className='flex items-center justify-between'>
<div className='flex-1'>
<div className='font-medium text-sm'>{suggestion.label}</div>
{suggestion.description && (
<div className='mt-0.5 text-muted-foreground text-xs'>
{suggestion.description}
</div>
)}
</div>
<div className='ml-4 font-mono text-muted-foreground text-xs'>
{suggestion.value}
</div>
</div>
</button>
))}
</div>
</div>
)}
{/* Active filters as chips */}
{hasFilters && (
<div className='mt-3 flex flex-wrap items-center gap-2'>
<span className='font-medium text-muted-foreground text-xs'>ACTIVE FILTERS:</span>
{parsedQuery.filters.map((filter, index) => (
<Badge
key={`${filter.field}-${filter.value}-${index}`}
variant='secondary'
className='h-6 border border-border/50 bg-muted/50 font-mono text-muted-foreground text-xs hover:bg-muted'
>
<span className='mr-1'>{filter.field}:</span>
<span>
{filter.operator !== '=' && filter.operator}
{filter.originalValue}
</span>
<Button
type='button'
variant='ghost'
className='ml-1 h-3 w-3 p-0 text-muted-foreground hover:bg-muted/50 hover:text-foreground'
onClick={() => removeFilter(filter)}
className='mr-2.5 flex h-5 w-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
onClick={clearAll}
>
<X className='h-2.5 w-2.5' />
</Button>
</Badge>
))}
{parsedQuery.filters.length > 1 && (
<Button
type='button'
variant='ghost'
className='h-6 text-muted-foreground text-xs hover:text-foreground'
onMouseDown={(e) => {
e.preventDefault()
const newQuery = parsedQuery.textSearch
handleInputChange(newQuery, newQuery.length)
if (inputRef.current) {
inputRef.current.focus()
}
}}
>
Clear all
</Button>
)}
</div>
)}
<X className='h-4 w-4' />
</button>
)}
</div>
</PopoverAnchor>
{/* Text search indicator */}
{hasTextSearch && (
<div className='mt-2 flex items-center gap-2'>
<span className='font-medium text-muted-foreground text-xs'>TEXT SEARCH:</span>
<Badge variant='outline' className='text-xs'>
"{parsedQuery.textSearch}"
</Badge>
</div>
)}
{/* Dropdown */}
<PopoverContent
ref={dropdownRef}
className='p-0'
style={{ width: dropdownWidth }}
align='start'
sideOffset={4}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className='max-h-96 overflow-y-auto'>
{sections.length > 0 ? (
// Multi-section layout
<div className='py-1'>
{/* Show all results (no header) */}
{suggestions[0]?.category === 'show-all' && (
<button
key={suggestions[0].id}
data-index={0}
className={cn(
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
highlightedIndex === 0 && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
)}
onMouseEnter={() => setHighlightedIndex(0)}
onMouseDown={(e) => {
e.preventDefault()
handleSuggestionSelect(suggestions[0])
}}
>
<div className='text-[13px]'>{suggestions[0].label}</div>
</button>
)}
{sections.map((section) => (
<div key={section.title}>
<div className='border-border/50 border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
{section.title}
</div>
{section.suggestions.map((suggestion) => {
if (suggestion.category === 'show-all') return null
const index = suggestions.indexOf(suggestion)
const isHighlighted = index === highlightedIndex
return (
<button
key={suggestion.id}
data-index={index}
className={cn(
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
isHighlighted && 'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault()
handleSuggestionSelect(suggestion)
}}
>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0 flex-1 truncate text-[13px]'>
{suggestion.label}
</div>
{suggestion.value !== suggestion.label && (
<div className='flex-shrink-0 font-mono text-[11px] text-[var(--text-muted)]'>
{suggestion.category === 'workflow' ||
suggestion.category === 'folder'
? `${suggestion.category}:`
: ''}
</div>
)}
</div>
</button>
)
})}
</div>
))}
</div>
) : (
// Single section layout
<div className='py-1'>
{suggestionType === 'filters' && (
<div className='border-border/50 border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
SUGGESTED FILTERS
</div>
)}
{suggestions.map((suggestion, index) => (
<button
key={suggestion.id}
data-index={index}
className={cn(
'w-full px-3 py-1.5 text-left transition-colors focus:outline-none',
'hover:bg-[var(--surface-9)] dark:hover:bg-[var(--surface-9)]',
index === highlightedIndex &&
'bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'
)}
onMouseEnter={() => setHighlightedIndex(index)}
onMouseDown={(e) => {
e.preventDefault()
handleSuggestionSelect(suggestion)
}}
>
<div className='flex items-center justify-between gap-3'>
<div className='min-w-0 flex-1 text-[13px]'>{suggestion.label}</div>
{suggestion.description && (
<div className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
{suggestion.value}
</div>
)}
</div>
</button>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -606,10 +606,25 @@ export default function Dashboard() {
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
} else if (mode === 'single') {
// Single mode: Clear all selections and select only this segment
setExpandedWorkflowId(workflowId)
setSelectedSegments({ [workflowId]: [segmentIndex] })
setLastAnchorIndices({ [workflowId]: segmentIndex })
// Single mode: Select this segment, or deselect if already selected
setSelectedSegments((prev) => {
const currentSegments = prev[workflowId] || []
const isOnlySelectedSegment =
currentSegments.length === 1 && currentSegments[0] === segmentIndex
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
// If this is the only selected segment in the only selected workflow, deselect it
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
setExpandedWorkflowId(null)
setLastAnchorIndices({})
return {}
}
// Otherwise, select only this segment
setExpandedWorkflowId(workflowId)
setLastAnchorIndices({ [workflowId]: segmentIndex })
return { [workflowId]: [segmentIndex] }
})
} else if (mode === 'range') {
// Range mode: Expand selection within the current workflow
if (expandedWorkflowId === workflowId) {
@@ -987,6 +1002,51 @@ export default function Dashboard() {
const totalRate =
totalExecutions > 0 ? (totalSuccess / totalExecutions) * 100 : 100
// Calculate overall time range across all selected workflows
let multiWorkflowTimeRange: { start: Date; end: Date } | null = null
if (sortedIndices.length > 0) {
const firstIdx = sortedIndices[0]
const lastIdx = sortedIndices[sortedIndices.length - 1]
// Find earliest start time
let earliestStart: Date | null = null
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
const segment = wf?.segments[firstIdx]
if (segment) {
const start = new Date(segment.timestamp)
if (!earliestStart || start < earliestStart) {
earliestStart = start
}
}
}
// Find latest end time
let latestEnd: Date | null = null
for (const wfId of selectedWorkflowIds) {
const wf = executions.find((w) => w.workflowId === wfId)
const segment = wf?.segments[lastIdx]
if (segment) {
const end = new Date(new Date(segment.timestamp).getTime() + segMs)
if (!latestEnd || end > latestEnd) {
latestEnd = end
}
}
}
if (earliestStart && latestEnd) {
multiWorkflowTimeRange = {
start: earliestStart,
end: latestEnd,
}
}
}
// Get workflow names
const workflowNames = selectedWorkflowIds
.map((id) => executions.find((w) => w.workflowId === id)?.workflowName)
.filter(Boolean) as string[]
return (
<WorkflowDetails
workspaceId={workspaceId}
@@ -1007,8 +1067,11 @@ export default function Dashboard() {
allLogs: allLogs,
} as any
}
selectedSegmentIndex={[]}
selectedSegmentIndex={sortedIndices}
selectedSegment={null}
selectedSegmentTimeRange={multiWorkflowTimeRange}
selectedWorkflowNames={workflowNames}
segmentDurationMs={segMs}
clearSegmentSelection={() => {
setSelectedSegments({})
setLastAnchorIndices({})
@@ -1121,6 +1184,9 @@ export default function Dashboard() {
const idxSet = new Set(workflowSelectedIndices)
const selectedSegs = wf.segments.filter((_, i) => idxSet.has(i))
;(details as any).__filtered = buildSeriesFromSegments(selectedSegs as any)
} else if (details) {
// Clear filtered data when no segments are selected
;(details as any).__filtered = undefined
}
const detailsWithFilteredLogs = details
@@ -1148,6 +1214,28 @@ export default function Dashboard() {
? wf.segments[workflowSelectedIndices[0]]
: null
// Calculate time range for selected segments
const segMs =
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
const selectedSegmentsData = workflowSelectedIndices
.map((idx) => wf.segments[idx])
.filter(Boolean)
const timeRange =
selectedSegmentsData.length > 0
? (() => {
const sortedIndices = [...workflowSelectedIndices].sort((a, b) => a - b)
const firstSegment = wf.segments[sortedIndices[0]]
const lastSegment = wf.segments[sortedIndices[sortedIndices.length - 1]]
if (!firstSegment || !lastSegment) return null
const rangeStart = new Date(firstSegment.timestamp)
const rangeEnd = new Date(lastSegment.timestamp).getTime() + segMs
return {
start: rangeStart,
end: new Date(rangeEnd),
}
})()
: null
return (
<WorkflowDetails
workspaceId={workspaceId}
@@ -1164,6 +1252,9 @@ export default function Dashboard() {
}
: null
}
selectedSegmentTimeRange={timeRange}
selectedWorkflowNames={undefined}
segmentDurationMs={segMs}
clearSegmentSelection={() => {
setSelectedSegments({})
setLastAnchorIndices({})
@@ -1197,6 +1288,9 @@ export default function Dashboard() {
details={globalDetails as any}
selectedSegmentIndex={[]}
selectedSegment={null}
selectedSegmentTimeRange={null}
selectedWorkflowNames={undefined}
segmentDurationMs={undefined}
clearSegmentSelection={() => {
setSelectedSegments({})
setLastAnchorIndices({})

View File

@@ -1,423 +0,0 @@
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
export interface Suggestion {
id: string
value: string
label: string
description?: string
category?:
| 'filters'
| 'level'
| 'trigger'
| 'cost'
| 'date'
| 'duration'
| 'workflow'
| 'folder'
| 'workflowId'
| 'executionId'
}
export interface SuggestionGroup {
type: 'filter-keys' | 'filter-values'
filterKey?: string
suggestions: Suggestion[]
}
interface AutocompleteState {
// Input state
inputValue: string
cursorPosition: number
// Dropdown state
isOpen: boolean
suggestions: Suggestion[]
suggestionType: 'filter-keys' | 'filter-values' | null
highlightedIndex: number
// Preview state
previewValue: string
showPreview: boolean
// Query state
isValidQuery: boolean
pendingQuery: string | null
}
type AutocompleteAction =
| { type: 'SET_INPUT_VALUE'; payload: { value: string; cursorPosition: number } }
| { type: 'SET_CURSOR_POSITION'; payload: number }
| { type: 'OPEN_DROPDOWN'; payload: SuggestionGroup }
| { type: 'CLOSE_DROPDOWN' }
| { type: 'HIGHLIGHT_SUGGESTION'; payload: { index: number; preview?: string } }
| { type: 'SET_PREVIEW'; payload: { value: string; show: boolean } }
| { type: 'CLEAR_PREVIEW' }
| { type: 'SET_QUERY_VALIDITY'; payload: boolean }
| { type: 'SET_PENDING'; payload: string | null }
| { type: 'RESET' }
const initialState: AutocompleteState = {
inputValue: '',
cursorPosition: 0,
isOpen: false,
suggestions: [],
suggestionType: null,
highlightedIndex: -1,
previewValue: '',
showPreview: false,
isValidQuery: true,
pendingQuery: null,
}
function autocompleteReducer(
state: AutocompleteState,
action: AutocompleteAction
): AutocompleteState {
switch (action.type) {
case 'SET_INPUT_VALUE':
return {
...state,
inputValue: action.payload.value,
cursorPosition: action.payload.cursorPosition,
previewValue: '',
showPreview: false,
}
case 'SET_CURSOR_POSITION':
return {
...state,
cursorPosition: action.payload,
}
case 'OPEN_DROPDOWN':
return {
...state,
isOpen: true,
suggestions: action.payload.suggestions,
suggestionType: action.payload.type,
highlightedIndex: action.payload.suggestions.length > 0 ? 0 : -1,
}
case 'CLOSE_DROPDOWN':
return {
...state,
isOpen: false,
suggestions: [],
suggestionType: null,
highlightedIndex: -1,
previewValue: '',
showPreview: false,
}
case 'HIGHLIGHT_SUGGESTION':
return {
...state,
highlightedIndex: action.payload.index,
previewValue: action.payload.preview || '',
showPreview: !!action.payload.preview,
}
case 'SET_PREVIEW':
return {
...state,
previewValue: action.payload.value,
showPreview: action.payload.show,
}
case 'CLEAR_PREVIEW':
return {
...state,
previewValue: '',
showPreview: false,
}
case 'SET_QUERY_VALIDITY':
return {
...state,
isValidQuery: action.payload,
}
case 'SET_PENDING':
return {
...state,
pendingQuery: action.payload,
}
case 'RESET':
return initialState
default:
return state
}
}
export interface AutocompleteOptions {
getSuggestions: (value: string, cursorPosition: number) => SuggestionGroup | null
generatePreview: (suggestion: Suggestion, currentValue: string, cursorPosition: number) => string
onQueryChange: (query: string) => void
validateQuery?: (query: string) => boolean
debounceMs?: number
}
export function useAutocomplete({
getSuggestions,
generatePreview,
onQueryChange,
validateQuery,
debounceMs = 150,
}: AutocompleteOptions) {
const [state, dispatch] = useReducer(autocompleteReducer, initialState)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
const pointerDownInDropdownRef = useRef<boolean>(false)
const latestRef = useRef<{ inputValue: string; cursorPosition: number }>({
inputValue: '',
cursorPosition: 0,
})
useEffect(() => {
latestRef.current.inputValue = state.inputValue
latestRef.current.cursorPosition = state.cursorPosition
}, [state.inputValue, state.cursorPosition])
const currentSuggestion = useMemo(() => {
if (state.highlightedIndex >= 0 && state.suggestions[state.highlightedIndex]) {
return state.suggestions[state.highlightedIndex]
}
return null
}, [state.highlightedIndex, state.suggestions])
const updateSuggestions = useCallback(() => {
const { inputValue, cursorPosition } = latestRef.current
const suggestionGroup = getSuggestions(inputValue, cursorPosition)
if (suggestionGroup && suggestionGroup.suggestions.length > 0) {
dispatch({ type: 'OPEN_DROPDOWN', payload: suggestionGroup })
const firstSuggestion = suggestionGroup.suggestions[0]
const preview = generatePreview(firstSuggestion, inputValue, cursorPosition)
dispatch({
type: 'HIGHLIGHT_SUGGESTION',
payload: { index: 0, preview },
})
} else {
dispatch({ type: 'CLOSE_DROPDOWN' })
}
}, [getSuggestions, generatePreview])
const handleInputChange = useCallback(
(value: string, cursorPosition: number) => {
dispatch({ type: 'SET_INPUT_VALUE', payload: { value, cursorPosition } })
const isValid = validateQuery ? validateQuery(value) : true
dispatch({ type: 'SET_QUERY_VALIDITY', payload: isValid })
if (isValid) {
onQueryChange(value)
}
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
dispatch({ type: 'SET_PENDING', payload: value })
debounceRef.current = setTimeout(() => {
dispatch({ type: 'SET_PENDING', payload: null })
updateSuggestions()
}, debounceMs)
},
[updateSuggestions, onQueryChange, validateQuery, debounceMs]
)
const handleCursorChange = useCallback(
(position: number) => {
dispatch({ type: 'SET_CURSOR_POSITION', payload: position })
updateSuggestions()
},
[updateSuggestions]
)
const handleSuggestionHover = useCallback(
(index: number) => {
if (index >= 0 && index < state.suggestions.length) {
const suggestion = state.suggestions[index]
const preview = generatePreview(suggestion, state.inputValue, state.cursorPosition)
dispatch({
type: 'HIGHLIGHT_SUGGESTION',
payload: { index, preview },
})
}
},
[state.suggestions, state.inputValue, state.cursorPosition, generatePreview]
)
const handleSuggestionSelect = useCallback(
(suggestion?: Suggestion) => {
const selectedSuggestion = suggestion || currentSuggestion
if (!selectedSuggestion) return
let newValue = generatePreview(selectedSuggestion, state.inputValue, state.cursorPosition)
let newCursorPosition = newValue.length
if (state.suggestionType === 'filter-keys' && selectedSuggestion.value.endsWith(':')) {
newCursorPosition = newValue.lastIndexOf(':') + 1
} else if (state.suggestionType === 'filter-values') {
newValue = `${newValue} `
newCursorPosition = newValue.length
}
dispatch({
type: 'SET_INPUT_VALUE',
payload: { value: newValue, cursorPosition: newCursorPosition },
})
const isValid = validateQuery ? validateQuery(newValue.trim()) : true
dispatch({ type: 'SET_QUERY_VALIDITY', payload: isValid })
if (isValid) {
onQueryChange(newValue.trim())
}
if (inputRef.current) {
inputRef.current.focus()
requestAnimationFrame(() => {
if (inputRef.current) {
inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
}
})
}
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
dispatch({ type: 'SET_PENDING', payload: null })
setTimeout(updateSuggestions, 0)
},
[
currentSuggestion,
state.inputValue,
state.cursorPosition,
state.suggestionType,
generatePreview,
onQueryChange,
validateQuery,
updateSuggestions,
]
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
event.preventDefault()
if (state.isOpen) {
handleSuggestionSelect()
} else if (state.isValidQuery) {
updateSuggestions()
}
return
}
if (!state.isOpen) return
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
const nextIndex = Math.min(state.highlightedIndex + 1, state.suggestions.length - 1)
handleSuggestionHover(nextIndex)
break
}
case 'ArrowUp': {
event.preventDefault()
const prevIndex = Math.max(state.highlightedIndex - 1, 0)
handleSuggestionHover(prevIndex)
break
}
case 'Escape':
event.preventDefault()
dispatch({ type: 'CLOSE_DROPDOWN' })
break
case 'Tab':
if (currentSuggestion) {
event.preventDefault()
handleSuggestionSelect()
} else {
dispatch({ type: 'CLOSE_DROPDOWN' })
}
break
}
},
[
state.isOpen,
state.highlightedIndex,
state.suggestions.length,
handleSuggestionHover,
handleSuggestionSelect,
currentSuggestion,
]
)
const handleFocus = useCallback(() => {
updateSuggestions()
}, [updateSuggestions])
const handleBlur = useCallback((e?: React.FocusEvent) => {
const related = (e?.relatedTarget as Node) || document.activeElement
const isInsideDropdown = related && dropdownRef.current?.contains(related)
const isInsideInput = related && inputRef.current === related
if (pointerDownInDropdownRef.current || isInsideDropdown || isInsideInput) {
return
}
setTimeout(() => {
dispatch({ type: 'CLOSE_DROPDOWN' })
}, 150)
}, [])
useEffect(() => {
const dropdownEl = dropdownRef.current
if (!dropdownEl) return
const onPointerDown = () => {
pointerDownInDropdownRef.current = true
}
const onPointerUp = () => {
setTimeout(() => {
pointerDownInDropdownRef.current = false
}, 0)
}
dropdownEl.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointerup', onPointerUp)
return () => {
dropdownEl.removeEventListener('pointerdown', onPointerDown)
window.removeEventListener('pointerup', onPointerUp)
}
}, [])
return {
// State
state,
currentSuggestion,
// Refs
inputRef,
dropdownRef,
// Handlers
handleInputChange,
handleCursorChange,
handleSuggestionHover,
handleSuggestionSelect,
handleKeyDown,
handleFocus,
handleBlur,
// Actions
closeDropdown: () => dispatch({ type: 'CLOSE_DROPDOWN' }),
clearPreview: () => dispatch({ type: 'CLEAR_PREVIEW' }),
reset: () => dispatch({ type: 'RESET' }),
}
}

View File

@@ -0,0 +1,291 @@
import { useCallback, useRef, useState } from 'react'
import type { ParsedFilter } from '@/lib/logs/query-parser'
import type {
Suggestion,
SuggestionGroup,
SuggestionSection,
} from '@/app/workspace/[workspaceId]/logs/types/search'
interface UseSearchStateOptions {
onFiltersChange: (filters: ParsedFilter[], textSearch: string) => void
getSuggestions: (input: string) => SuggestionGroup | null
debounceMs?: number
}
export function useSearchState({
onFiltersChange,
getSuggestions,
debounceMs = 100,
}: UseSearchStateOptions) {
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>([])
const [currentInput, setCurrentInput] = useState('')
const [textSearch, setTextSearch] = useState('')
// Dropdown state
const [isOpen, setIsOpen] = useState(false)
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [sections, setSections] = useState<SuggestionSection[]>([])
const [highlightedIndex, setHighlightedIndex] = useState(-1)
// Badge interaction
const [highlightedBadgeIndex, setHighlightedBadgeIndex] = useState<number | null>(null)
// Refs
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<NodeJS.Timeout | null>(null)
// Update suggestions when input changes
const updateSuggestions = useCallback(
(input: string) => {
const suggestionGroup = getSuggestions(input)
if (suggestionGroup && suggestionGroup.suggestions.length > 0) {
setSuggestions(suggestionGroup.suggestions)
setSections(suggestionGroup.sections || [])
setIsOpen(true)
setHighlightedIndex(0)
} else {
setIsOpen(false)
setSuggestions([])
setSections([])
setHighlightedIndex(-1)
}
},
[getSuggestions]
)
// Handle input changes
const handleInputChange = useCallback(
(value: string) => {
setCurrentInput(value)
setHighlightedBadgeIndex(null) // Clear badge highlight on any input
// Debounce suggestion updates
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
updateSuggestions(value)
}, debounceMs)
},
[updateSuggestions, debounceMs]
)
// Handle suggestion selection
const handleSuggestionSelect = useCallback(
(suggestion: Suggestion) => {
if (suggestion.category === 'show-all') {
// Treat as text search
setTextSearch(suggestion.value)
setCurrentInput('')
setIsOpen(false)
onFiltersChange(appliedFilters, suggestion.value)
return
}
// Check if this is a filter-key suggestion (ends with ':')
if (suggestion.category === 'filters' && suggestion.value.endsWith(':')) {
// Set input to the filter key and keep dropdown open for values
setCurrentInput(suggestion.value)
updateSuggestions(suggestion.value)
return
}
// For filter values, workflows, folders - add as a filter
const newFilter: ParsedFilter = {
field: suggestion.value.split(':')[0] as any,
operator: '=',
value: suggestion.value.includes(':')
? suggestion.value.split(':').slice(1).join(':').replace(/"/g, '')
: suggestion.value.replace(/"/g, ''),
originalValue: suggestion.value.includes(':')
? suggestion.value.split(':').slice(1).join(':')
: suggestion.value,
}
const updatedFilters = [...appliedFilters, newFilter]
setAppliedFilters(updatedFilters)
setCurrentInput('')
setTextSearch('')
// Notify parent
onFiltersChange(updatedFilters, '')
// Focus back on input and reopen dropdown with empty suggestions
if (inputRef.current) {
inputRef.current.focus()
}
// Show filter keys dropdown again after selection
setTimeout(() => {
updateSuggestions('')
}, 50)
},
[appliedFilters, onFiltersChange, updateSuggestions]
)
// Remove a badge
const removeBadge = useCallback(
(index: number) => {
const updatedFilters = appliedFilters.filter((_, i) => i !== index)
setAppliedFilters(updatedFilters)
setHighlightedBadgeIndex(null)
onFiltersChange(updatedFilters, textSearch)
if (inputRef.current) {
inputRef.current.focus()
}
},
[appliedFilters, textSearch, onFiltersChange]
)
// Handle keyboard navigation
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
// Backspace on empty input - badge deletion
if (event.key === 'Backspace' && currentInput === '') {
event.preventDefault()
if (highlightedBadgeIndex !== null) {
// Delete highlighted badge
removeBadge(highlightedBadgeIndex)
} else if (appliedFilters.length > 0) {
// Highlight last badge
setHighlightedBadgeIndex(appliedFilters.length - 1)
}
return
}
// Clear badge highlight on any other key when not in dropdown navigation
if (
highlightedBadgeIndex !== null &&
!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)
) {
setHighlightedBadgeIndex(null)
}
// Enter key
if (event.key === 'Enter') {
event.preventDefault()
if (isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
handleSuggestionSelect(suggestions[highlightedIndex])
} else if (currentInput.trim()) {
// Submit current input as text search
setTextSearch(currentInput.trim())
setCurrentInput('')
setIsOpen(false)
onFiltersChange(appliedFilters, currentInput.trim())
}
return
}
// Dropdown navigation
if (!isOpen) return
switch (event.key) {
case 'ArrowDown': {
event.preventDefault()
setHighlightedIndex((prev) => Math.min(prev + 1, suggestions.length - 1))
break
}
case 'ArrowUp': {
event.preventDefault()
setHighlightedIndex((prev) => Math.max(prev - 1, 0))
break
}
case 'Escape': {
event.preventDefault()
setIsOpen(false)
setHighlightedIndex(-1)
break
}
case 'Tab': {
if (highlightedIndex >= 0 && suggestions[highlightedIndex]) {
event.preventDefault()
handleSuggestionSelect(suggestions[highlightedIndex])
}
break
}
}
},
[
currentInput,
highlightedBadgeIndex,
appliedFilters,
isOpen,
highlightedIndex,
suggestions,
handleSuggestionSelect,
removeBadge,
onFiltersChange,
]
)
// Handle focus
const handleFocus = useCallback(() => {
updateSuggestions(currentInput)
}, [currentInput, updateSuggestions])
// Handle blur
const handleBlur = useCallback(() => {
setTimeout(() => {
setIsOpen(false)
setHighlightedIndex(-1)
}, 150)
}, [])
// Clear all filters
const clearAll = useCallback(() => {
setAppliedFilters([])
setCurrentInput('')
setTextSearch('')
setIsOpen(false)
onFiltersChange([], '')
if (inputRef.current) {
inputRef.current.focus()
}
}, [onFiltersChange])
// Initialize from external value (URL params, etc.)
const initializeFromQuery = useCallback((query: string, filters: ParsedFilter[]) => {
setAppliedFilters(filters)
setTextSearch(query)
setCurrentInput('')
}, [])
return {
// State
appliedFilters,
currentInput,
textSearch,
isOpen,
suggestions,
sections,
highlightedIndex,
highlightedBadgeIndex,
// Refs
inputRef,
dropdownRef,
// Handlers
handleInputChange,
handleSuggestionSelect,
handleKeyDown,
handleFocus,
handleBlur,
removeBadge,
clearAll,
initializeFromQuery,
// Setters for external control
setHighlightedIndex,
}
}

View File

@@ -711,9 +711,7 @@ export default function Logs() {
value={searchQuery}
onChange={setSearchQuery}
placeholder='Search logs...'
availableWorkflows={availableWorkflows}
availableFolders={availableFolders}
onOpenChange={(open) => {
onOpenChange={(open: boolean) => {
isSearchOpenRef.current = open
}}
/>
@@ -840,8 +838,16 @@ export default function Logs() {
{/* Workflow */}
<div className='min-w-0'>
<div className='truncate font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{log.workflow?.name || 'Unknown Workflow'}
<div className='flex items-center gap-2 truncate'>
<div
className='h-[12px] w-[12px] flex-shrink-0 rounded'
style={{
backgroundColor: log.workflow?.color || '#64748b',
}}
/>
<span className='truncate font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
{log.workflow?.name || 'Unknown Workflow'}
</span>
</div>
</div>

View File

@@ -0,0 +1,30 @@
export interface Suggestion {
id: string
value: string
label: string
description?: string
category?:
| 'filters'
| 'level'
| 'trigger'
| 'cost'
| 'date'
| 'duration'
| 'workflow'
| 'folder'
| 'workflowId'
| 'executionId'
| 'show-all'
}
export interface SuggestionSection {
title: string
suggestions: Suggestion[]
}
export interface SuggestionGroup {
type: 'filter-keys' | 'filter-values' | 'multi-section'
filterKey?: string
suggestions: Suggestion[]
sections?: SuggestionSection[]
}

View File

@@ -447,7 +447,7 @@ export function SearchModal({
if (open && selectedIndex >= 0) {
const element = document.querySelector(`[data-search-item-index="${selectedIndex}"]`)
if (element) {
element.scrollIntoView({ block: 'nearest' })
element.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
}
}, [selectedIndex, open])

View File

@@ -151,7 +151,9 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
case 'level':
case 'status':
if (filter.operator === '=') {
params.level = filter.value as string
const existing = params.level ? params.level.split(',') : []
existing.push(filter.value as string)
params.level = existing.join(',')
}
break

View File

@@ -1,182 +0,0 @@
import { describe, expect, it } from 'vitest'
import { SearchSuggestions } from './search-suggestions'
describe('SearchSuggestions', () => {
const engine = new SearchSuggestions(['workflow1', 'workflow2'], ['folder1', 'folder2'])
describe('validateQuery', () => {
it.concurrent('should return false for incomplete filter expressions', () => {
expect(engine.validateQuery('level:')).toBe(false)
expect(engine.validateQuery('trigger:')).toBe(false)
expect(engine.validateQuery('cost:')).toBe(false)
expect(engine.validateQuery('some text level:')).toBe(false)
})
it.concurrent('should return false for incomplete quoted strings', () => {
expect(engine.validateQuery('workflow:"incomplete')).toBe(false)
expect(engine.validateQuery('level:error workflow:"incomplete')).toBe(false)
expect(engine.validateQuery('"incomplete string')).toBe(false)
})
it.concurrent('should return true for complete queries', () => {
expect(engine.validateQuery('level:error')).toBe(true)
expect(engine.validateQuery('trigger:api')).toBe(true)
expect(engine.validateQuery('cost:>0.01')).toBe(true)
expect(engine.validateQuery('workflow:"test workflow"')).toBe(true)
expect(engine.validateQuery('level:error trigger:api')).toBe(true)
expect(engine.validateQuery('some search text')).toBe(true)
expect(engine.validateQuery('')).toBe(true)
})
it.concurrent('should return true for mixed complete queries', () => {
expect(engine.validateQuery('search text level:error')).toBe(true)
expect(engine.validateQuery('level:error some search')).toBe(true)
expect(engine.validateQuery('workflow:"test" level:error search')).toBe(true)
})
})
describe('getSuggestions', () => {
it.concurrent('should return filter key suggestions at the beginning', () => {
const result = engine.getSuggestions('', 0)
expect(result?.type).toBe('filter-keys')
expect(result?.suggestions.length).toBeGreaterThan(0)
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
})
it.concurrent('should return value suggestions for uniquely identified partial keys', () => {
const result = engine.getSuggestions('lev', 3)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'error' || s.value === 'info')).toBe(true)
})
it.concurrent('should return filter value suggestions after colon', () => {
const result = engine.getSuggestions('level:', 6)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.length).toBeGreaterThan(0)
expect(result?.suggestions.some((s) => s.value === 'error')).toBe(true)
})
it.concurrent('should return filtered value suggestions for partial values', () => {
const result = engine.getSuggestions('level:err', 9)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'error')).toBe(true)
})
it.concurrent('should handle workflow suggestions', () => {
const result = engine.getSuggestions('workflow:', 9)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.label === 'workflow1')).toBe(true)
})
it.concurrent('should return null for text search context', () => {
const result = engine.getSuggestions('some random text', 10)
expect(result).toBe(null)
})
it.concurrent('should show filter key suggestions after completing a filter', () => {
const result = engine.getSuggestions('level:error ', 12)
expect(result?.type).toBe('filter-keys')
expect(result?.suggestions.length).toBeGreaterThan(0)
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
expect(result?.suggestions.some((s) => s.value === 'trigger:')).toBe(true)
})
it.concurrent('should show filter key suggestions after multiple completed filters', () => {
const result = engine.getSuggestions('level:error trigger:api ', 24)
expect(result?.type).toBe('filter-keys')
expect(result?.suggestions.length).toBeGreaterThan(0)
})
it.concurrent(
'should surface value suggestions for uniquely matched partial keys after existing filters',
() => {
const result = engine.getSuggestions('level:error lev', 15)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'error' || s.value === 'info')).toBe(
true
)
}
)
it.concurrent('should handle filter values after existing filters', () => {
const result = engine.getSuggestions('level:error level:', 18)
expect(result?.type).toBe('filter-values')
expect(result?.suggestions.some((s) => s.value === 'info')).toBe(true)
})
})
describe('generatePreview', () => {
it.concurrent('should generate correct preview for filter keys', () => {
const suggestion = {
id: 'test',
value: 'level:',
label: 'Status',
category: 'filters' as const,
}
const preview = engine.generatePreview(suggestion, '', 0)
expect(preview).toBe('level:')
})
it.concurrent('should generate correct preview for filter values', () => {
const suggestion = { id: 'test', value: 'error', label: 'Error', category: 'level' as const }
const preview = engine.generatePreview(suggestion, 'level:', 6)
expect(preview).toBe('level:error')
})
it.concurrent('should handle partial replacements correctly', () => {
const suggestion = {
id: 'test',
value: 'level:',
label: 'Status',
category: 'filters' as const,
}
const preview = engine.generatePreview(suggestion, 'lev', 3)
expect(preview).toBe('level:')
})
it.concurrent('should handle quoted workflow values', () => {
const suggestion = {
id: 'test',
value: '"workflow1"',
label: 'workflow1',
category: 'workflow' as const,
}
const preview = engine.generatePreview(suggestion, 'workflow:', 9)
expect(preview).toBe('workflow:"workflow1"')
})
it.concurrent('should add space when adding filter after completed filter', () => {
const suggestion = {
id: 'test',
value: 'trigger:',
label: 'Trigger',
category: 'filters' as const,
}
const preview = engine.generatePreview(suggestion, 'level:error ', 12)
expect(preview).toBe('level:error trigger:')
})
it.concurrent('should handle multiple completed filters', () => {
const suggestion = { id: 'test', value: 'cost:', label: 'Cost', category: 'filters' as const }
const preview = engine.generatePreview(suggestion, 'level:error trigger:api ', 24)
expect(preview).toBe('level:error trigger:api cost:')
})
it.concurrent('should handle adding same filter type multiple times', () => {
const suggestion = {
id: 'test',
value: 'level:',
label: 'Status',
category: 'filters' as const,
}
const preview = engine.generatePreview(suggestion, 'level:error ', 12)
expect(preview).toBe('level:error level:')
})
it.concurrent('should handle filter value after existing filters', () => {
const suggestion = { id: 'test', value: 'info', label: 'Info', category: 'level' as const }
const preview = engine.generatePreview(suggestion, 'level:error level:', 19)
expect(preview).toBe('level:error level:info')
})
})
})

View File

@@ -1,7 +1,4 @@
import type {
Suggestion,
SuggestionGroup,
} from '@/app/workspace/[workspaceId]/logs/hooks/use-autocomplete'
import type { Suggestion, SuggestionGroup } from '@/app/workspace/[workspaceId]/logs/types/search'
export interface FilterDefinition {
key: string
@@ -14,6 +11,17 @@ export interface FilterDefinition {
}>
}
export interface WorkflowData {
id: string
name: string
description?: string
}
export interface FolderData {
id: string
name: string
}
export const FILTER_DEFINITIONS: FilterDefinition[] = [
{
key: 'level',
@@ -62,10 +70,6 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
{ 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" },
// Friendly relative range shortcuts like Stripe
{ value: '"> 2 days ago"', label: '> 2 days ago', description: 'Newer than 2 days' },
{ value: '"> last week"', label: '> last week', description: 'Newer than last week' },
{ value: '">=2025/08/31"', label: '>= YYYY/MM/DD', description: 'Start date (YYYY/MM/DD)' },
],
},
{
@@ -82,395 +86,348 @@ export const FILTER_DEFINITIONS: FilterDefinition[] = [
},
]
interface QueryContext {
type: 'initial' | 'filter-key-partial' | 'filter-value-context' | 'text-search'
filterKey?: string
partialInput?: string
startPosition?: number
endPosition?: number
}
export class SearchSuggestions {
private availableWorkflows: string[]
private availableFolders: string[]
private workflowsData: WorkflowData[]
private foldersData: FolderData[]
constructor(availableWorkflows: string[] = [], availableFolders: string[] = []) {
this.availableWorkflows = availableWorkflows
this.availableFolders = availableFolders
constructor(workflowsData: WorkflowData[] = [], foldersData: FolderData[] = []) {
this.workflowsData = workflowsData
this.foldersData = foldersData
}
updateAvailableData(workflows: string[] = [], folders: string[] = []) {
this.availableWorkflows = workflows
this.availableFolders = folders
updateData(workflowsData: WorkflowData[] = [], foldersData: FolderData[] = []) {
this.workflowsData = workflowsData
this.foldersData = foldersData
}
/**
* Check if a filter value is complete (matches a valid option)
* Get suggestions based ONLY on current input (no cursor position!)
*/
private isCompleteFilterValue(filterKey: string, value: string): boolean {
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === filterKey)
if (filterDef) {
return filterDef.options.some((option) => option.value === value)
getSuggestions(input: string): SuggestionGroup | null {
const trimmed = input.trim()
// Empty input → show all filter keys
if (!trimmed) {
return this.getFilterKeysList()
}
// For workflow and folder filters, any quoted value is considered complete
if (filterKey === 'workflow' || filterKey === 'folder') {
return value.startsWith('"') && value.endsWith('"') && value.length > 2
// Input ends with ':' → show values for that key
if (trimmed.endsWith(':')) {
const key = trimmed.slice(0, -1)
return this.getFilterValues(key)
}
return false
// Input contains ':' → filter value context
if (trimmed.includes(':')) {
const [key, partial] = trimmed.split(':')
return this.getFilterValues(key, partial)
}
// Plain text → multi-section results
return this.getMultiSectionResults(trimmed)
}
/**
* Analyze the current input context to determine what suggestions to show.
* Get filter keys list (empty input state)
*/
private analyzeContext(input: string, cursorPosition: number): QueryContext {
const textBeforeCursor = input.slice(0, cursorPosition)
if (textBeforeCursor === '' || textBeforeCursor.endsWith(' ')) {
return { type: 'initial' }
}
// Check for filter value context (must be after a space or at start, and not empty value)
const filterValueMatch = textBeforeCursor.match(/(?:^|\s)(\w+):([\w"<>=!]*)$/)
if (filterValueMatch && filterValueMatch[2].length > 0 && !filterValueMatch[2].includes(' ')) {
const filterKey = filterValueMatch[1]
const filterValue = filterValueMatch[2]
// If the filter value is complete, treat as ready for next filter
if (this.isCompleteFilterValue(filterKey, filterValue)) {
return { type: 'initial' }
}
// Otherwise, treat as partial value needing completion
return {
type: 'filter-value-context',
filterKey,
partialInput: filterValue,
startPosition:
filterValueMatch.index! +
(filterValueMatch[0].startsWith(' ') ? 1 : 0) +
filterKey.length +
1,
endPosition: cursorPosition,
}
}
// Check for empty filter key (just "key:" with no value)
const emptyFilterMatch = textBeforeCursor.match(/(?:^|\s)(\w+):$/)
if (emptyFilterMatch) {
return { type: 'initial' } // Treat as initial to show filter value suggestions
}
const filterKeyMatch = textBeforeCursor.match(/(?:^|\s)(\w+):?$/)
if (filterKeyMatch && !filterKeyMatch[0].includes(':')) {
return {
type: 'filter-key-partial',
partialInput: filterKeyMatch[1],
startPosition: filterKeyMatch.index! + (filterKeyMatch[0].startsWith(' ') ? 1 : 0),
endPosition: cursorPosition,
}
}
return { type: 'text-search' }
}
/**
* Get filter key suggestions
*/
private getFilterKeySuggestions(partialInput?: string): Suggestion[] {
private getFilterKeysList(): SuggestionGroup {
const suggestions: Suggestion[] = []
// Add all filter keys
for (const filter of FILTER_DEFINITIONS) {
const matchesPartial =
!partialInput ||
filter.key.toLowerCase().startsWith(partialInput.toLowerCase()) ||
filter.label.toLowerCase().startsWith(partialInput.toLowerCase())
if (matchesPartial) {
suggestions.push({
id: `filter-key-${filter.key}`,
value: `${filter.key}:`,
label: filter.label,
description: filter.description,
category: 'filters',
})
}
}
if (this.availableWorkflows.length > 0) {
const matchesWorkflow =
!partialInput ||
'workflow'.startsWith(partialInput.toLowerCase()) ||
'workflows'.startsWith(partialInput.toLowerCase())
if (matchesWorkflow) {
suggestions.push({
id: 'filter-key-workflow',
value: 'workflow:',
label: 'Workflow',
description: 'Filter by workflow name',
category: 'filters',
})
}
}
if (this.availableFolders.length > 0) {
const matchesFolder =
!partialInput ||
'folder'.startsWith(partialInput.toLowerCase()) ||
'folders'.startsWith(partialInput.toLowerCase())
if (matchesFolder) {
suggestions.push({
id: 'filter-key-folder',
value: 'folder:',
label: 'Folder',
description: 'Filter by folder name',
category: 'filters',
})
}
}
// Always include id-based keys (workflowId, executionId)
const idKeys: Array<{ key: string; label: string; description: string }> = [
{ key: 'workflowId', label: 'Workflow ID', description: 'Filter by workflowId' },
{ key: 'executionId', label: 'Execution ID', description: 'Filter by executionId' },
]
for (const idDef of idKeys) {
const matchesIdKey =
!partialInput ||
idDef.key.toLowerCase().startsWith(partialInput.toLowerCase()) ||
idDef.label.toLowerCase().startsWith(partialInput.toLowerCase())
if (matchesIdKey) {
suggestions.push({
id: `filter-key-${idDef.key}`,
value: `${idDef.key}:`,
label: idDef.label,
description: idDef.description,
category: 'filters',
})
}
}
return suggestions
}
/**
* Get filter value suggestions for a specific filter key
*/
private getFilterValueSuggestions(filterKey: string, partialInput = ''): Suggestion[] {
const suggestions: Suggestion[] = []
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === filterKey)
if (filterDef) {
for (const option of filterDef.options) {
const matchesPartial =
!partialInput ||
option.value.toLowerCase().includes(partialInput.toLowerCase()) ||
option.label.toLowerCase().includes(partialInput.toLowerCase())
if (matchesPartial) {
suggestions.push({
id: `filter-value-${filterKey}-${option.value}`,
value: option.value,
label: option.label,
description: option.description,
category: filterKey as any,
})
}
}
return suggestions
}
if (filterKey === 'workflow') {
for (const workflow of this.availableWorkflows) {
const matchesPartial =
!partialInput || workflow.toLowerCase().includes(partialInput.toLowerCase())
if (matchesPartial) {
suggestions.push({
id: `filter-value-workflow-${workflow}`,
value: `"${workflow}"`,
label: workflow,
description: 'Workflow name',
category: 'workflow',
})
}
}
return suggestions.slice(0, 8)
}
if (filterKey === 'folder') {
for (const folder of this.availableFolders) {
const matchesPartial =
!partialInput || folder.toLowerCase().includes(partialInput.toLowerCase())
if (matchesPartial) {
suggestions.push({
id: `filter-value-folder-${folder}`,
value: `"${folder}"`,
label: folder,
description: 'Folder name',
category: 'folder',
})
}
}
return suggestions.slice(0, 8)
}
if (filterKey === 'workflowId' || filterKey === 'executionId') {
const example = partialInput || '"1234..."'
suggestions.push({
id: `filter-value-${filterKey}-example`,
value: example,
label: 'Enter exact ID',
description: 'Use quotes for the full ID',
category: filterKey,
id: `filter-key-${filter.key}`,
value: `${filter.key}:`,
label: filter.label,
description: filter.description,
category: 'filters',
})
return suggestions
}
return suggestions
// Add workflow and folder keys
if (this.workflowsData.length > 0) {
suggestions.push({
id: 'filter-key-workflow',
value: 'workflow:',
label: 'Workflow',
description: 'Filter by workflow name',
category: 'filters',
})
}
if (this.foldersData.length > 0) {
suggestions.push({
id: 'filter-key-folder',
value: 'folder:',
label: 'Folder',
description: 'Filter by folder name',
category: 'filters',
})
}
suggestions.push({
id: 'filter-key-workflowId',
value: 'workflowId:',
label: 'Workflow ID',
description: 'Filter by workflow ID',
category: 'filters',
})
suggestions.push({
id: 'filter-key-executionId',
value: 'executionId:',
label: 'Execution ID',
description: 'Filter by execution ID',
category: 'filters',
})
return {
type: 'filter-keys',
suggestions,
}
}
/**
* Get suggestions based on current input and cursor position
* Get filter values for a specific key
*/
getSuggestions(input: string, cursorPosition: number): SuggestionGroup | null {
const context = this.analyzeContext(input, cursorPosition)
private getFilterValues(key: string, partial = ''): SuggestionGroup | null {
const filterDef = FILTER_DEFINITIONS.find((f) => f.key === key)
// Special case: check if we're at "key:" position for filter values
const textBeforeCursor = input.slice(0, cursorPosition)
const emptyFilterMatch = textBeforeCursor.match(/(?:^|\s)(\w+):$/)
if (emptyFilterMatch) {
const filterKey = emptyFilterMatch[1]
const filterValueSuggestions = this.getFilterValueSuggestions(filterKey, '')
return filterValueSuggestions.length > 0
if (filterDef) {
const suggestions = filterDef.options
.filter(
(opt) =>
!partial ||
opt.value.toLowerCase().includes(partial.toLowerCase()) ||
opt.label.toLowerCase().includes(partial.toLowerCase())
)
.map((opt) => ({
id: `filter-value-${key}-${opt.value}`,
value: `${key}:${opt.value}`,
label: opt.label,
description: opt.description,
category: key as any,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey,
suggestions: filterValueSuggestions,
filterKey: key,
suggestions,
}
: null
}
switch (context.type) {
case 'initial':
case 'filter-key-partial': {
if (context.type === 'filter-key-partial' && context.partialInput) {
const matches = FILTER_DEFINITIONS.filter(
(f) =>
f.key.toLowerCase().startsWith(context.partialInput!.toLowerCase()) ||
f.label.toLowerCase().startsWith(context.partialInput!.toLowerCase())
)
// Workflow filter values
if (key === 'workflow') {
const suggestions = this.workflowsData
.filter((w) => !partial || w.name.toLowerCase().includes(partial.toLowerCase()))
.slice(0, 8)
.map((w) => ({
id: `filter-value-workflow-${w.id}`,
value: `workflow:"${w.name}"`,
label: w.name,
description: w.description,
category: 'workflow' as const,
}))
if (matches.length === 1) {
const key = matches[0].key
const filterValueSuggestions = this.getFilterValueSuggestions(key, '')
if (filterValueSuggestions.length > 0) {
return {
type: 'filter-values',
filterKey: key,
suggestions: filterValueSuggestions,
}
}
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'workflow',
suggestions,
}
: null
}
// Folder filter values
if (key === 'folder') {
const suggestions = this.foldersData
.filter((f) => !partial || f.name.toLowerCase().includes(partial.toLowerCase()))
.slice(0, 8)
.map((f) => ({
id: `filter-value-folder-${f.id}`,
value: `folder:"${f.name}"`,
label: f.name,
category: 'folder' as const,
}))
return suggestions.length > 0
? {
type: 'filter-values',
filterKey: 'folder',
suggestions,
}
: null
}
return null
}
/**
* Get multi-section results for plain text
*/
private getMultiSectionResults(query: string): SuggestionGroup | null {
const sections: Array<{ title: string; suggestions: Suggestion[] }> = []
const allSuggestions: Suggestion[] = []
// Show all results option
const showAllSuggestion: Suggestion = {
id: 'show-all',
value: query,
label: `Show all results for "${query}"`,
category: 'show-all',
}
allSuggestions.push(showAllSuggestion)
// Match filter values (e.g., "info" → "Status: Info")
const matchingFilterValues = this.getMatchingFilterValues(query)
if (matchingFilterValues.length > 0) {
sections.push({
title: 'SUGGESTED FILTERS',
suggestions: matchingFilterValues,
})
allSuggestions.push(...matchingFilterValues)
}
// Match workflows
const matchingWorkflows = this.getMatchingWorkflows(query)
if (matchingWorkflows.length > 0) {
sections.push({
title: 'WORKFLOWS',
suggestions: matchingWorkflows,
})
allSuggestions.push(...matchingWorkflows)
}
// Match folders
const matchingFolders = this.getMatchingFolders(query)
if (matchingFolders.length > 0) {
sections.push({
title: 'FOLDERS',
suggestions: matchingFolders,
})
allSuggestions.push(...matchingFolders)
}
// Add filter keys if no specific matches
if (
matchingFilterValues.length === 0 &&
matchingWorkflows.length === 0 &&
matchingFolders.length === 0
) {
const filterKeys = this.getFilterKeysList()
if (filterKeys.suggestions.length > 0) {
sections.push({
title: 'SUGGESTED FILTERS',
suggestions: filterKeys.suggestions.slice(0, 5),
})
allSuggestions.push(...filterKeys.suggestions.slice(0, 5))
}
}
return allSuggestions.length > 0
? {
type: 'multi-section',
suggestions: allSuggestions,
sections,
}
const filterKeySuggestions = this.getFilterKeySuggestions(context.partialInput)
return filterKeySuggestions.length > 0
? {
type: 'filter-keys',
suggestions: filterKeySuggestions,
}
: null
}
case 'filter-value-context': {
if (!context.filterKey) return null
const filterValueSuggestions = this.getFilterValueSuggestions(
context.filterKey,
context.partialInput
)
return filterValueSuggestions.length > 0
? {
type: 'filter-values',
filterKey: context.filterKey,
suggestions: filterValueSuggestions,
}
: null
}
default:
return null
}
: null
}
/**
* Generate preview text for a suggestion
* Show suggestion at the end of input, with proper spacing logic
* Match filter values across all definitions
*/
generatePreview(suggestion: Suggestion, currentValue: string, cursorPosition: number): string {
// If input is empty, just show the suggestion
if (!currentValue.trim()) {
return suggestion.value
}
private getMatchingFilterValues(query: string): Suggestion[] {
if (!query.trim()) return []
// Check if we're doing a partial replacement (like "lev" -> "level:")
const context = this.analyzeContext(currentValue, cursorPosition)
const matches: Suggestion[] = []
const lowerQuery = query.toLowerCase()
if (
context.type === 'filter-key-partial' &&
context.startPosition !== undefined &&
context.endPosition !== undefined
) {
const before = currentValue.slice(0, context.startPosition)
const after = currentValue.slice(context.endPosition)
const isFilterValue =
!!suggestion.category && FILTER_DEFINITIONS.some((f) => f.key === suggestion.category)
if (isFilterValue) {
return `${before}${suggestion.category}:${suggestion.value}${after}`
for (const filterDef of FILTER_DEFINITIONS) {
for (const option of filterDef.options) {
if (
option.value.toLowerCase().includes(lowerQuery) ||
option.label.toLowerCase().includes(lowerQuery)
) {
matches.push({
id: `filter-match-${filterDef.key}-${option.value}`,
value: `${filterDef.key}:${option.value}`,
label: `${filterDef.label}: ${option.label}`,
description: option.description,
category: filterDef.key as any,
})
}
}
return `${before}${suggestion.value}${after}`
}
if (
context.type === 'filter-value-context' &&
context.startPosition !== undefined &&
context.endPosition !== undefined
) {
const before = currentValue.slice(0, context.startPosition)
const after = currentValue.slice(context.endPosition)
return `${before}${suggestion.value}${after}`
}
let result = currentValue
if (currentValue.endsWith(':')) {
result += suggestion.value
} else if (currentValue.endsWith(' ')) {
result += suggestion.value
} else {
result += ` ${suggestion.value}`
}
return result
return matches.slice(0, 5)
}
/**
* Validate if a query is complete and should trigger backend calls
* Match workflows by name/description
*/
validateQuery(query: string): boolean {
const incompleteFilterMatch = query.match(/(\w+):$/)
if (incompleteFilterMatch) {
return false
}
private getMatchingWorkflows(query: string): Suggestion[] {
if (!query.trim() || this.workflowsData.length === 0) return []
const openQuotes = (query.match(/"/g) || []).length
if (openQuotes % 2 !== 0) {
return false
}
const lowerQuery = query.toLowerCase()
return true
const matches = this.workflowsData
.filter(
(workflow) =>
workflow.name.toLowerCase().includes(lowerQuery) ||
workflow.description?.toLowerCase().includes(lowerQuery)
)
.sort((a, b) => {
const aName = a.name.toLowerCase()
const bName = b.name.toLowerCase()
if (aName === lowerQuery) return -1
if (bName === lowerQuery) return 1
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1
if (bName.startsWith(lowerQuery) && !aName.startsWith(lowerQuery)) return 1
return aName.localeCompare(bName)
})
.slice(0, 8)
.map((workflow) => ({
id: `workflow-match-${workflow.id}`,
value: `workflow:"${workflow.name}"`,
label: workflow.name,
description: workflow.description,
category: 'workflow' as const,
}))
return matches
}
/**
* Match folders by name
*/
private getMatchingFolders(query: string): Suggestion[] {
if (!query.trim() || this.foldersData.length === 0) return []
const lowerQuery = query.toLowerCase()
const matches = this.foldersData
.filter((folder) => folder.name.toLowerCase().includes(lowerQuery))
.sort((a, b) => {
const aName = a.name.toLowerCase()
const bName = b.name.toLowerCase()
if (aName === lowerQuery) return -1
if (bName === lowerQuery) return 1
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1
if (bName.startsWith(lowerQuery) && !aName.startsWith(lowerQuery)) return 1
return aName.localeCompare(bName)
})
.slice(0, 8)
.map((folder) => ({
id: `folder-match-${folder.id}`,
value: `folder:"${folder.name}"`,
label: folder.name,
category: 'folder' as const,
}))
return matches
}
}