Elasticsearch: full indexer, rich search UI, Kibana dashboards + shortcut
- Backend: Trino-federated indexer pushes all sources (postgres/mysql/mongo/cassandra/iceberg/neo4j + catalog) into atc-* indices; adds /indices, /mapping, /query, /aggs, /reindex(+status), /kibana/setup(+links) - Kibana: auto-provision data views + ATC Data Overview dashboard (docs by source, top tables) - UI: rebuilt Search tab — KPIs, source/index charts, full-text + filtered search, facets, paginated doc viewer, re-index + Kibana buttons - SideNav: Kibana Dashboards shortcut
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ExternalLink, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BarChart3, ChevronDown, ChevronRight, Database, ExternalLink, Filter, LayoutDashboard,
|
||||
Layers, Loader2, Play, RefreshCw, Search, Sparkles, X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||
|
||||
@@ -10,20 +13,87 @@ type EsHealth = {
|
||||
health?: string
|
||||
nodes?: number
|
||||
indices_count?: number
|
||||
indices?: { name?: string; docs?: string; size?: string; health?: string }[]
|
||||
total_docs?: number
|
||||
atc_docs?: number
|
||||
error?: string
|
||||
url?: string
|
||||
}
|
||||
type KbHealth = { ok?: boolean; level?: string; ui_url?: string; url?: string; version?: string; error?: string }
|
||||
type IndexInfo = { name: string; docs: number; size_bytes: number; health?: string; atc?: boolean }
|
||||
type Field = { name: string; type?: string; aggregatable?: boolean }
|
||||
type Hit = { index?: string; id?: string; score?: number; source?: Record<string, unknown> }
|
||||
type ReindexState = {
|
||||
running?: boolean
|
||||
current?: string | null
|
||||
total_docs?: number
|
||||
index_count?: number
|
||||
errors?: string[]
|
||||
log?: string[]
|
||||
finished_at?: string | null
|
||||
}
|
||||
type Filt = { field: string; value: string }
|
||||
|
||||
type KbHealth = { ok?: boolean; level?: string; ui_url?: string; url?: string; error?: string }
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
iceberg: '#34d399',
|
||||
postgres_sales: '#38bdf8',
|
||||
mysql_hr: '#f59e0b',
|
||||
mongodb_supplychain: '#22c55e',
|
||||
cassandra_telemetry: '#a78bfa',
|
||||
neo4j: '#f472b6',
|
||||
catalog: '#94a3b8',
|
||||
}
|
||||
|
||||
function fmtBytes(n: number) {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let i = 0
|
||||
let v = n
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
|
||||
return `${v.toFixed(i ? 1 : 0)} ${u[i]}`
|
||||
}
|
||||
function fmtVal(v: unknown): string {
|
||||
if (v === null || v === undefined) return '—'
|
||||
if (typeof v === 'object') return JSON.stringify(v)
|
||||
return String(v)
|
||||
}
|
||||
function srcOf(index?: string): string {
|
||||
if (!index) return 'other'
|
||||
const m = index.replace(/^atc-/, '')
|
||||
for (const k of Object.keys(SOURCE_COLORS)) {
|
||||
if (m.startsWith(k.replace(/_/g, '-'))) return k
|
||||
}
|
||||
return m.split('-')[0]
|
||||
}
|
||||
|
||||
export function SearchView() {
|
||||
const [es, setEs] = useState<EsHealth | null>(null)
|
||||
const [kb, setKb] = useState<KbHealth | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [hits, setHits] = useState<{ index?: string; score?: number; source?: Record<string, unknown> }[]>([])
|
||||
const [indices, setIndices] = useState<IndexInfo[]>([])
|
||||
const [includeSystem, setIncludeSystem] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
||||
const [q, setQuery] = useState('')
|
||||
const [filters, setFilters] = useState<Filt[]>([])
|
||||
const [size, setSize] = useState(20)
|
||||
const [from, setFrom] = useState(0)
|
||||
const [sortField, setSortField] = useState('')
|
||||
const [results, setResults] = useState<{ total: number; took?: number; hits: Hit[] } | null>(null)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [showRaw, setShowRaw] = useState<string | null>(null)
|
||||
|
||||
const [sourceAgg, setSourceAgg] = useState<{ key: string; count: number }[]>([])
|
||||
const [reindex, setReindex] = useState<ReindexState | null>(null)
|
||||
const [kbMsg, setKbMsg] = useState<string | null>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// facets
|
||||
const [facetIndex, setFacetIndex] = useState('')
|
||||
const [facetFields, setFacetFields] = useState<Field[]>([])
|
||||
const [facetField, setFacetField] = useState('')
|
||||
const [facetBuckets, setFacetBuckets] = useState<{ key: string; count: number }[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -37,117 +107,381 @@ export function SearchView() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const iv = setInterval(load, 15000)
|
||||
return () => clearInterval(iv)
|
||||
}, [load])
|
||||
|
||||
const onSearch = async () => {
|
||||
if (!query.trim()) return
|
||||
setSearchError(null)
|
||||
const loadIndices = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/search/elasticsearch/query?q=${encodeURIComponent(query)}&size=15`)
|
||||
const r = await fetch(`/api/search/indices?include_system=${includeSystem}`)
|
||||
const j = await r.json()
|
||||
if (j.ok) setIndices(j.indices || [])
|
||||
} catch { /* ignore */ }
|
||||
}, [includeSystem])
|
||||
|
||||
const loadSourceAgg = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/search/aggs?index=atc-*&field=meta.catalog.keyword&size=20')
|
||||
const j = await r.json()
|
||||
if (j.ok) setSourceAgg(j.buckets || [])
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load(); loadSourceAgg() }, [load, loadSourceAgg])
|
||||
useEffect(() => { loadIndices() }, [loadIndices])
|
||||
|
||||
const runSearch = useCallback(async (resetFrom = true) => {
|
||||
setSearching(true)
|
||||
setSearchError(null)
|
||||
const nextFrom = resetFrom ? 0 : from
|
||||
if (resetFrom) setFrom(0)
|
||||
try {
|
||||
const r = await fetch('/api/search/query', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
q,
|
||||
indices: [...selected],
|
||||
filters,
|
||||
from: nextFrom,
|
||||
size,
|
||||
sort: sortField ? { field: sortField, order: 'desc' } : undefined,
|
||||
}),
|
||||
})
|
||||
const j = await r.json()
|
||||
if (!r.ok || !j.ok) {
|
||||
setSearchError(j.error || 'Search failed — set ELASTIC_PASSWORD in .env for query API')
|
||||
setHits([])
|
||||
setSearchError(j.error || 'Search failed')
|
||||
setResults(null)
|
||||
return
|
||||
}
|
||||
setHits(j.hits || [])
|
||||
setResults({ total: j.total, took: j.took, hits: j.hits || [] })
|
||||
} catch {
|
||||
setSearchError('Search request failed')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [q, selected, filters, from, size, sortField])
|
||||
|
||||
// re-run when paging/size/filters change
|
||||
useEffect(() => { runSearch(false) /* eslint-disable-next-line */ }, [from])
|
||||
useEffect(() => { if (results) runSearch(true) /* eslint-disable-next-line */ }, [filters, size])
|
||||
|
||||
const addFilter = (field: string, value: string) => {
|
||||
setFilters((f) => (f.some((x) => x.field === field && x.value === value) ? f : [...f, { field, value }]))
|
||||
}
|
||||
|
||||
const toggleIndex = (name: string) => {
|
||||
setSelected((s) => {
|
||||
const n = new Set(s)
|
||||
if (n.has(name)) n.delete(name)
|
||||
else n.add(name)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const startReindex = async () => {
|
||||
setKbMsg(null)
|
||||
await fetch('/api/search/reindex', { method: 'POST' }).catch(() => {})
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/search/reindex/status')
|
||||
const j = await r.json()
|
||||
setReindex(j)
|
||||
if (!j.running) {
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
load(); loadIndices(); loadSourceAgg()
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 1500)
|
||||
}
|
||||
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, [])
|
||||
|
||||
const setupKibana = async () => {
|
||||
setKbMsg('Setting up Kibana…')
|
||||
try {
|
||||
const r = await fetch('/api/search/kibana/setup', { method: 'POST' })
|
||||
const j = await r.json()
|
||||
setKbMsg(j.ok ? `Kibana ready — created ${j.created?.length ?? 0} objects (dashboard + data views).` : `Kibana setup failed: ${j.error}`)
|
||||
} catch {
|
||||
setKbMsg('Kibana setup request failed')
|
||||
}
|
||||
}
|
||||
|
||||
const loadFacetFields = async (index: string) => {
|
||||
setFacetIndex(index)
|
||||
setFacetField('')
|
||||
setFacetBuckets([])
|
||||
setFacetFields([])
|
||||
if (!index) return
|
||||
try {
|
||||
const r = await fetch(`/api/search/mapping?index=${encodeURIComponent(index)}`)
|
||||
const j = await r.json()
|
||||
if (j.ok) setFacetFields((j.fields || []).filter((f: Field) => f.aggregatable))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
const loadFacet = async (field: string) => {
|
||||
setFacetField(field)
|
||||
if (!facetIndex || !field) return
|
||||
try {
|
||||
const r = await fetch(`/api/search/aggs?index=${encodeURIComponent(facetIndex)}&field=${encodeURIComponent(field)}&size=15`)
|
||||
const j = await r.json()
|
||||
if (j.ok) setFacetBuckets(j.buckets || [])
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const healthColor = (h?: string) => (h === 'green' ? 'text-success' : h === 'yellow' ? 'text-warning' : 'text-danger')
|
||||
const kibanaBase = kb?.ui_url || ''
|
||||
|
||||
const topIndices = useMemo(() => [...indices].filter((i) => i.atc).sort((a, b) => b.docs - a.docs).slice(0, 10), [indices])
|
||||
const maxDocs = Math.max(1, ...topIndices.map((i) => i.docs))
|
||||
const aggMax = Math.max(1, ...sourceAgg.map((b) => b.count))
|
||||
|
||||
return (
|
||||
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* header */}
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Search className="h-4 w-4 text-docker" />
|
||||
Elasticsearch & Kibana
|
||||
<Search className="h-4 w-4 text-docker" /> Elasticsearch & Kibana
|
||||
<span className={cn('ml-1 rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase', es?.ok ? 'bg-success/15 text-success' : 'bg-danger/15 text-danger')}>
|
||||
{es?.ok ? (es.health || 'up') : 'offline'}
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-[10px] text-foreground-muted">atc-elastic01 · 10.0.21.46 · login: admin or elastic</p>
|
||||
<p className="text-[10px] text-foreground-muted">
|
||||
{es?.cluster_name || 'atc-lakehouse'} · {es?.version ? `v${es.version}` : '10.0.21.46'} · full-text search across every indexed source
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{kb?.ui_url && (
|
||||
<a href={kb.ui_url} target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
|
||||
<ExternalLink className="h-3 w-3" /> Kibana
|
||||
</a>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{kibanaBase && (
|
||||
<>
|
||||
<a href={`${kibanaBase}/app/dashboards#/view/atc-data-overview`} target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
|
||||
<LayoutDashboard className="h-3 w-3" /> Dashboard
|
||||
</a>
|
||||
<a href={`${kibanaBase}/app/discover`} target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabIdle)}>
|
||||
<ExternalLink className="h-3 w-3" /> Kibana
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={load} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
|
||||
<button type="button" onClick={setupKibana} className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||
<Sparkles className="h-3 w-3" /> Set up Kibana
|
||||
</button>
|
||||
<button type="button" onClick={startReindex} disabled={reindex?.running} className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px]', subTabIdle, reindex?.running && 'opacity-60')}>
|
||||
{reindex?.running ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />} Re-index all
|
||||
</button>
|
||||
<button type="button" onClick={() => { load(); loadIndices(); loadSourceAgg() }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid shrink-0 grid-cols-2 gap-3 border-b border-border p-4 md:grid-cols-4">
|
||||
<Stat label="Elasticsearch" value={es?.ok ? (es.health || 'up') : 'offline'} className={healthColor(es?.health)} />
|
||||
<Stat label="Cluster" value={es?.cluster_name || '—'} />
|
||||
{/* reindex / kibana banner */}
|
||||
{(reindex?.running || kbMsg) && (
|
||||
<div className="shrink-0 border-b border-border bg-docker/5 px-4 py-2 text-[11px] text-foreground-muted">
|
||||
{reindex?.running ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-docker" />
|
||||
Indexing <span className="font-mono text-docker">{reindex.current}</span> · {reindex.total_docs} docs · {reindex.index_count} indices
|
||||
</span>
|
||||
) : (
|
||||
<span>{kbMsg}{reindex?.finished_at && ` · last index run done (${reindex.total_docs} docs, ${reindex.index_count} indices${reindex.errors?.length ? `, ${reindex.errors.length} skipped` : ''})`}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid shrink-0 grid-cols-3 gap-3 border-b border-border p-4 md:grid-cols-6">
|
||||
<Stat label="Cluster" value={es?.health || '—'} className={healthColor(es?.health)} />
|
||||
<Stat label="Nodes" value={String(es?.nodes ?? '—')} />
|
||||
<Stat label="Indices" value={String(es?.indices_count ?? '—')} />
|
||||
<Stat label="Kibana" value={kb?.ok ? (kb.level || 'available') : 'offline'} className={kb?.ok ? 'text-success' : 'text-warning'} />
|
||||
<Stat label="Total docs" value={(es?.total_docs ?? 0).toLocaleString()} />
|
||||
<Stat label="ATC docs" value={(es?.atc_docs ?? 0).toLocaleString()} className="text-docker" />
|
||||
<Stat label="Kibana" value={kb?.ok ? (kb.level || 'up') : 'offline'} className={kb?.ok ? 'text-success' : 'text-warning'} />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-2 border-b border-border p-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onSearch()}
|
||||
placeholder="Search indices…"
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-3 py-2 text-[12px]"
|
||||
/>
|
||||
<button type="button" onClick={onSearch} className={cn('rounded px-3 py-2 text-[11px]', subTabActive)}>Search</button>
|
||||
{/* charts */}
|
||||
<div className="grid shrink-0 grid-cols-1 gap-4 border-b border-border p-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint"><BarChart3 className="h-3 w-3" /> Documents by source</h3>
|
||||
<div className="space-y-1">
|
||||
{sourceAgg.map((b) => (
|
||||
<div key={b.key} className="flex items-center gap-2">
|
||||
<span className="w-32 shrink-0 truncate font-mono text-[10px] text-foreground-muted">{b.key}</span>
|
||||
<div className="h-3 flex-1 overflow-hidden rounded bg-surface-overlay">
|
||||
<div className="h-full rounded" style={{ width: `${(b.count / aggMax) * 100}%`, background: SOURCE_COLORS[b.key] || '#64748b' }} />
|
||||
</div>
|
||||
<span className="w-12 shrink-0 text-right font-mono text-[10px] text-foreground">{b.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{sourceAgg.length === 0 && <p className="text-[11px] text-foreground-muted">No indexed data yet — click “Re-index all”.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint"><Database className="h-3 w-3" /> Top indices</h3>
|
||||
<div className="space-y-1">
|
||||
{topIndices.map((i) => (
|
||||
<div key={i.name} className="flex items-center gap-2">
|
||||
<span className="w-44 shrink-0 truncate font-mono text-[10px] text-foreground-muted" title={i.name}>{i.name.replace(/^atc-/, '')}</span>
|
||||
<div className="h-3 flex-1 overflow-hidden rounded bg-surface-overlay">
|
||||
<div className="h-full rounded" style={{ width: `${(i.docs / maxDocs) * 100}%`, background: SOURCE_COLORS[srcOf(i.name)] || '#38bdf8' }} />
|
||||
</div>
|
||||
<span className="w-10 shrink-0 text-right font-mono text-[10px] text-foreground">{i.docs}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* search bar */}
|
||||
<div className="shrink-0 space-y-2 border-b border-border p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && runSearch(true)}
|
||||
placeholder="Search all data… (e.g. region:EMEA OR status:shipped OR free text)"
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-3 py-2 text-[12px]"
|
||||
/>
|
||||
<select value={size} onChange={(e) => setSize(Number(e.target.value))} className="rounded border border-border bg-surface-overlay px-2 py-2 text-[11px]">
|
||||
{[10, 20, 50, 100].map((n) => <option key={n} value={n}>{n}/page</option>)}
|
||||
</select>
|
||||
<button type="button" onClick={() => runSearch(true)} className={cn('rounded px-4 py-2 text-[11px] font-medium', subTabActive)}>
|
||||
{searching ? <Loader2 className="inline h-3 w-3 animate-spin" /> : <Search className="inline h-3 w-3" />} Search
|
||||
</button>
|
||||
</div>
|
||||
{(filters.length > 0 || selected.size > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{selected.size > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-docker/15 px-2 py-0.5 text-[10px] text-docker">
|
||||
<Database className="h-3 w-3" /> {selected.size} {selected.size === 1 ? 'index' : 'indices'} scoped
|
||||
<button type="button" onClick={() => setSelected(new Set())}><X className="h-3 w-3" /></button>
|
||||
</span>
|
||||
)}
|
||||
{filters.map((f, i) => (
|
||||
<span key={`${f.field}-${i}`} className="inline-flex items-center gap-1 rounded bg-surface-overlay px-2 py-0.5 font-mono text-[10px] text-foreground">
|
||||
<Filter className="h-3 w-3 text-docker" /> {f.field}: {f.value}
|
||||
<button type="button" onClick={() => setFilters((arr) => arr.filter((_, j) => j !== i))}><X className="h-3 w-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searchError && <p className="px-4 py-2 text-[11px] text-warning">{searchError}</p>}
|
||||
|
||||
<div className="scrollbar-thin flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 md:flex-row">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Indices</h3>
|
||||
{es?.indices && es.indices.length > 0 ? (
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
|
||||
<th className="py-1">Index</th>
|
||||
<th className="py-1">Docs</th>
|
||||
<th className="py-1">Size</th>
|
||||
<th className="py-1">Health</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{es.indices.map((i) => (
|
||||
<tr key={i.name} className="border-b border-border/40">
|
||||
<td className="py-1 font-mono text-[10px]">{i.name}</td>
|
||||
<td className="py-1">{i.docs ?? '—'}</td>
|
||||
<td className="py-1">{i.size ?? '—'}</td>
|
||||
<td className={cn('py-1', healthColor(i.health))}>{i.health}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-[11px] text-foreground-muted">
|
||||
{es?.ok ? 'No indices listed.' : (es?.error || 'Elasticsearch :9200 not reachable from Command Center — Kibana :5601 may still be up.')}
|
||||
</p>
|
||||
)}
|
||||
{/* body: indices | results | facets */}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
{/* indices */}
|
||||
<div className="flex w-60 shrink-0 flex-col border-r border-border">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Indices ({indices.length})</span>
|
||||
<button type="button" onClick={() => setIncludeSystem((v) => !v)} className="text-[9px] text-foreground-muted hover:text-docker">
|
||||
{includeSystem ? 'hide system' : 'show system'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{indices.map((i) => (
|
||||
<div key={i.name} className={cn('group mb-0.5 flex items-center gap-1.5 rounded px-1.5 py-1 text-[10px] hover:bg-surface-overlay', selected.has(i.name) && 'bg-docker/10')}>
|
||||
<input type="checkbox" checked={selected.has(i.name)} onChange={() => toggleIndex(i.name)} className="shrink-0" />
|
||||
<button type="button" onClick={() => loadFacetFields(i.name)} className="min-w-0 flex-1 text-left" title={i.name}>
|
||||
<span className="block truncate font-mono text-foreground">{i.name.replace(/^atc-/, '')}</span>
|
||||
<span className="text-foreground-faint">{i.docs} docs · {fmtBytes(i.size_bytes)}</span>
|
||||
</button>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: SOURCE_COLORS[srcOf(i.name)] || '#64748b' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hits.length > 0 && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Search results</h3>
|
||||
{/* results */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between border-b border-border px-3 py-1.5 text-[10px] text-foreground-muted">
|
||||
<span>{results ? `${results.total.toLocaleString()} hits · ${results.took ?? 0} ms` : 'Run a search to see results'}</span>
|
||||
{results && results.total > size && (
|
||||
<span className="flex items-center gap-2">
|
||||
<button type="button" disabled={from === 0} onClick={() => setFrom(Math.max(0, from - size))} className="rounded px-1.5 py-0.5 hover:bg-surface-overlay disabled:opacity-40">Prev</button>
|
||||
<span>{from + 1}–{Math.min(from + size, results.total)}</span>
|
||||
<button type="button" disabled={from + size >= results.total} onClick={() => setFrom(from + size)} className="rounded px-1.5 py-0.5 hover:bg-surface-overlay disabled:opacity-40">Next</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{!results && <p className="text-[11px] text-foreground-muted">Search across {(es?.atc_docs ?? 0).toLocaleString()} indexed records from Postgres, MySQL, MongoDB, Cassandra, Neo4j and the Iceberg lakehouse.</p>}
|
||||
{results?.hits.length === 0 && <p className="text-[11px] text-foreground-muted">No matches.</p>}
|
||||
<div className="space-y-2">
|
||||
{hits.map((h, i) => (
|
||||
<div key={i} className="rounded border border-border bg-surface-overlay/50 p-2 text-[10px]">
|
||||
<p className="font-mono text-docker">{h.index} · score {h.score?.toFixed(2)}</p>
|
||||
<pre className="mt-1 max-h-24 overflow-auto whitespace-pre-wrap text-foreground-muted">{JSON.stringify(h.source, null, 2).slice(0, 400)}</pre>
|
||||
</div>
|
||||
))}
|
||||
{results?.hits.map((h) => {
|
||||
const key = `${h.index}-${h.id}`
|
||||
const src = (h.source || {}) as Record<string, unknown>
|
||||
const meta = (src.meta || {}) as Record<string, unknown>
|
||||
const fields = Object.entries(src).filter(([k]) => k !== 'meta')
|
||||
const isOpen = expanded === key
|
||||
return (
|
||||
<div key={key} className="rounded border border-border bg-surface-overlay/40">
|
||||
<button type="button" onClick={() => setExpanded(isOpen ? null : key)} className="flex w-full items-center gap-2 px-2.5 py-1.5 text-left">
|
||||
{isOpen ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
|
||||
<span className="h-2 w-2 shrink-0 rounded-full" style={{ background: SOURCE_COLORS[srcOf(h.index)] || '#64748b' }} />
|
||||
<span className="shrink-0 font-mono text-[10px] text-docker">{h.index?.replace(/^atc-/, '')}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-foreground-muted">
|
||||
{fields.slice(0, 4).map(([k, v]) => `${k}=${fmtVal(v)}`).join(' · ')}
|
||||
</span>
|
||||
{typeof h.score === 'number' && <span className="shrink-0 text-[9px] text-foreground-faint">★ {h.score.toFixed(2)}</span>}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-border/60 px-3 py-2">
|
||||
<div className="mb-2 flex items-center gap-2 text-[9px] text-foreground-faint">
|
||||
<span>id: {h.id}</span>
|
||||
{meta.fqn ? <span>· source: {String(meta.fqn)}</span> : null}
|
||||
<button type="button" onClick={() => setShowRaw(showRaw === key ? null : key)} className="ml-auto rounded border border-border px-1.5 py-0.5 hover:bg-surface-overlay">{showRaw === key ? 'fields' : 'raw JSON'}</button>
|
||||
</div>
|
||||
{showRaw === key ? (
|
||||
<pre className="max-h-72 overflow-auto whitespace-pre-wrap rounded bg-surface-base p-2 text-[10px] text-foreground-muted">{JSON.stringify(src, null, 2)}</pre>
|
||||
) : (
|
||||
<table className="w-full text-left text-[10px]">
|
||||
<tbody>
|
||||
{fields.map(([k, v]) => (
|
||||
<tr key={k} className="border-b border-border/30">
|
||||
<td className="w-40 py-1 pr-2 align-top font-mono text-foreground-faint">{k}</td>
|
||||
<td className="py-1 align-top text-foreground">
|
||||
<span className="break-words">{fmtVal(v)}</span>
|
||||
<button type="button" onClick={() => addFilter(`${k}.keyword`, fmtVal(v))} title="Filter by this value" className="ml-1.5 align-middle text-foreground-faint hover:text-docker"><Filter className="inline h-2.5 w-2.5" /></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* facets */}
|
||||
<div className="hidden w-60 shrink-0 flex-col border-l border-border lg:flex">
|
||||
<div className="border-b border-border px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">
|
||||
<Layers className="mr-1 inline h-3 w-3" /> Facets
|
||||
</div>
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{!facetIndex && <p className="text-[11px] text-foreground-muted">Click an index name on the left to explore its fields and top values.</p>}
|
||||
{facetIndex && (
|
||||
<>
|
||||
<p className="mb-1 truncate font-mono text-[10px] text-docker" title={facetIndex}>{facetIndex.replace(/^atc-/, '')}</p>
|
||||
<select value={facetField} onChange={(e) => loadFacet(e.target.value)} className="mb-2 w-full rounded border border-border bg-surface-overlay px-2 py-1.5 text-[10px]">
|
||||
<option value="">Select a field…</option>
|
||||
{facetFields.map((f) => <option key={f.name} value={f.name}>{f.name} ({f.type})</option>)}
|
||||
</select>
|
||||
<div className="space-y-1">
|
||||
{facetBuckets.map((b) => (
|
||||
<button key={String(b.key)} type="button" onClick={() => addFilter(facetField, String(b.key))} className="flex w-full items-center justify-between gap-2 rounded px-1.5 py-1 text-left text-[10px] hover:bg-surface-overlay">
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{String(b.key)}</span>
|
||||
<span className="shrink-0 rounded bg-surface-overlay px-1.5 font-mono text-foreground-muted">{b.count}</span>
|
||||
</button>
|
||||
))}
|
||||
{facetField && facetBuckets.length === 0 && <p className="text-[10px] text-foreground-faint">No aggregatable values.</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -157,7 +491,7 @@ function Stat({ label, value, className }: { label: string; value: string; class
|
||||
return (
|
||||
<div className="rounded border border-border bg-surface-overlay/60 px-3 py-2 text-center">
|
||||
<p className="text-[9px] uppercase text-foreground-faint">{label}</p>
|
||||
<p className={cn('font-mono text-sm font-semibold capitalize', className || 'text-foreground')}>{value}</p>
|
||||
<p className={cn('truncate font-mono text-sm font-semibold capitalize', className || 'text-foreground')}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Database, DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Server, TerminalSquare, Activity, GitBranch } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Database, DatabaseZap, HardDrive, Search, LayoutDashboard, MessageSquare, Server, TerminalSquare, Activity, GitBranch, ExternalLink } from 'lucide-react'
|
||||
import type { GpuStatus, WorkloadData } from '../../types'
|
||||
import type { GpuLiveMetrics } from '../../hooks/useLiveMetrics'
|
||||
import { cn } from '../../lib/utils'
|
||||
@@ -47,6 +48,13 @@ export function SideNav({
|
||||
onOpenSsh,
|
||||
}: Props) {
|
||||
const matrixBoost = gpuBoost || mainView === 'knowledge'
|
||||
const [kibanaUrl, setKibanaUrl] = useState<string | null>(null)
|
||||
useEffect(() => {
|
||||
fetch('/api/search/health')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((j) => { if (j?.kibana?.ui_url) setKibanaUrl(j.kibana.ui_url) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav className="flex h-full min-h-0 w-[240px] shrink-0 flex-col border-r border-border bg-surface-raised">
|
||||
@@ -75,6 +83,18 @@ export function SideNav({
|
||||
<TerminalSquare className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-[11px] font-medium text-foreground">SSH Terminal</span>
|
||||
</button>
|
||||
{kibanaUrl && (
|
||||
<a
|
||||
href={`${kibanaUrl}/app/dashboards#/view/atc-data-overview`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn('flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left transition-all', viewTabIdle)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 text-amber-400" />
|
||||
<span className="flex-1 text-[11px] font-medium text-foreground">Kibana Dashboards</span>
|
||||
<ExternalLink className="h-3 w-3 text-foreground-faint" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user