diff --git a/frontend/src/features/log-explorer/components/AddFilterButton.tsx b/frontend/src/features/log-explorer/components/AddFilterButton.tsx new file mode 100644 index 000000000..c9227424a --- /dev/null +++ b/frontend/src/features/log-explorer/components/AddFilterButton.tsx @@ -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('IS') + const [values, setValues] = useState([]) + const [loadingValues, setLoadingValues] = useState(false) + const [vq, setVq] = useState('') + const ref = useRef(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 ( +
+ + {open && ( +
+
+ + + {needsValue ? ( + <> +
+ + setVq(e.target.value)} placeholder={t('logExplorer.builder.filterValues')} className="h-8 pl-8 text-xs" autoFocus /> +
+
+ {loadingValues ? ( +
{t('logExplorer.builder.loadingValues')}
+ ) : filtered.length === 0 ? ( +
{t('logExplorer.builder.noValues')}
+ ) : ( + filtered.map((v) => ( + + )) + )} +
+ + ) : ( +
+ + +
+ )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/ChartPanel.tsx b/frontend/src/features/log-explorer/components/ChartPanel.tsx new file mode 100644 index 000000000..c527b37d0 --- /dev/null +++ b/frontend/src/features/log-explorer/components/ChartPanel.tsx @@ -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(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 ( +
+
+ {t('logExplorer.chart.aggregateOn')} + + {isDate ? ( + <> + {t('logExplorer.chart.per')} + + + ) : ( + {t('logExplorer.chart.topValues')} + )} +
+ +
+ {loading ? ( +
+ {t('logExplorer.chart.building')} +
+ ) : error ? ( +
+ {t('logExplorer.chart.failed')} +
+ ) : !data || data.values.length === 0 ? ( +
+ {t('logExplorer.chart.noData')} +
+ ) : isDate ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/features/log-explorer/components/FieldItem.tsx b/frontend/src/features/log-explorer/components/FieldItem.tsx new file mode 100644 index 000000000..0996523a9 --- /dev/null +++ b/frontend/src/features/log-explorer/components/FieldItem.tsx @@ -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(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 ( +
+
+ + + +
+ {open && ( +
+
+ {t('logExplorer.fields.topValues', { count: Math.min(5, top?.top.length ?? 0) })} +
+ {loading ? ( +
+ {t('logExplorer.fields.loading')} +
+ ) : !top || top.top.length === 0 ? ( +
{t('logExplorer.fields.noValues')}
+ ) : ( +
+ {top.top.slice(0, 5).map((v) => ( +
+
+ + {v.value || t('logExplorer.fields.empty')} + + + {Math.round(v.percent)}% + +
+ + +
+
+
+
+
+
+ ))} +
+ )} +
+ )} +
+ ) +} + +export const FieldItem = memo(FieldItemImpl) diff --git a/frontend/src/features/log-explorer/components/FieldSidebar.tsx b/frontend/src/features/log-explorer/components/FieldSidebar.tsx new file mode 100644 index 000000000..361099d24 --- /dev/null +++ b/frontend/src/features/log-explorer/components/FieldSidebar.tsx @@ -0,0 +1,101 @@ +import { memo, useCallback, useMemo, useState } from 'react' +import { Search } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { Input } from '@/shared/components/ui/input' +import type { FilterType, IndexField, IndexPattern } from '../types/log-explorer.types' +import { FieldItem } from './FieldItem' +import { SidebarSectionLabel } from './SidebarSectionLabel' + +// Memoized: rendered even in SQL mode, but its props are reference-stable so +// SQL-typing keystrokes don't re-run FieldItem() N times. +function FieldSidebarImpl({ + fields, + pattern, + filters, + columns, + onAdd, + onToggleColumn, +}: { + fields: IndexField[] + pattern: IndexPattern | null + filters: FilterType[] + columns: string[] + onAdd: (f: FilterType) => void + onToggleColumn: (name: string) => void +}) { + const { t } = useTranslation() + const [q, setQ] = useState('') + const [openField, setOpenField] = useState(null) + const handleToggleOpen = useCallback( + (name: string) => setOpenField((prev) => (prev === name ? null : name)), + [], + ) + + // Hide raw .keyword variants — the base field covers them. + const visible = useMemo( + () => + fields + .filter((f) => !f.name.endsWith('.keyword')) + .filter((f) => (q ? f.name.toLowerCase().includes(q.toLowerCase()) : true)) + .sort((a, b) => a.name.localeCompare(b.name)), + [fields, q] + ) + + // Selected fields (in column order) on top, the rest below — like the legacy. + const selected = columns + .map((name) => visible.find((f) => f.name === name)) + .filter((f): f is IndexField => !!f) + const available = visible.filter((f) => !columns.includes(f.name)) + + const renderItem = (f: IndexField) => ( + + ) + + return ( + + ) +} + +export const FieldSidebar = memo(FieldSidebarImpl) diff --git a/frontend/src/features/log-explorer/components/FilterChips.tsx b/frontend/src/features/log-explorer/components/FilterChips.tsx new file mode 100644 index 000000000..983761966 --- /dev/null +++ b/frontend/src/features/log-explorer/components/FilterChips.tsx @@ -0,0 +1,60 @@ +import { Filter, X } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' +import type { FilterType } from '../types/log-explorer.types' +import { OP_KEY } from './log-explorer.constants' + +export function FilterChips({ + filters, + onRemove, + onClear, +}: { + filters: FilterType[] + onRemove: (i: number) => void + onClear: () => void +}) { + const { t } = useTranslation() + return ( +
+ + {t('logExplorer.filters.label')} + + {filters.map((f, i) => { + const neg = f.operator === 'IS_NOT' + return ( + + {f.field} + + {OP_KEY[f.operator] ? t(`logExplorer.ops.${OP_KEY[f.operator]}`) : f.operator} + + {f.value != null && f.operator !== 'EXIST' && ( + + {Array.isArray(f.value) ? t('logExplorer.related.nLogs', { count: f.value.length }) : String(f.value)} + + )} + + + ) + })} + {filters.length > 1 && ( + + )} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/HistogramStrip.tsx b/frontend/src/features/log-explorer/components/HistogramStrip.tsx new file mode 100644 index 000000000..a2218eaba --- /dev/null +++ b/frontend/src/features/log-explorer/components/HistogramStrip.tsx @@ -0,0 +1,88 @@ +import { memo, useEffect, useState } from 'react' +import type { TimeRange } from '@/shared/components/ui/time-range-picker' +import { logExplorerHttpService as svc } from '../services/log-explorer-http.service' +import type { ChartView, FilterType, IndexPattern } from '../types/log-explorer.types' +import { TS, chartTimeLabel } from './log-explorer.constants' + +// Pick a date-histogram interval that yields a readable number of buckets for +// the selected time window. +function histogramInterval(from?: string | null, to?: string | null): string { + if (!from || !to) return 'hour' + const span = new Date(to).getTime() - new Date(from).getTime() + if (Number.isNaN(span) || span <= 0) return 'hour' + const H = 3_600_000 + const D = 24 * H + if (span <= 2 * H) return 'minute' + if (span <= 2 * D) return 'hour' + if (span <= 60 * D) return 'day' + if (span <= 365 * D) return 'week' + return 'month' +} + +// Always-on event-volume histogram shown above the results table (Discover-style). +// Aggregates the same filtered/time-scoped result set on @timestamp so analysts +// see spikes and gaps without leaving the table. Uses flex columns (not a stretched +// SVG) so the strip never collapses into a solid block. +// Memoized: props are reference-stable (activeFilterList useMemo, pattern/range +// state) so SQL-mode keystrokes don't re-enter this subtree. +function HistogramStripImpl({ + pattern, + filters, + range, +}: { + pattern: IndexPattern + filters: FilterType[] + range: TimeRange +}) { + const interval = histogramInterval(range.from, range.to) + const [data, setData] = useState(null) + + useEffect(() => { + let cancelled = false + svc + .chartView({ + indexPattern: pattern.pattern, + field: TS, + fieldDataType: 'date', + filters, + interval, + top: 50, + }) + .then((d) => { + if (!cancelled) setData(d) + }) + .catch(() => { + if (!cancelled) setData(null) + }) + return () => { + cancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pattern.pattern, filters, interval]) + + if (!data || data.values.length === 0) return null + const max = Math.max(1, ...data.values) + return ( +
+
+ {data.values.map((v, i) => ( +
+
+
+ ))} +
+ {data.categories.length > 1 && ( +
+ {chartTimeLabel(data.categories[0])} + {chartTimeLabel(data.categories[data.categories.length - 1])} +
+ )} +
+ ) +} + +export const HistogramStrip = memo(HistogramStripImpl) diff --git a/frontend/src/features/log-explorer/components/LogExplorerView.tsx b/frontend/src/features/log-explorer/components/LogExplorerView.tsx index a43862a01..007527a82 100644 --- a/frontend/src/features/log-explorer/components/LogExplorerView.tsx +++ b/frontend/src/features/log-explorer/components/LogExplorerView.tsx @@ -1,63 +1,35 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - AlertTriangle, - BarChart3, - Braces, - Calendar, - Check, - ChevronRight, - Columns3, - Code2, - Download, - Bookmark, - Filter, - Globe, - Hash, - Loader2, - Minus, - Play, - Plus, - RefreshCw, - Save, - Search, - Table as TableIcon, - Tag, - ToggleLeft, - Trash2, - Type, - X, - type LucideIcon, -} from 'lucide-react' +import { AlertTriangle, Loader2 } from 'lucide-react' import { toast } from 'sonner' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' -import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' -import { Input } from '@/shared/components/ui/input' -import { TimeRangePicker, presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' +import { presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' import { ResultsHeader, ResultRow, flattenDoc } from './log-results' -import { IndexPatternSelector } from './IndexPatternSelector' -import { SqlQueryEditor } from '@/shared/components/sql-editor' +import { QueryBar } from './QueryBar' +import { AddFilterButton } from './AddFilterButton' +import { FilterChips } from './FilterChips' +import { FieldSidebar } from './FieldSidebar' +import { RowMessage } from './RowMessage' +import { ViewToggle } from './ViewToggle' +import { SavedSearches, type SavedSearchState } from './SavedSearches' +import { HistogramStrip } from './HistogramStrip' +import { ChartPanel } from './ChartPanel' +import { TS } from './log-explorer.constants' import { logExplorerHttpService as svc, LogExplorerHttpError, } from '../services/log-explorer-http.service' import type { - ChartView, - FilterOperator, FilterType, IndexField, IndexPattern, LogDocument, LogExplorerTabConfig, - TopValues, } from '../types/log-explorer.types' /* ─── Constants ────────────────────────────────────────────────────────── */ -const TS = '@timestamp' - - // Field-name candidates for the compact result columns (read from the flattened doc). const MSG_FIELDS = ['log.message', 'logx.message', 'message', 'event.original', 'rule.name', 'logx.raw'] @@ -417,14 +389,21 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp } } - const addFilter = (f: FilterType) => { + // Stable identities: memoized children (FieldSidebar/FieldItem/HistogramStrip/ + // ResultRow) skip re-render on SQL keystrokes only if their callback props are + // reference-stable. Functional setState updaters let these be dep-free. + const addFilter = useCallback((f: FilterType) => { setFilters((cur) => cur.some((c) => c.field === f.field && c.operator === f.operator && c.value === f.value) ? cur : [...cur, f] ) - } - const removeFilter = (i: number) => { + }, []) + const removeFilter = useCallback((i: number) => { setFilters((cur) => cur.filter((_, idx) => idx !== i)) - } + }, []) + const clearFilters = useCallback(() => setFilters([]), []) + const toggleExpanded = useCallback((i: number) => { + setExpanded((prev) => (prev === i ? null : i)) + }, []) return (
@@ -471,7 +450,7 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp )} {!sqlMode && } - {filters.length > 0 && setFilters([])} />} + {filters.length > 0 && }
@@ -493,11 +472,10 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp ) : ( <> - {!sqlMode && pattern && ( + {pattern && ( )}
- {!sqlMode && ( - )}
@@ -535,11 +512,12 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp {rows.map((doc, i) => ( setExpanded(expanded === i ? null : i)} + onToggle={toggleExpanded} onAdd={addFilter} onSurrounding={viewSurrounding} /> @@ -565,968 +543,3 @@ export function LogExplorerView({ initial, onConfigChange }: LogExplorerViewProp
) } - -/* ─── Query bar ────────────────────────────────────────────────────────── */ - -function QueryBar({ - patterns, - pattern, - onPattern, - searchInput, - onSearchInput, - sqlMode, - onSqlMode, - sqlInput, - onSqlInput, - fields, - range, - onRange, - onRun, - onRefresh, - loading, - onExport, -}: { - patterns: IndexPattern[] - pattern: IndexPattern | null - onPattern: (p: IndexPattern) => void - searchInput: string - onSearchInput: (q: string) => void - sqlMode: boolean - onSqlMode: (b: boolean) => void - sqlInput: string - onSqlInput: (q: string) => void - fields: IndexField[] - range: TimeRange - onRange: (r: TimeRange) => void - onRun: () => void - onRefresh: () => void - loading: boolean - onExport: () => void -}) { - const { t } = useTranslation() - - return ( -
- - -
- - {/* Search input — free text or SQL */} -
- {sqlMode ? ( - - ) : ( - <> - - onSearchInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && onRun()} - placeholder={t('logExplorer.query.searchPlaceholder')} - className="h-9 border-0 bg-transparent pl-8 font-mono text-xs shadow-none focus-visible:ring-0" - /> - - )} -
- -
- - {/* Time range */} - - - - - - - - - -
- ) -} - -/* ─── Active filter chips ──────────────────────────────────────────────── */ - -const OP_KEY: Record = { - IS: 'is', - IS_NOT: 'isNot', - CONTAIN: 'contains', - EXIST: 'exists', - IS_BETWEEN: 'between', - IS_IN_FIELDS: 'search', - IS_ONE_OF_TERMS: 'isOneOf', -} - -/* 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 }, -] - -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('IS') - const [values, setValues] = useState([]) - const [loadingValues, setLoadingValues] = useState(false) - const [vq, setVq] = useState('') - const ref = useRef(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 ( -
- - {open && ( -
-
- - - {needsValue ? ( - <> -
- - setVq(e.target.value)} placeholder={t('logExplorer.builder.filterValues')} className="h-8 pl-8 text-xs" autoFocus /> -
-
- {loadingValues ? ( -
{t('logExplorer.builder.loadingValues')}
- ) : filtered.length === 0 ? ( -
{t('logExplorer.builder.noValues')}
- ) : ( - filtered.map((v) => ( - - )) - )} -
- - ) : ( -
- - -
- )} -
-
- )} -
- ) -} - -function FilterChips({ - filters, - onRemove, - onClear, -}: { - filters: FilterType[] - onRemove: (i: number) => void - onClear: () => void -}) { - const { t } = useTranslation() - return ( -
- - {t('logExplorer.filters.label')} - - {filters.map((f, i) => { - const neg = f.operator === 'IS_NOT' - return ( - - {f.field} - - {OP_KEY[f.operator] ? t(`logExplorer.ops.${OP_KEY[f.operator]}`) : f.operator} - - {f.value != null && f.operator !== 'EXIST' && ( - - {Array.isArray(f.value) ? t('logExplorer.related.nLogs', { count: f.value.length }) : String(f.value)} - - )} - - - ) - })} - {filters.length > 1 && ( - - )} -
- ) -} - -/* ─── Field sidebar ────────────────────────────────────────────────────── */ - -function FieldSidebar({ - fields, - pattern, - filters, - columns, - onAdd, - onToggleColumn, -}: { - fields: IndexField[] - pattern: IndexPattern | null - filters: FilterType[] - columns: string[] - onAdd: (f: FilterType) => void - onToggleColumn: (name: string) => void -}) { - const { t } = useTranslation() - const [q, setQ] = useState('') - const [openField, setOpenField] = useState(null) - - // Hide raw .keyword variants — the base field covers them. - const visible = useMemo( - () => - fields - .filter((f) => !f.name.endsWith('.keyword')) - .filter((f) => (q ? f.name.toLowerCase().includes(q.toLowerCase()) : true)) - .sort((a, b) => a.name.localeCompare(b.name)), - [fields, q] - ) - - // Selected fields (in column order) on top, the rest below — like the legacy. - const selected = columns - .map((name) => visible.find((f) => f.name === name)) - .filter((f): f is IndexField => !!f) - const available = visible.filter((f) => !columns.includes(f.name)) - - const renderItem = (f: IndexField) => ( - setOpenField(openField === f.name ? null : f.name)} - onAdd={onAdd} - onToggleColumn={() => onToggleColumn(f.name)} - /> - ) - - return ( - - ) -} - -function SidebarSectionLabel({ children, className }: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ) -} - -const TYPE_META: Record = { - date: { icon: Calendar, color: 'text-violet-500', label: 'Date' }, - keyword: { icon: Tag, color: 'text-sky-500', label: 'Keyword' }, - text: { icon: Type, color: 'text-emerald-500', label: 'Text' }, - ip: { icon: Globe, color: 'text-fuchsia-500', label: 'IP' }, - boolean: { icon: ToggleLeft, color: 'text-rose-500', label: 'Boolean' }, -} -const NUMBER_TYPES = new Set(['long', 'integer', 'short', 'byte', 'double', 'float', 'half_float', 'scaled_float']) - -function typeMeta(type: string) { - if (TYPE_META[type]) return TYPE_META[type] - if (NUMBER_TYPES.has(type)) return { icon: Hash, color: 'text-amber-500', label: 'Number' } - return { icon: Braces, color: 'text-muted-foreground', label: type || 'object' } -} - -function TypeBadge({ type }: { type: string }) { - const m = typeMeta(type) - const Icon = m.icon - return ( - - - - ) -} - -function FieldItem({ - field, - pattern, - filters, - isColumn, - open, - onToggle, - onAdd, - onToggleColumn, -}: { - field: IndexField - pattern: IndexPattern | null - filters: FilterType[] - isColumn: boolean - open: boolean - onToggle: () => void - onAdd: (f: FilterType) => void - onToggleColumn: () => void -}) { - const { t } = useTranslation() - const [top, setTop] = useState(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 ( -
-
- - - -
- {open && ( -
-
- {t('logExplorer.fields.topValues', { count: Math.min(5, top?.top.length ?? 0) })} -
- {loading ? ( -
- {t('logExplorer.fields.loading')} -
- ) : !top || top.top.length === 0 ? ( -
{t('logExplorer.fields.noValues')}
- ) : ( -
- {top.top.slice(0, 5).map((v) => ( -
-
- - {v.value || t('logExplorer.fields.empty')} - - - {Math.round(v.percent)}% - -
- - -
-
-
-
-
-
- ))} -
- )} -
- )} -
- ) -} - -function RowMessage({ children }: { children: React.ReactNode }) { - return ( -
{children}
- ) -} - -/* ─── Chart mode ───────────────────────────────────────────────────────── */ - -// 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' }, -] - -const SELECT_CLS = - 'h-8 cursor-pointer rounded-md border border-border bg-background px-2 text-xs transition-colors focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' - -function ViewToggle({ mode, onChange }: { mode: 'table' | 'chart'; onChange: (m: 'table' | 'chart') => void }) { - const { t } = useTranslation() - const opts = [ - { id: 'table' as const, icon: TableIcon, label: t('logExplorer.view.table') }, - { id: 'chart' as const, icon: BarChart3, label: t('logExplorer.view.chart') }, - ] - return ( -
- {opts.map(({ id, icon: Icon, label }) => ( - - ))} -
- ) -} - -function chartTimeLabel(c: string) { - const d = new Date(c) - return Number.isNaN(d.getTime()) - ? c - : d.toLocaleString(undefined, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) -} - -// ── Saved searches ──────────────────────────────────────────────────────── -// Reusable query snapshots persisted in localStorage so an analyst's daily -// queries survive reloads. Backend-free by design (per-browser, like the tabs). - -interface SavedSearchState { - patternStr: string | null - range: TimeRange - filters: FilterType[] - searchInput: string - appliedQuery: string -} - -interface SavedSearch extends SavedSearchState { - name: string -} - -const SAVED_SEARCHES_KEY = 'utmstack-logexplorer-saved-searches' - -function loadSavedSearches(): SavedSearch[] { - if (typeof window === 'undefined') return [] - try { - const raw = window.localStorage.getItem(SAVED_SEARCHES_KEY) - const arr = raw ? JSON.parse(raw) : [] - return Array.isArray(arr) ? arr : [] - } catch { - return [] - } -} - -function persistSavedSearches(list: SavedSearch[]) { - try { - window.localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(list)) - } catch { - /* ignore quota/availability errors */ - } -} - -function SavedSearches({ - snapshot, - onLoad, -}: { - snapshot: () => SavedSearchState - onLoad: (s: SavedSearchState) => void -}) { - const { t } = useTranslation() - const [open, setOpen] = useState(false) - const [list, setList] = useState(() => loadSavedSearches()) - const [saving, setSaving] = useState(false) - const [name, setName] = useState('') - - const commit = (next: SavedSearch[]) => { - setList(next) - persistSavedSearches(next) - } - - const save = () => { - const n = name.trim() - if (!n) return - const next = [...list.filter((s) => s.name !== n), { name: n, ...snapshot() }] - commit(next) - setName('') - setSaving(false) - toast.success(t('logExplorer.saved.saved', { name: n })) - } - - return ( -
- - {open && ( - <> -
setOpen(false)} /> -
-
- {list.length === 0 ? ( -
{t('logExplorer.saved.empty')}
- ) : ( - list.map((s) => ( -
- - -
- )) - )} -
-
- {saving ? ( -
- setName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') save() - if (e.key === 'Escape') setSaving(false) - }} - placeholder={t('logExplorer.saved.namePlaceholder')} - className="h-7 min-w-0 flex-1 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" - /> - -
- ) : ( - - )} -
-
- - )} -
- ) -} - -// Pick a date-histogram interval that yields a readable number of buckets for -// the selected time window. -function histogramInterval(from?: string | null, to?: string | null): string { - if (!from || !to) return 'hour' - const span = new Date(to).getTime() - new Date(from).getTime() - if (Number.isNaN(span) || span <= 0) return 'hour' - const H = 3_600_000 - const D = 24 * H - if (span <= 2 * H) return 'minute' - if (span <= 2 * D) return 'hour' - if (span <= 60 * D) return 'day' - if (span <= 365 * D) return 'week' - return 'month' -} - -// Always-on event-volume histogram shown above the results table (Discover-style). -// Aggregates the same filtered/time-scoped result set on @timestamp so analysts -// see spikes and gaps without leaving the table. Uses flex columns (not a stretched -// SVG) so the strip never collapses into a solid block. -function HistogramStrip({ - pattern, - filters, - range, -}: { - pattern: IndexPattern - filters: FilterType[] - range: TimeRange -}) { - const interval = histogramInterval(range.from, range.to) - const [data, setData] = useState(null) - - useEffect(() => { - let cancelled = false - svc - .chartView({ - indexPattern: pattern.pattern, - field: TS, - fieldDataType: 'date', - filters, - interval, - top: 50, - }) - .then((d) => { - if (!cancelled) setData(d) - }) - .catch(() => { - if (!cancelled) setData(null) - }) - return () => { - cancelled = true - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pattern.pattern, filters, interval]) - - if (!data || data.values.length === 0) return null - const max = Math.max(1, ...data.values) - return ( -
-
- {data.values.map((v, i) => ( -
-
-
- ))} -
- {data.categories.length > 1 && ( -
- {chartTimeLabel(data.categories[0])} - {chartTimeLabel(data.categories[data.categories.length - 1])} -
- )} -
- ) -} - -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(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 ( -
-
- {t('logExplorer.chart.aggregateOn')} - - {isDate ? ( - <> - {t('logExplorer.chart.per')} - - - ) : ( - {t('logExplorer.chart.topValues')} - )} -
- -
- {loading ? ( -
- {t('logExplorer.chart.building')} -
- ) : error ? ( -
- {t('logExplorer.chart.failed')} -
- ) : !data || data.values.length === 0 ? ( -
- {t('logExplorer.chart.noData')} -
- ) : isDate ? ( - - ) : ( - - )} -
-
- ) -} - -function TermsChart({ data }: { data: ChartView }) { - const { t } = useTranslation() - const max = Math.max(1, ...data.values) - return ( -
- {data.categories.map((cat, i) => { - const v = data.values[i] ?? 0 - const pct = (v / max) * 100 - return ( -
-
- {cat || t('logExplorer.fields.empty')} -
-
-
-
-
- {v.toLocaleString()} -
-
- ) - })} -
- ) -} - -function TimeChart({ data }: { data: ChartView }) { - const values = data.values - const max = Math.max(1, ...values) - const w = 1200 - const h = 300 - const n = values.length || 1 - const slot = w / n - const bw = Math.max(1, slot - 3) - return ( -
- - {values.map((v, i) => { - if (v <= 0) return null - const bh = Math.max(2, (v / max) * h) - const x = i * slot + (slot - bw) / 2 - return ( - - {`${data.categories[i]}: ${v.toLocaleString()}`} - - ) - })} - - {data.categories.length > 1 && ( -
- {chartTimeLabel(data.categories[0])} - {chartTimeLabel(data.categories[data.categories.length - 1])} -
- )} -
- ) -} diff --git a/frontend/src/features/log-explorer/components/QueryBar.tsx b/frontend/src/features/log-explorer/components/QueryBar.tsx new file mode 100644 index 000000000..ab765e5d9 --- /dev/null +++ b/frontend/src/features/log-explorer/components/QueryBar.tsx @@ -0,0 +1,112 @@ +import { Code2, Download, Play, RefreshCw, 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 { TimeRangePicker, type TimeRange } from '@/shared/components/ui/time-range-picker' +import { IndexPatternSelector } from './IndexPatternSelector' +import { SqlQueryEditor } from '@/shared/components/sql-editor' +import type { IndexField, IndexPattern } from '../types/log-explorer.types' + +export function QueryBar({ + patterns, + pattern, + onPattern, + searchInput, + onSearchInput, + sqlMode, + onSqlMode, + sqlInput, + onSqlInput, + fields, + range, + onRange, + onRun, + onRefresh, + loading, + onExport, +}: { + patterns: IndexPattern[] + pattern: IndexPattern | null + onPattern: (p: IndexPattern) => void + searchInput: string + onSearchInput: (q: string) => void + sqlMode: boolean + onSqlMode: (b: boolean) => void + sqlInput: string + onSqlInput: (q: string) => void + fields: IndexField[] + range: TimeRange + onRange: (r: TimeRange) => void + onRun: () => void + onRefresh: () => void + loading: boolean + onExport: () => void +}) { + const { t } = useTranslation() + + return ( +
+ + +
+ + {/* Search input — free text or SQL */} +
+ {sqlMode ? ( + + ) : ( + <> + + onSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && onRun()} + placeholder={t('logExplorer.query.searchPlaceholder')} + className="h-9 border-0 bg-transparent pl-8 font-mono text-xs shadow-none focus-visible:ring-0" + /> + + )} +
+ +
+ + {/* Time range */} + + + + + + + + + +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/RowMessage.tsx b/frontend/src/features/log-explorer/components/RowMessage.tsx new file mode 100644 index 000000000..0d14a7ad4 --- /dev/null +++ b/frontend/src/features/log-explorer/components/RowMessage.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react' + +export function RowMessage({ children }: { children: ReactNode }) { + return ( +
{children}
+ ) +} diff --git a/frontend/src/features/log-explorer/components/SavedSearches.tsx b/frontend/src/features/log-explorer/components/SavedSearches.tsx new file mode 100644 index 000000000..77e2fbc10 --- /dev/null +++ b/frontend/src/features/log-explorer/components/SavedSearches.tsx @@ -0,0 +1,145 @@ +import { useState } from 'react' +import { Bookmark, Save, Trash2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' +import type { TimeRange } from '@/shared/components/ui/time-range-picker' +import type { FilterType } from '../types/log-explorer.types' + +// ── Saved searches ──────────────────────────────────────────────────────── +// Reusable query snapshots persisted in localStorage so an analyst's daily +// queries survive reloads. Backend-free by design (per-browser, like the tabs). + +export interface SavedSearchState { + patternStr: string | null + range: TimeRange + filters: FilterType[] + searchInput: string + appliedQuery: string +} + +interface SavedSearch extends SavedSearchState { + name: string +} + +const SAVED_SEARCHES_KEY = 'utmstack-logexplorer-saved-searches' + +function loadSavedSearches(): SavedSearch[] { + if (typeof window === 'undefined') return [] + try { + const raw = window.localStorage.getItem(SAVED_SEARCHES_KEY) + const arr = raw ? JSON.parse(raw) : [] + return Array.isArray(arr) ? arr : [] + } catch { + return [] + } +} + +function persistSavedSearches(list: SavedSearch[]) { + try { + window.localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(list)) + } catch { + /* ignore quota/availability errors */ + } +} + +export function SavedSearches({ + snapshot, + onLoad, +}: { + snapshot: () => SavedSearchState + onLoad: (s: SavedSearchState) => void +}) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [list, setList] = useState(() => loadSavedSearches()) + const [saving, setSaving] = useState(false) + const [name, setName] = useState('') + + const commit = (next: SavedSearch[]) => { + setList(next) + persistSavedSearches(next) + } + + const save = () => { + const n = name.trim() + if (!n) return + const next = [...list.filter((s) => s.name !== n), { name: n, ...snapshot() }] + commit(next) + setName('') + setSaving(false) + toast.success(t('logExplorer.saved.saved', { name: n })) + } + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+
+ {list.length === 0 ? ( +
{t('logExplorer.saved.empty')}
+ ) : ( + list.map((s) => ( +
+ + +
+ )) + )} +
+
+ {saving ? ( +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') save() + if (e.key === 'Escape') setSaving(false) + }} + placeholder={t('logExplorer.saved.namePlaceholder')} + className="h-7 min-w-0 flex-1 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> + +
+ ) : ( + + )} +
+
+ + )} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/SidebarSectionLabel.tsx b/frontend/src/features/log-explorer/components/SidebarSectionLabel.tsx new file mode 100644 index 000000000..da97bea9a --- /dev/null +++ b/frontend/src/features/log-explorer/components/SidebarSectionLabel.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' +import { cn } from '@/shared/lib/utils' + +export function SidebarSectionLabel({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/TermsChart.tsx b/frontend/src/features/log-explorer/components/TermsChart.tsx new file mode 100644 index 000000000..8fe88eacc --- /dev/null +++ b/frontend/src/features/log-explorer/components/TermsChart.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from 'react-i18next' +import type { ChartView } from '../types/log-explorer.types' + +export function TermsChart({ data }: { data: ChartView }) { + const { t } = useTranslation() + const max = Math.max(1, ...data.values) + return ( +
+ {data.categories.map((cat, i) => { + const v = data.values[i] ?? 0 + const pct = (v / max) * 100 + return ( +
+
+ {cat || t('logExplorer.fields.empty')} +
+
+
+
+
+ {v.toLocaleString()} +
+
+ ) + })} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/TimeChart.tsx b/frontend/src/features/log-explorer/components/TimeChart.tsx new file mode 100644 index 000000000..2ba8db28c --- /dev/null +++ b/frontend/src/features/log-explorer/components/TimeChart.tsx @@ -0,0 +1,34 @@ +import type { ChartView } from '../types/log-explorer.types' +import { chartTimeLabel } from './log-explorer.constants' + +export function TimeChart({ data }: { data: ChartView }) { + const values = data.values + const max = Math.max(1, ...values) + const w = 1200 + const h = 300 + const n = values.length || 1 + const slot = w / n + const bw = Math.max(1, slot - 3) + return ( +
+ + {values.map((v, i) => { + if (v <= 0) return null + const bh = Math.max(2, (v / max) * h) + const x = i * slot + (slot - bw) / 2 + return ( + + {`${data.categories[i]}: ${v.toLocaleString()}`} + + ) + })} + + {data.categories.length > 1 && ( +
+ {chartTimeLabel(data.categories[0])} + {chartTimeLabel(data.categories[data.categories.length - 1])} +
+ )} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/TypeBadge.tsx b/frontend/src/features/log-explorer/components/TypeBadge.tsx new file mode 100644 index 000000000..f77812d74 --- /dev/null +++ b/frontend/src/features/log-explorer/components/TypeBadge.tsx @@ -0,0 +1,29 @@ +import { Braces, Calendar, Globe, Hash, Tag, ToggleLeft, Type, type LucideIcon } from 'lucide-react' + +const TYPE_META: Record = { + date: { icon: Calendar, color: 'text-violet-500', label: 'Date' }, + keyword: { icon: Tag, color: 'text-sky-500', label: 'Keyword' }, + text: { icon: Type, color: 'text-emerald-500', label: 'Text' }, + ip: { icon: Globe, color: 'text-fuchsia-500', label: 'IP' }, + boolean: { icon: ToggleLeft, color: 'text-rose-500', label: 'Boolean' }, +} +const NUMBER_TYPES = new Set(['long', 'integer', 'short', 'byte', 'double', 'float', 'half_float', 'scaled_float']) + +function typeMeta(type: string) { + if (TYPE_META[type]) return TYPE_META[type] + if (NUMBER_TYPES.has(type)) return { icon: Hash, color: 'text-amber-500', label: 'Number' } + return { icon: Braces, color: 'text-muted-foreground', label: type || 'object' } +} + +export function TypeBadge({ type }: { type: string }) { + const m = typeMeta(type) + const Icon = m.icon + return ( + + + + ) +} diff --git a/frontend/src/features/log-explorer/components/ViewToggle.tsx b/frontend/src/features/log-explorer/components/ViewToggle.tsx new file mode 100644 index 000000000..5964849fd --- /dev/null +++ b/frontend/src/features/log-explorer/components/ViewToggle.tsx @@ -0,0 +1,27 @@ +import { BarChart3, Table as TableIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/shared/lib/utils' + +export function ViewToggle({ mode, onChange }: { mode: 'table' | 'chart'; onChange: (m: 'table' | 'chart') => void }) { + const { t } = useTranslation() + const opts = [ + { id: 'table' as const, icon: TableIcon, label: t('logExplorer.view.table') }, + { id: 'chart' as const, icon: BarChart3, label: t('logExplorer.view.chart') }, + ] + return ( +
+ {opts.map(({ id, icon: Icon, label }) => ( + + ))} +
+ ) +} diff --git a/frontend/src/features/log-explorer/components/log-explorer.constants.ts b/frontend/src/features/log-explorer/components/log-explorer.constants.ts new file mode 100644 index 000000000..2478ff120 --- /dev/null +++ b/frontend/src/features/log-explorer/components/log-explorer.constants.ts @@ -0,0 +1,21 @@ +export const TS = '@timestamp' + +export const SELECT_CLS = + 'h-8 cursor-pointer rounded-md border border-border bg-background px-2 text-xs transition-colors focus-visible:border-ring focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + +export const OP_KEY: Record = { + IS: 'is', + IS_NOT: 'isNot', + CONTAIN: 'contains', + EXIST: 'exists', + IS_BETWEEN: 'between', + IS_IN_FIELDS: 'search', + IS_ONE_OF_TERMS: 'isOneOf', +} + +export function chartTimeLabel(c: string) { + const d = new Date(c) + return Number.isNaN(d.getTime()) + ? c + : d.toLocaleString(undefined, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) +} diff --git a/frontend/src/features/log-explorer/components/log-results.tsx b/frontend/src/features/log-explorer/components/log-results.tsx index 307e0a30c..d0d3ce3ac 100644 --- a/frontend/src/features/log-explorer/components/log-results.tsx +++ b/frontend/src/features/log-explorer/components/log-results.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ReactNode } from 'react' +import { memo, useCallback, useMemo, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { ChevronRight, Copy, Crosshair, Minus, Plus, X } from 'lucide-react' @@ -123,7 +123,7 @@ function colValue(flat: Record, c: string): string { return String(v) } -export function ResultsHeader({ +function ResultsHeaderImpl({ columns, autoColumns = [], onRemoveColumn, @@ -144,16 +144,16 @@ export function ResultsHeader({ {columns.length === 0 ? ( <>
{t('logExplorer.results.source')}
- {autoColumns.map((c) => ( -
+ {autoColumns.map((c,i) => ( +
{fieldLabel(c)}
))}
{t('logExplorer.results.message')}
) : ( - columns.map((c) => ( -
+ columns.map((c,i) => ( +
{fieldLabel(c)} @@ -173,12 +173,15 @@ export function ResultsHeader({ ) } +export const ResultsHeader = memo(ResultsHeaderImpl) + // Short, readable column header from a field path: "origin.ip" → "origin ip". function fieldLabel(field: string): string { return field.replace(/\./g, ' ') } -export function ResultRow({ +function ResultRowImpl({ + index, doc, columns, autoColumns = [], @@ -187,11 +190,12 @@ export function ResultRow({ onAdd, onSurrounding, }: { + index: number doc: LogDocument columns: string[] autoColumns?: string[] expanded: boolean - onToggle: () => void + onToggle: (index: number) => void onAdd?: (f: FilterType) => void onSurrounding?: (ts: string, srcField?: string, srcVal?: string) => void }) { @@ -206,7 +210,7 @@ export function ResultRow({ return ( <>
onToggle(index)} className={cn( 'grid cursor-pointer items-center gap-3 border-b border-border/40 px-4 py-1 text-xs leading-tight transition-colors last:border-b-0', expanded ? 'bg-muted/30' : 'hover:bg-muted/20' @@ -217,18 +221,18 @@ export function ResultRow({
{ts ? shortTime(ts) : '—'}
{columns.length > 0 ? ( - columns.map((c) => ( -
+ columns.map((c,i) => ( +
{colValue(flat, c)}
)) ) : ( <>
{source}
- {autoColumns.map((c) => { + {autoColumns.map((c,i) => { const val = colValue(flat, c) return ( -
+
{val}
) @@ -238,7 +242,7 @@ export function ResultRow({ ) : (
{preview!.map(([k, v], idx) => ( - + {idx > 0 && ·} {k} {v} @@ -317,7 +321,7 @@ function ExpandedPanel({
{entries.map(([k, v], i) => (
(null) + const toggle = useCallback((i: number) => setExpanded((prev) => (prev === i ? null : i)), []) return (
{docs.length === 0 ? ( @@ -406,10 +413,11 @@ export function LogResults({ docs.map((doc, i) => ( setExpanded(expanded === i ? null : i)} + onToggle={toggle} onAdd={onAdd} /> ))