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:
mo
2026-06-28 13:23:27 +00:00
parent fcf85b0e4c
commit 3a054ac13d
3 changed files with 976 additions and 122 deletions
+416 -82
View File
@@ -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>
)
}
+21 -1
View File
@@ -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>