Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions frontend/src/features/log-explorer/components/AddFilterButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Filter, Loader2, Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/shared/lib/utils'
import { Button } from '@/shared/components/ui/button'
import { Input } from '@/shared/components/ui/input'
import { logExplorerHttpService as svc } from '../services/log-explorer-http.service'
import type {
FilterOperator,
FilterType,
IndexField,
IndexPattern,
TopValues,
} from '../types/log-explorer.types'
import { OP_KEY, SELECT_CLS } from './log-explorer.constants'

/* Explicit field/operator/value filter builder — pick a field, an operator, and
* a value from the field's real existing values (fetched via top-x-values). */
const BUILDER_OPS: { id: FilterOperator; label: string; needsValue: boolean }[] = [
{ id: 'IS', label: 'is', needsValue: true },
{ id: 'IS_NOT', label: 'is not', needsValue: true },
{ id: 'CONTAIN', label: 'contains', needsValue: true },
{ id: 'EXIST', label: 'exists', needsValue: false },
]

export function AddFilterButton({
pattern,
fields,
filters,
onAdd,
}: {
pattern: IndexPattern
fields: IndexField[]
filters: FilterType[]
onAdd: (f: FilterType) => void
}) {
const { t } = useTranslation()
const selectable = useMemo(
() => fields.filter((f) => !f.name.endsWith('.keyword')).sort((a, b) => a.name.localeCompare(b.name)),
[fields]
)
const [open, setOpen] = useState(false)
const [field, setField] = useState('')
const [operator, setOperator] = useState<FilterOperator>('IS')
const [values, setValues] = useState<TopValues['top']>([])
const [loadingValues, setLoadingValues] = useState(false)
const [vq, setVq] = useState('')
const ref = useRef<HTMLDivElement>(null)

useEffect(() => {
if (open && !field && selectable.length) setField(selectable[0].name)
}, [open, field, selectable])

useEffect(() => {
if (!open) return
const onDoc = (e: MouseEvent) => ref.current && !ref.current.contains(e.target as Node) && setOpen(false)
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [open])

const op = BUILDER_OPS.find((o) => o.id === operator)
const needsValue = op?.needsValue ?? true
const fieldDef = selectable.find((f) => f.name === field)
const aggField = fieldDef?.type === 'text' && !field.endsWith('.keyword') ? `${field}.keyword` : field

useEffect(() => {
if (!open || !needsValue || !field) return
setLoadingValues(true)
setValues([])
svc
.topValues(pattern.pattern, aggField, filters, 100)
.then((r) => setValues(r.top ?? []))
.catch(() => setValues([]))
.finally(() => setLoadingValues(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, field, needsValue])

const add = (value: string) => {
onAdd({ field, operator, value: needsValue ? value : undefined })
setOpen(false)
setVq('')
}
const filtered = values.filter((v) => (vq ? String(v.value).toLowerCase().includes(vq.toLowerCase()) : true))

return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-full border border-dashed border-border px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Filter size={12} /> {t('logExplorer.builder.add')}
</button>
{open && (
<div className="absolute left-0 top-full z-30 mt-1 w-80 rounded-md border border-border bg-popover p-3 shadow-lg">
<div className="space-y-2">
<select value={field} onChange={(e) => setField(e.target.value)} className={cn(SELECT_CLS, 'w-full font-mono')}>
{selectable.map((f) => (
<option key={f.name} value={f.name}>{f.name}</option>
))}
</select>
<select value={operator} onChange={(e) => setOperator(e.target.value as FilterOperator)} className={cn(SELECT_CLS, 'w-full')}>
{BUILDER_OPS.map((o) => (
<option key={o.id} value={o.id}>{t(`logExplorer.ops.${OP_KEY[o.id] ?? o.id}`)}</option>
))}
</select>
{needsValue ? (
<>
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input value={vq} onChange={(e) => setVq(e.target.value)} placeholder={t('logExplorer.builder.filterValues')} className="h-8 pl-8 text-xs" autoFocus />
</div>
<div className="max-h-48 overflow-y-auto rounded-md border border-border">
{loadingValues ? (
<div className="flex items-center gap-1.5 px-3 py-3 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('logExplorer.builder.loadingValues')}</div>
) : filtered.length === 0 ? (
<div className="px-3 py-3 text-xs text-muted-foreground">{t('logExplorer.builder.noValues')}</div>
) : (
filtered.map((v) => (
<button key={v.value} onClick={() => add(v.value)} className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-xs hover:bg-muted">
<span className="truncate font-mono">{v.value || t('logExplorer.fields.empty')}</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground">{v.count.toLocaleString()}</span>
</button>
))
)}
</div>
</>
) : (
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>{t('logExplorer.builder.cancel')}</Button>
<Button size="sm" onClick={() => add('')}>{t('logExplorer.builder.confirm')}</Button>
</div>
)}
</div>
</div>
)}
</div>
)
}
122 changes: 122 additions & 0 deletions frontend/src/features/log-explorer/components/ChartPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/shared/lib/utils'
import { logExplorerHttpService as svc } from '../services/log-explorer-http.service'
import type { ChartView, FilterType, IndexField, IndexPattern } from '../types/log-explorer.types'
import { SELECT_CLS, TS } from './log-explorer.constants'
import { TermsChart } from './TermsChart'
import { TimeChart } from './TimeChart'

// Valid OpenSearch calendar_interval tokens (lowercase) for the date histogram.
const CALENDAR_INTERVALS = [
{ id: 'minute', label: 'Minute' },
{ id: 'hour', label: 'Hour' },
{ id: 'day', label: 'Day' },
{ id: 'week', label: 'Week' },
{ id: 'month', label: 'Month' },
{ id: 'quarter', label: 'Quarter' },
{ id: 'year', label: 'Year' },
]

export function ChartPanel({
pattern,
fields,
filters,
}: {
pattern: IndexPattern | null
fields: IndexField[]
filters: FilterType[]
}) {
const { t } = useTranslation()
const selectable = useMemo(
() => fields.filter((f) => !f.name.endsWith('.keyword')).sort((a, b) => a.name.localeCompare(b.name)),
[fields]
)
const [fieldName, setFieldName] = useState('')
const [interval, setInterval] = useState('day')
const [data, setData] = useState<ChartView | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)

// Default to @timestamp (time histogram) when present, else the first field.
useEffect(() => {
if (fieldName || selectable.length === 0) return
setFieldName(selectable.find((f) => f.name === TS)?.name ?? selectable[0].name)
}, [selectable, fieldName])

const field = selectable.find((f) => f.name === fieldName) ?? null
const isDate = field?.type === 'date'
const aggField = field ? (field.type === 'text' ? `${field.name}.keyword` : field.name) : ''

useEffect(() => {
if (!pattern || !field) return
setLoading(true)
setError(false)
svc
.chartView({
indexPattern: pattern.pattern,
field: aggField,
fieldDataType: field.type,
filters,
interval: isDate ? interval : '',
top: 20,
})
.then(setData)
.catch(() => {
setData(null)
setError(true)
})
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pattern, fieldName, interval, filters])

return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-4 py-2.5 text-xs">
<span className="text-muted-foreground">{t('logExplorer.chart.aggregateOn')}</span>
<select value={fieldName} onChange={(e) => setFieldName(e.target.value)} className={cn(SELECT_CLS, 'min-w-[200px] font-mono')}>
{selectable.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
{isDate ? (
<>
<span className="text-muted-foreground">{t('logExplorer.chart.per')}</span>
<select value={interval} onChange={(e) => setInterval(e.target.value)} className={SELECT_CLS}>
{CALENDAR_INTERVALS.map((i) => (
<option key={i.id} value={i.id}>
{t(`logExplorer.intervals.${i.id}`)}
</option>
))}
</select>
</>
) : (
<span className="text-muted-foreground">{t('logExplorer.chart.topValues')}</span>
)}
</div>

<div className="min-h-0 flex-1 overflow-y-auto p-5">
{loading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> {t('logExplorer.chart.building')}
</div>
) : error ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<AlertTriangle size={16} className="text-amber-500" /> {t('logExplorer.chart.failed')}
</div>
) : !data || data.values.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t('logExplorer.chart.noData')}
</div>
) : isDate ? (
<TimeChart data={data} />
) : (
<TermsChart data={data} />
)}
</div>
</div>
)
}
127 changes: 127 additions & 0 deletions frontend/src/features/log-explorer/components/FieldItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { memo, useEffect, useState } from 'react'
import { Check, ChevronRight, Columns3, Loader2, Minus, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/shared/lib/utils'
import { logExplorerHttpService as svc } from '../services/log-explorer-http.service'
import type { FilterType, IndexField, IndexPattern, TopValues } from '../types/log-explorer.types'
import { TypeBadge } from './TypeBadge'

// Memoized so parent-render-storms (e.g. SQL keystrokes cascading down) don't
// re-run this for every field in the pattern. Callbacks receive `field.name` so
// FieldSidebar can pass stable identities.
function FieldItemImpl({
field,
pattern,
filters,
isColumn,
open,
onToggle,
onAdd,
onToggleColumn,
}: {
field: IndexField
pattern: IndexPattern | null
filters: FilterType[]
isColumn: boolean
open: boolean
onToggle: (name: string) => void
onAdd: (f: FilterType) => void
onToggleColumn: (name: string) => void
}) {
const { t } = useTranslation()
const [top, setTop] = useState<TopValues | null>(null)
const [loading, setLoading] = useState(false)

// Aggregations need the keyword sub-field for text types.
const aggField =
field.type === 'text' && !field.name.endsWith('.keyword') ? `${field.name}.keyword` : field.name

useEffect(() => {
if (!open || !pattern || top) return
setLoading(true)
svc
.topValues(pattern.pattern, aggField, filters, 5)
.then(setTop)
.catch(() => setTop({ total: 0, top: [] }))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

return (
<div className={cn('group/field rounded-md', open && 'bg-card shadow-sm ring-1 ring-border/70')}>
<div className={cn('flex items-center gap-2.5 rounded-md px-2 py-2', !open && 'hover:bg-card/70')}>
<button onClick={() => onToggle(field.name)} className="flex min-w-0 flex-1 items-center gap-2.5 text-left">
<TypeBadge type={field.type} />
<span className="flex-1 truncate font-mono text-xs" title={field.name}>
{field.name}
</span>
</button>
<button
onClick={() => onToggleColumn(field.name)}
title={isColumn ? t('logExplorer.fields.removeColumn') : t('logExplorer.fields.addColumn')}
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded transition-colors',
isColumn
? 'text-primary hover:bg-primary/10'
: 'text-muted-foreground opacity-0 hover:bg-muted group-hover/field:opacity-100'
)}
>
{isColumn ? <Check size={13} /> : <Columns3 size={13} />}
</button>
<button onClick={() => onToggle(field.name)} className="shrink-0">
<ChevronRight size={13} className={cn('text-muted-foreground/60 transition-transform', open && 'rotate-90')} />
</button>
</div>
{open && (
<div className="px-2 pb-2.5 pt-1">
<div className="mb-1.5 px-1 text-[10px] uppercase tracking-wider text-muted-foreground/60">
{t('logExplorer.fields.topValues', { count: Math.min(5, top?.top.length ?? 0) })}
</div>
{loading ? (
<div className="flex items-center gap-1.5 px-1 py-2 text-[11px] text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t('logExplorer.fields.loading')}
</div>
) : !top || top.top.length === 0 ? (
<div className="px-1 py-2 text-[11px] text-muted-foreground">{t('logExplorer.fields.noValues')}</div>
) : (
<div className="space-y-1">
{top.top.slice(0, 5).map((v) => (
<div key={v.value} className="group rounded px-1.5 py-1.5 hover:bg-muted/50">
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-[11px]" title={v.value}>
{v.value || t('logExplorer.fields.empty')}
</span>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground group-hover:hidden">
{Math.round(v.percent)}%
</span>
<div className="hidden shrink-0 items-center gap-1 group-hover:flex">
<button
title={t('logExplorer.fields.filterFor')}
onClick={() => onAdd({ field: field.name, operator: 'IS', value: v.value })}
className="flex h-5 w-5 items-center justify-center rounded text-emerald-500 hover:bg-emerald-500/15"
>
<Plus size={12} />
</button>
<button
title={t('logExplorer.fields.filterOut')}
onClick={() => onAdd({ field: field.name, operator: 'IS_NOT', value: v.value })}
className="flex h-5 w-5 items-center justify-center rounded text-red-500 hover:bg-red-500/15"
>
<Minus size={12} />
</button>
</div>
</div>
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary/50" style={{ width: `${Math.max(3, v.percent)}%` }} />
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}

export const FieldItem = memo(FieldItemImpl)
Loading
Loading