mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(logs): improved logs search (#1985)
* improvement(logs): improved logs search * more * ack PR comments
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
30
apps/sim/app/workspace/[workspaceId]/logs/types/search.ts
Normal file
30
apps/sim/app/workspace/[workspaceId]/logs/types/search.ts
Normal 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[]
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user