mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-03 11:14:58 -05:00
improvement(search): added more granular logs search, added logs export, improved overall search experience (#1378)
* improvement(search): added more granular logs search, added logs export, improved overall search experience * updated tests
This commit is contained in:
200
apps/sim/app/api/logs/export/route.ts
Normal file
200
apps/sim/app/api/logs/export/route.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { db } from '@sim/db'
|
||||
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('LogsExportAPI')
|
||||
|
||||
export const revalidate = 0
|
||||
|
||||
const ExportParamsSchema = z.object({
|
||||
level: z.string().optional(),
|
||||
workflowIds: z.string().optional(),
|
||||
folderIds: z.string().optional(),
|
||||
triggers: z.string().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
folderName: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
})
|
||||
|
||||
function escapeCsv(value: any): string {
|
||||
if (value === null || value === undefined) return ''
|
||||
const str = String(value)
|
||||
if (/[",\n]/.test(str)) {
|
||||
return `"${str.replace(/"/g, '""')}"`
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const params = ExportParamsSchema.parse(Object.fromEntries(searchParams.entries()))
|
||||
|
||||
const selectColumns = {
|
||||
id: workflowExecutionLogs.id,
|
||||
workflowId: workflowExecutionLogs.workflowId,
|
||||
executionId: workflowExecutionLogs.executionId,
|
||||
level: workflowExecutionLogs.level,
|
||||
trigger: workflowExecutionLogs.trigger,
|
||||
startedAt: workflowExecutionLogs.startedAt,
|
||||
endedAt: workflowExecutionLogs.endedAt,
|
||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||
cost: workflowExecutionLogs.cost,
|
||||
executionData: workflowExecutionLogs.executionData,
|
||||
workflowName: workflow.name,
|
||||
}
|
||||
|
||||
let conditions: SQL | undefined = eq(workflow.workspaceId, params.workspaceId)
|
||||
|
||||
if (params.level && params.level !== 'all') {
|
||||
conditions = and(conditions, eq(workflowExecutionLogs.level, params.level))
|
||||
}
|
||||
|
||||
if (params.workflowIds) {
|
||||
const workflowIds = params.workflowIds.split(',').filter(Boolean)
|
||||
if (workflowIds.length > 0) conditions = and(conditions, inArray(workflow.id, workflowIds))
|
||||
}
|
||||
|
||||
if (params.folderIds) {
|
||||
const folderIds = params.folderIds.split(',').filter(Boolean)
|
||||
if (folderIds.length > 0) conditions = and(conditions, inArray(workflow.folderId, folderIds))
|
||||
}
|
||||
|
||||
if (params.triggers) {
|
||||
const triggers = params.triggers.split(',').filter(Boolean)
|
||||
if (triggers.length > 0 && !triggers.includes('all')) {
|
||||
conditions = and(conditions, inArray(workflowExecutionLogs.trigger, triggers))
|
||||
}
|
||||
}
|
||||
|
||||
if (params.startDate) {
|
||||
conditions = and(conditions, gte(workflowExecutionLogs.startedAt, new Date(params.startDate)))
|
||||
}
|
||||
if (params.endDate) {
|
||||
conditions = and(conditions, lte(workflowExecutionLogs.startedAt, new Date(params.endDate)))
|
||||
}
|
||||
|
||||
if (params.search) {
|
||||
const term = `%${params.search}%`
|
||||
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${term}`)
|
||||
}
|
||||
if (params.workflowName) {
|
||||
const nameTerm = `%${params.workflowName}%`
|
||||
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
|
||||
}
|
||||
if (params.folderName) {
|
||||
const folderTerm = `%${params.folderName}%`
|
||||
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
|
||||
}
|
||||
|
||||
const header = [
|
||||
'startedAt',
|
||||
'level',
|
||||
'workflow',
|
||||
'trigger',
|
||||
'durationMs',
|
||||
'costTotal',
|
||||
'workflowId',
|
||||
'executionId',
|
||||
'message',
|
||||
'traceSpans',
|
||||
].join(',')
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start: async (controller) => {
|
||||
controller.enqueue(encoder.encode(`${header}\n`))
|
||||
const pageSize = 1000
|
||||
let offset = 0
|
||||
try {
|
||||
while (true) {
|
||||
const rows = await db
|
||||
.select(selectColumns)
|
||||
.from(workflowExecutionLogs)
|
||||
.innerJoin(workflow, eq(workflowExecutionLogs.workflowId, workflow.id))
|
||||
.innerJoin(
|
||||
permissions,
|
||||
and(
|
||||
eq(permissions.entityType, 'workspace'),
|
||||
eq(permissions.entityId, workflow.workspaceId),
|
||||
eq(permissions.userId, userId)
|
||||
)
|
||||
)
|
||||
.where(conditions)
|
||||
.orderBy(desc(workflowExecutionLogs.startedAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
|
||||
if (!rows.length) break
|
||||
|
||||
for (const r of rows as any[]) {
|
||||
let message = ''
|
||||
let traces: any = null
|
||||
try {
|
||||
const ed = (r as any).executionData
|
||||
if (ed) {
|
||||
if (ed.finalOutput)
|
||||
message =
|
||||
typeof ed.finalOutput === 'string'
|
||||
? ed.finalOutput
|
||||
: JSON.stringify(ed.finalOutput)
|
||||
if (ed.message) message = ed.message
|
||||
if (ed.traceSpans) traces = ed.traceSpans
|
||||
}
|
||||
} catch {}
|
||||
const line = [
|
||||
escapeCsv(r.startedAt?.toISOString?.() || r.startedAt),
|
||||
escapeCsv(r.level),
|
||||
escapeCsv(r.workflowName),
|
||||
escapeCsv(r.trigger),
|
||||
escapeCsv(r.totalDurationMs ?? ''),
|
||||
escapeCsv(r.cost?.total ?? r.cost?.value?.total ?? ''),
|
||||
escapeCsv(r.workflowId ?? ''),
|
||||
escapeCsv(r.executionId ?? ''),
|
||||
escapeCsv(message),
|
||||
escapeCsv(traces ? JSON.stringify(traces) : ''),
|
||||
].join(',')
|
||||
controller.enqueue(encoder.encode(`${line}\n`))
|
||||
}
|
||||
|
||||
offset += pageSize
|
||||
}
|
||||
controller.close()
|
||||
} catch (e: any) {
|
||||
logger.error('Export stream error', { error: e?.message })
|
||||
try {
|
||||
controller.error(e)
|
||||
} catch {}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `logs-${ts}.csv`
|
||||
|
||||
return new NextResponse(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error('Export error', { error: error?.message })
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ const QueryParamsSchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
workflowName: z.string().optional(),
|
||||
folderName: z.string().optional(),
|
||||
workspaceId: z.string(),
|
||||
})
|
||||
|
||||
@@ -155,6 +157,18 @@ export async function GET(request: NextRequest) {
|
||||
conditions = and(conditions, sql`${workflowExecutionLogs.executionId} ILIKE ${searchTerm}`)
|
||||
}
|
||||
|
||||
// Filter by workflow name (from advanced search input)
|
||||
if (params.workflowName) {
|
||||
const nameTerm = `%${params.workflowName}%`
|
||||
conditions = and(conditions, sql`${workflow.name} ILIKE ${nameTerm}`)
|
||||
}
|
||||
|
||||
// Filter by folder name (best-effort text match when present on workflows)
|
||||
if (params.folderName) {
|
||||
const folderTerm = `%${params.folderName}%`
|
||||
conditions = and(conditions, sql`${workflow.name} ILIKE ${folderTerm}`)
|
||||
}
|
||||
|
||||
// Execute the query using the optimized join
|
||||
const logs = await baseQuery
|
||||
.where(conditions)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
@@ -26,20 +27,27 @@ interface WorkflowOption {
|
||||
}
|
||||
|
||||
export default function Workflow() {
|
||||
const { workflowIds, toggleWorkflowId, setWorkflowIds } = useFilterStore()
|
||||
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
|
||||
const params = useParams()
|
||||
const workspaceId = params?.workspaceId as string | undefined
|
||||
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
// Fetch all available workflows from the API
|
||||
useEffect(() => {
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/workflows')
|
||||
const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''
|
||||
const response = await fetch(`/api/workflows${query}`)
|
||||
if (response.ok) {
|
||||
const { data } = await response.json()
|
||||
const workflowOptions: WorkflowOption[] = data.map((workflow: any) => ({
|
||||
const scoped = Array.isArray(data)
|
||||
? folderIds.length > 0
|
||||
? data.filter((w: any) => (w.folderId ? folderIds.includes(w.folderId) : false))
|
||||
: data
|
||||
: []
|
||||
const workflowOptions: WorkflowOption[] = scoped.map((workflow: any) => ({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
color: workflow.color || '#3972F6',
|
||||
@@ -54,7 +62,7 @@ export default function Workflow() {
|
||||
}
|
||||
|
||||
fetchWorkflows()
|
||||
}, [])
|
||||
}, [workspaceId, folderIds])
|
||||
|
||||
const getSelectedWorkflowsText = () => {
|
||||
if (workflowIds.length === 0) return 'All workflows'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Loader2, Search, X } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -17,6 +17,7 @@ interface AutocompleteSearchProps {
|
||||
availableWorkflows?: string[]
|
||||
availableFolders?: string[]
|
||||
className?: string
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function AutocompleteSearch({
|
||||
@@ -26,6 +27,7 @@ export function AutocompleteSearch({
|
||||
availableWorkflows = [],
|
||||
availableFolders = [],
|
||||
className,
|
||||
onOpenChange,
|
||||
}: AutocompleteSearchProps) {
|
||||
const suggestionEngine = useMemo(() => {
|
||||
return new SearchSuggestions(availableWorkflows, availableFolders)
|
||||
@@ -42,6 +44,8 @@ export function AutocompleteSearch({
|
||||
handleKeyDown,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
reset: resetAutocomplete,
|
||||
closeDropdown,
|
||||
} = useAutocomplete({
|
||||
getSuggestions: (inputValue, cursorPos) =>
|
||||
suggestionEngine.getSuggestions(inputValue, cursorPos),
|
||||
@@ -52,10 +56,39 @@ export function AutocompleteSearch({
|
||||
debounceMs: 100,
|
||||
})
|
||||
|
||||
const clearAll = () => {
|
||||
resetAutocomplete()
|
||||
closeDropdown()
|
||||
onChange('')
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
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' })
|
||||
}
|
||||
}
|
||||
}, [state.isOpen, state.highlightedIndex])
|
||||
|
||||
const onInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursorPos = e.target.selectionStart || 0
|
||||
@@ -77,8 +110,10 @@ export function AutocompleteSearch({
|
||||
)
|
||||
|
||||
const newQuery = [...filterStrings, parsedQuery.textSearch].filter(Boolean).join(' ')
|
||||
|
||||
onChange(newQuery)
|
||||
handleInputChange(newQuery, newQuery.length)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -91,24 +126,37 @@ export function AutocompleteSearch({
|
||||
state.isOpen && 'ring-1 ring-ring'
|
||||
)}
|
||||
>
|
||||
<Search className='h-4 w-4 flex-shrink-0 text-muted-foreground' strokeWidth={2} />
|
||||
{state.pendingQuery ? (
|
||||
<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} />
|
||||
)}
|
||||
|
||||
{/* 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)}
|
||||
onKeyUp={(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 */}
|
||||
@@ -134,7 +182,10 @@ export function AutocompleteSearch({
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 hover:bg-muted/50'
|
||||
onClick={() => onChange('')}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
clearAll()
|
||||
}}
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
@@ -145,7 +196,10 @@ export function AutocompleteSearch({
|
||||
{state.isOpen && state.suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className='absolute z-[9999] mt-1 w-full min-w-[500px] overflow-hidden rounded-md border bg-popover shadow-md'
|
||||
className='min-w[500px] absolute z-[9999] mt-1 w-full overflow-hidden rounded-md 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' && (
|
||||
@@ -168,12 +222,20 @@ export function AutocompleteSearch({
|
||||
'transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
index === state.highlightedIndex && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onMouseEnter={() => handleSuggestionHover(index)}
|
||||
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'>
|
||||
@@ -226,7 +288,14 @@ export function AutocompleteSearch({
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 text-muted-foreground text-xs hover:text-foreground'
|
||||
onClick={() => onChange(parsedQuery.textSearch)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
const newQuery = parsedQuery.textSearch
|
||||
handleInputChange(newQuery, newQuery.length)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useCallback, useMemo, useReducer, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
|
||||
|
||||
export interface Suggestion {
|
||||
id: string
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
category?: string
|
||||
category?:
|
||||
| 'filters'
|
||||
| 'level'
|
||||
| 'trigger'
|
||||
| 'cost'
|
||||
| 'date'
|
||||
| 'duration'
|
||||
| 'workflow'
|
||||
| 'folder'
|
||||
| 'workflowId'
|
||||
| 'executionId'
|
||||
}
|
||||
|
||||
export interface SuggestionGroup {
|
||||
@@ -43,6 +53,7 @@ type AutocompleteAction =
|
||||
| { 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 = {
|
||||
@@ -126,6 +137,12 @@ function autocompleteReducer(
|
||||
isValidQuery: action.payload,
|
||||
}
|
||||
|
||||
case 'SET_PENDING':
|
||||
return {
|
||||
...state,
|
||||
pendingQuery: action.payload,
|
||||
}
|
||||
|
||||
case 'RESET':
|
||||
return initialState
|
||||
|
||||
@@ -153,6 +170,16 @@ export function useAutocomplete({
|
||||
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]) {
|
||||
@@ -162,13 +189,14 @@ export function useAutocomplete({
|
||||
}, [state.highlightedIndex, state.suggestions])
|
||||
|
||||
const updateSuggestions = useCallback(() => {
|
||||
const suggestionGroup = getSuggestions(state.inputValue, state.cursorPosition)
|
||||
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, state.inputValue, state.cursorPosition)
|
||||
const preview = generatePreview(firstSuggestion, inputValue, cursorPosition)
|
||||
dispatch({
|
||||
type: 'HIGHLIGHT_SUGGESTION',
|
||||
payload: { index: 0, preview },
|
||||
@@ -176,7 +204,7 @@ export function useAutocomplete({
|
||||
} else {
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
}
|
||||
}, [state.inputValue, state.cursorPosition, getSuggestions, generatePreview])
|
||||
}, [getSuggestions, generatePreview])
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(value: string, cursorPosition: number) => {
|
||||
@@ -193,7 +221,11 @@ export function useAutocomplete({
|
||||
clearTimeout(debounceRef.current)
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(updateSuggestions, debounceMs)
|
||||
dispatch({ type: 'SET_PENDING', payload: value })
|
||||
debounceRef.current = setTimeout(() => {
|
||||
dispatch({ type: 'SET_PENDING', payload: null })
|
||||
updateSuggestions()
|
||||
}, debounceMs)
|
||||
},
|
||||
[updateSuggestions, onQueryChange, validateQuery, debounceMs]
|
||||
)
|
||||
@@ -257,6 +289,11 @@ export function useAutocomplete({
|
||||
})
|
||||
}
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
}
|
||||
dispatch({ type: 'SET_PENDING', payload: null })
|
||||
setTimeout(updateSuggestions, 0)
|
||||
},
|
||||
[
|
||||
@@ -273,6 +310,16 @@ export function useAutocomplete({
|
||||
|
||||
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) {
|
||||
@@ -290,11 +337,6 @@ export function useAutocomplete({
|
||||
break
|
||||
}
|
||||
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
handleSuggestionSelect()
|
||||
break
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
dispatch({ type: 'CLOSE_DROPDOWN' })
|
||||
@@ -324,12 +366,37 @@ export function useAutocomplete({
|
||||
updateSuggestions()
|
||||
}, [updateSuggestions])
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
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,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { AutocompleteSearch } from '@/app/workspace/[workspaceId]/logs/component
|
||||
import { Sidebar } from '@/app/workspace/[workspaceId]/logs/components/sidebar/sidebar'
|
||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils/format-date'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import type { LogsResponse, WorkflowLog } from '@/stores/logs/filters/types'
|
||||
|
||||
@@ -77,7 +78,6 @@ export default function Logs() {
|
||||
triggers,
|
||||
} = useFilterStore()
|
||||
|
||||
// Set workspace ID in store when component mounts or workspaceId changes
|
||||
useEffect(() => {
|
||||
setWorkspaceId(workspaceId)
|
||||
}, [workspaceId])
|
||||
@@ -94,11 +94,9 @@ export default function Logs() {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const isInitialized = useRef<boolean>(false)
|
||||
|
||||
// Local search state with debouncing for the header
|
||||
const [searchQuery, setSearchQuery] = useState(storeSearchQuery)
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
|
||||
// Available data for suggestions
|
||||
const [availableWorkflows, setAvailableWorkflows] = useState<string[]>([])
|
||||
const [availableFolders, setAvailableFolders] = useState<string[]>([])
|
||||
|
||||
@@ -106,29 +104,63 @@ export default function Logs() {
|
||||
const [isLive, setIsLive] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const liveIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const isSearchOpenRef = useRef<boolean>(false)
|
||||
|
||||
// Sync local search query with store search query
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeSearchQuery)
|
||||
}, [storeSearchQuery])
|
||||
|
||||
const { fetchFolders, getFolderTree } = useFolderStore()
|
||||
|
||||
useEffect(() => {
|
||||
const workflowNames = new Set<string>()
|
||||
const folderNames = new Set<string>()
|
||||
let cancelled = false
|
||||
|
||||
logs.forEach((log) => {
|
||||
if (log.workflow?.name) {
|
||||
workflowNames.add(log.workflow.name)
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/workflows?workspaceId=${encodeURIComponent(workspaceId)}`)
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
const names: string[] = Array.isArray(body?.data)
|
||||
? body.data.map((w: any) => w?.name).filter(Boolean)
|
||||
: []
|
||||
if (!cancelled) setAvailableWorkflows(names)
|
||||
} else {
|
||||
if (!cancelled) setAvailableWorkflows([])
|
||||
}
|
||||
|
||||
await fetchFolders(workspaceId)
|
||||
const tree = getFolderTree(workspaceId)
|
||||
|
||||
const flatten = (nodes: any[], parentPath = ''): string[] => {
|
||||
const out: string[] = []
|
||||
for (const n of nodes) {
|
||||
const path = parentPath ? `${parentPath} / ${n.name}` : n.name
|
||||
out.push(path)
|
||||
if (n.children?.length) out.push(...flatten(n.children, path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const folderPaths: string[] = Array.isArray(tree) ? flatten(tree) : []
|
||||
if (!cancelled) setAvailableFolders(folderPaths)
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setAvailableWorkflows([])
|
||||
setAvailableFolders([])
|
||||
}
|
||||
}
|
||||
// Note: folder info would need to be added to the logs response
|
||||
// For now, we'll leave folders empty
|
||||
})
|
||||
}
|
||||
|
||||
setAvailableWorkflows(Array.from(workflowNames).slice(0, 10)) // Limit to top 10
|
||||
setAvailableFolders([]) // TODO: Add folder data to logs response
|
||||
}, [logs])
|
||||
if (workspaceId) {
|
||||
fetchSuggestions()
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [workspaceId, fetchFolders, getFolderTree])
|
||||
|
||||
// Update store when debounced search query changes
|
||||
useEffect(() => {
|
||||
if (isInitialized.current && debouncedSearchQuery !== storeSearchQuery) {
|
||||
setStoreSearchQuery(debouncedSearchQuery)
|
||||
@@ -142,12 +174,10 @@ export default function Logs() {
|
||||
setIsSidebarOpen(true)
|
||||
setIsDetailsLoading(true)
|
||||
|
||||
// Fetch details for current, previous, and next concurrently with cache
|
||||
const currentId = log.id
|
||||
const prevId = index > 0 ? logs[index - 1]?.id : undefined
|
||||
const nextId = index < logs.length - 1 ? logs[index + 1]?.id : undefined
|
||||
|
||||
// Abort any previous details fetch batch
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
@@ -167,7 +197,6 @@ export default function Logs() {
|
||||
if (nextId && !detailsCacheRef.current.has(nextId))
|
||||
idsToFetch.push({ id: nextId, merge: false })
|
||||
|
||||
// Merge cached current immediately
|
||||
if (cachedCurrent) {
|
||||
setSelectedLog((prev) =>
|
||||
prev && prev.id === currentId
|
||||
@@ -207,7 +236,6 @@ export default function Logs() {
|
||||
setSelectedLogIndex(nextIndex)
|
||||
const nextLog = logs[nextIndex]
|
||||
setSelectedLog(nextLog)
|
||||
// Abort any previous details fetch batch
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
@@ -265,7 +293,6 @@ export default function Logs() {
|
||||
setSelectedLogIndex(prevIndex)
|
||||
const prevLog = logs[prevIndex]
|
||||
setSelectedLog(prevLog)
|
||||
// Abort any previous details fetch batch
|
||||
if (detailsAbortRef.current) {
|
||||
try {
|
||||
detailsAbortRef.current.abort()
|
||||
@@ -340,19 +367,16 @@ export default function Logs() {
|
||||
setIsFetchingMore(true)
|
||||
}
|
||||
|
||||
// Get fresh query params by calling buildQueryParams from store
|
||||
const { buildQueryParams: getCurrentQueryParams } = useFilterStore.getState()
|
||||
const queryParams = getCurrentQueryParams(pageNum, LOGS_PER_PAGE)
|
||||
|
||||
// Parse the current search query for enhanced filtering
|
||||
const parsedQuery = parseQuery(searchQuery)
|
||||
const { searchQuery: currentSearchQuery } = useFilterStore.getState()
|
||||
const parsedQuery = parseQuery(currentSearchQuery)
|
||||
const enhancedParams = queryToApiParams(parsedQuery)
|
||||
|
||||
// Add enhanced search parameters to the query string
|
||||
const allParams = new URLSearchParams(queryParams)
|
||||
Object.entries(enhancedParams).forEach(([key, value]) => {
|
||||
if (key === 'triggers' && allParams.has('triggers')) {
|
||||
// Combine triggers from both sources
|
||||
const existingTriggers = allParams.get('triggers')?.split(',') || []
|
||||
const searchTriggers = value.split(',')
|
||||
const combined = [...new Set([...existingTriggers, ...searchTriggers])]
|
||||
@@ -429,7 +453,27 @@ export default function Logs() {
|
||||
setIsLive(!isLive)
|
||||
}
|
||||
|
||||
// Initialize filters from URL on mount
|
||||
const handleExport = async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('workspaceId', workspaceId)
|
||||
if (level !== 'all') params.set('level', level)
|
||||
if (triggers.length > 0) params.set('triggers', triggers.join(','))
|
||||
if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(','))
|
||||
if (folderIds.length > 0) params.set('folderIds', folderIds.join(','))
|
||||
|
||||
const parsed = parseQuery(debouncedSearchQuery)
|
||||
const extra = queryToApiParams(parsed)
|
||||
Object.entries(extra).forEach(([k, v]) => params.set(k, v))
|
||||
|
||||
const url = `/api/logs/export?${params.toString()}`
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'logs_export.csv'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized.current) {
|
||||
isInitialized.current = true
|
||||
@@ -437,7 +481,6 @@ export default function Logs() {
|
||||
}
|
||||
}, [initializeFromURL])
|
||||
|
||||
// Handle browser navigation events (back/forward)
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
initializeFromURL()
|
||||
@@ -447,43 +490,34 @@ export default function Logs() {
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [initializeFromURL])
|
||||
|
||||
// Single useEffect to handle both initial load and filter changes
|
||||
useEffect(() => {
|
||||
// Only fetch logs after initialization
|
||||
if (!isInitialized.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Reset pagination and fetch from beginning
|
||||
setPage(1)
|
||||
setHasMore(true)
|
||||
|
||||
// Inline fetch logic to avoid circular dependency
|
||||
const fetchWithFilters = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Build query params inline to avoid dependency issues
|
||||
const params = new URLSearchParams()
|
||||
params.set('details', 'basic')
|
||||
params.set('limit', LOGS_PER_PAGE.toString())
|
||||
params.set('offset', '0') // Always start from page 1
|
||||
params.set('workspaceId', workspaceId)
|
||||
|
||||
// Parse the search query for enhanced filtering
|
||||
const parsedQuery = parseQuery(searchQuery)
|
||||
const parsedQuery = parseQuery(debouncedSearchQuery)
|
||||
const enhancedParams = queryToApiParams(parsedQuery)
|
||||
|
||||
// Add filters from store
|
||||
if (level !== 'all') params.set('level', level)
|
||||
if (triggers.length > 0) params.set('triggers', triggers.join(','))
|
||||
if (workflowIds.length > 0) params.set('workflowIds', workflowIds.join(','))
|
||||
if (folderIds.length > 0) params.set('folderIds', folderIds.join(','))
|
||||
|
||||
// Add enhanced search parameters (these may override some store filters)
|
||||
Object.entries(enhancedParams).forEach(([key, value]) => {
|
||||
if (key === 'triggers' && params.has('triggers')) {
|
||||
// Combine triggers from both sources
|
||||
const storeTriggers = params.get('triggers')?.split(',') || []
|
||||
const searchTriggers = value.split(',')
|
||||
const combined = [...new Set([...storeTriggers, ...searchTriggers])]
|
||||
@@ -493,7 +527,6 @@ export default function Logs() {
|
||||
}
|
||||
})
|
||||
|
||||
// Add time range filter
|
||||
if (timeRange !== 'All time') {
|
||||
const now = new Date()
|
||||
let startDate: Date
|
||||
@@ -532,7 +565,7 @@ export default function Logs() {
|
||||
}
|
||||
|
||||
fetchWithFilters()
|
||||
}, [workspaceId, timeRange, level, workflowIds, folderIds, searchQuery, triggers])
|
||||
}, [workspaceId, timeRange, level, workflowIds, folderIds, debouncedSearchQuery, triggers])
|
||||
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (!isFetchingMore && hasMore) {
|
||||
@@ -598,6 +631,7 @@ export default function Logs() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isSearchOpenRef.current) return
|
||||
if (logs.length === 0) return
|
||||
|
||||
if (selectedLogIndex === -1 && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
@@ -651,9 +685,12 @@ export default function Logs() {
|
||||
placeholder='Search logs...'
|
||||
availableWorkflows={availableWorkflows}
|
||||
availableFolders={availableFolders}
|
||||
onOpenChange={(open) => {
|
||||
isSearchOpenRef.current = open
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className='flex flex-shrink-0 items-center gap-3'>
|
||||
<div className='ml-auto flex flex-shrink-0 items-center gap-3'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -674,6 +711,34 @@ export default function Logs() {
|
||||
<TooltipContent>{isRefreshing ? 'Refreshing...' : 'Refresh'}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={handleExport}
|
||||
className='h-9 rounded-[11px] hover:bg-secondary'
|
||||
aria-label='Export CSV'
|
||||
>
|
||||
{/* Download icon */}
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Export CSV</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
className={`group h-9 gap-2 rounded-[11px] border bg-card text-card-foreground shadow-xs transition-all duration-200 hover:border-[var(--brand-primary-hex)] hover:bg-[var(--brand-primary-hex)] hover:text-white ${
|
||||
isLive
|
||||
|
||||
@@ -169,6 +169,12 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
|
||||
}
|
||||
break
|
||||
|
||||
case 'folder':
|
||||
if (filter.operator === '=') {
|
||||
params.folderName = filter.value as string
|
||||
}
|
||||
break
|
||||
|
||||
case 'execution':
|
||||
if (filter.operator === '=' && parsedQuery.textSearch) {
|
||||
params.search = `${parsedQuery.textSearch} ${filter.value}`.trim()
|
||||
@@ -177,6 +183,18 @@ export function queryToApiParams(parsedQuery: ParsedQuery): Record<string, strin
|
||||
}
|
||||
break
|
||||
|
||||
case 'workflowId':
|
||||
if (filter.operator === '=') {
|
||||
params.workflowIds = String(filter.value)
|
||||
}
|
||||
break
|
||||
|
||||
case 'executionId':
|
||||
if (filter.operator === '=') {
|
||||
params.executionId = String(filter.value)
|
||||
}
|
||||
break
|
||||
|
||||
case 'date':
|
||||
if (filter.operator === '=' && filter.value === 'today') {
|
||||
const today = new Date()
|
||||
|
||||
@@ -43,10 +43,10 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should return filter key suggestions for partial matches', () => {
|
||||
it.concurrent('should return value suggestions for uniquely identified partial keys', () => {
|
||||
const result = engine.getSuggestions('lev', 3)
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
|
||||
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', () => {
|
||||
@@ -87,11 +87,16 @@ describe('SearchSuggestions', () => {
|
||||
expect(result?.suggestions.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it.concurrent('should handle partial filter keys after existing filters', () => {
|
||||
const result = engine.getSuggestions('level:error lev', 15)
|
||||
expect(result?.type).toBe('filter-keys')
|
||||
expect(result?.suggestions.some((s) => s.value === 'level:')).toBe(true)
|
||||
})
|
||||
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)
|
||||
|
||||
@@ -62,6 +62,10 @@ 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)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -228,6 +232,27 @@ export class SearchSuggestions {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -251,7 +276,7 @@ export class SearchSuggestions {
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
category: filterKey,
|
||||
category: filterKey as any,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -294,6 +319,18 @@ export class SearchSuggestions {
|
||||
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,
|
||||
})
|
||||
return suggestions
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
@@ -321,6 +358,26 @@ export class SearchSuggestions {
|
||||
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())
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filterKeySuggestions = this.getFilterKeySuggestions(context.partialInput)
|
||||
return filterKeySuggestions.length > 0
|
||||
? {
|
||||
@@ -367,9 +424,13 @@ export class SearchSuggestions {
|
||||
context.startPosition !== undefined &&
|
||||
context.endPosition !== undefined
|
||||
) {
|
||||
// Replace partial text: "lev" -> "level:"
|
||||
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}`
|
||||
}
|
||||
return `${before}${suggestion.value}${after}`
|
||||
}
|
||||
|
||||
@@ -378,23 +439,18 @@ export class SearchSuggestions {
|
||||
context.startPosition !== undefined &&
|
||||
context.endPosition !== undefined
|
||||
) {
|
||||
// Replace partial filter value: "level:err" -> "level:error"
|
||||
const before = currentValue.slice(0, context.startPosition)
|
||||
const after = currentValue.slice(context.endPosition)
|
||||
return `${before}${suggestion.value}${after}`
|
||||
}
|
||||
|
||||
// For all other cases, append at the end with smart spacing:
|
||||
let result = currentValue
|
||||
|
||||
if (currentValue.endsWith(':')) {
|
||||
// Direct append for filter values: "level:" + "error" = "level:error"
|
||||
result += suggestion.value
|
||||
} else if (currentValue.endsWith(' ')) {
|
||||
// Already has space, direct append: "level:error " + "trigger:" = "level:error trigger:"
|
||||
result += suggestion.value
|
||||
} else {
|
||||
// Need space: "level:error" + " " + "trigger:" = "level:error trigger:"
|
||||
result += ` ${suggestion.value}`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user