feat: Generate-data button + generation-script viewer + vector DB explorer

Data Flow tab:
- Prominent "Generate data" button (500 / 2K / 10K) that inserts a fresh
  burst of business rows into all source DBs on demand via a new
  POST /api/federated/generate (fresh connections, safe alongside the
  background streamer); result toast shows what was inserted, CDC streams it.
- "Scripts" button + a "View generation scripts" action on the Data Generator
  node open a modal listing every generator script with full source, served by
  GET /api/dataflow/scripts. Sources are the real files: the live streaming
  generator (sliced live out of trino_federated.py) and the Airflow per-source
  DAGs + Faker scripts (mounted read-only from infra/airflow into the API).

Knowledge Chat:
- New "Vector DB" explorer modal: shows the ChromaDB chunking config
  (RecursiveCharacterTextSplitter 800/120, all-MiniLM-L6-v2, 384-dim, HNSW),
  collections & documents, and the actual stored chunks with text, metadata and
  an embedding preview (bars + values) so you can see exactly how files are
  split and written as vectors.

Refactor: generator row-builders shared by the streamer and the on-demand burst.
This commit is contained in:
mo
2026-06-28 22:06:27 +00:00
parent 9059006cc2
commit 213350ec75
5 changed files with 528 additions and 25 deletions
+74
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import os import os
import time import time
from pathlib import Path
from typing import Any from typing import Any
import httpx import httpx
@@ -300,6 +301,79 @@ async def get_dataflow(refresh: bool = False) -> JSONResponse:
return JSONResponse(data) return JSONResponse(data)
GEN_SCRIPTS_DIR = os.getenv("GEN_SCRIPTS_DIR", "/app/gen_scripts")
# Which generation scripts to surface, in display order. `file` is relative to
# GEN_SCRIPTS_DIR (the mounted infra/airflow dir); `live` slices the running
# streamer source straight out of trino_federated.py so it is always in sync.
_SCRIPT_SPECS: list[dict[str, Any]] = [
{"id": "live", "title": "Live streaming generator", "engine": "Command Center API",
"desc": "Runs inside this API. While the Live dashboard is open it streams randomly-sized bursts of rows into PostgreSQL, MySQL, MongoDB & Cassandra every few seconds (and powers the manual 'Generate data' button). CDC propagates everything downstream.",
"live": True},
{"id": "dag", "title": "Airflow per-source DAGs", "engine": "Apache Airflow",
"desc": "One triggerable DAG per database. Each passes a row count via dag_run conf and shells out to the matching generator script below.",
"file": "per_source_gen_dags.py"},
{"id": "postgres", "title": "PostgreSQL — sales orders", "engine": "Faker → psycopg2",
"desc": "Generates realistic customers, products, regions, channels & amounts (incl. the PII columns that the masking layer later protects).",
"file": "scripts/generate_postgres_sales_data.py"},
{"id": "mysql", "title": "MySQL — employee events", "engine": "Faker → PyMySQL",
"desc": "HR lifecycle events (hire, promotion, salary change, …) with employee PII.",
"file": "scripts/generate_mysql_employee_data.py"},
{"id": "mongodb", "title": "MongoDB — supply events", "engine": "Faker → PyMongo",
"desc": "Schemaless supply-chain events with free-form payloads.",
"file": "scripts/generate_mongodb_events_data.py"},
{"id": "cassandra", "title": "Cassandra — device telemetry", "engine": "Faker → cassandra-driver",
"desc": "High-volume IoT device metrics (temperature, voltage, …) on a time-series schema.",
"file": "scripts/generate_cassandra_telemetry_data.py"},
{"id": "neo4j", "title": "Neo4j — product & supplier graph", "engine": "Faker → neo4j driver",
"desc": "Product/supplier nodes and relationships for the graph database.",
"file": "scripts/generate_neo4j_graph_data.py"},
]
def _live_generator_source() -> str:
try:
text = Path("/app/trino_federated.py").read_text(encoding="utf-8")
except Exception:
return "# live generator source unavailable"
start = text.find("# Continuous live generator")
end = text.find('@router.get("/live")', start if start >= 0 else 0)
if start >= 0 and end > start:
return text[start:end].rstrip()
return "# live generator source unavailable"
_scripts_cache: dict[str, Any] = {"ts": 0.0, "data": None}
@router.get("/scripts")
async def get_scripts() -> JSONResponse:
now = time.time()
if _scripts_cache["data"] and now - _scripts_cache["ts"] < 30:
return JSONResponse(_scripts_cache["data"])
base = Path(GEN_SCRIPTS_DIR)
scripts = []
for spec in _SCRIPT_SPECS:
src = ""
if spec.get("live"):
src = _live_generator_source()
else:
p = base / spec["file"]
try:
src = p.read_text(encoding="utf-8")
except Exception as exc:
src = f"# source unavailable ({exc})"
scripts.append({
"id": spec["id"], "title": spec["title"], "engine": spec["engine"],
"desc": spec["desc"], "filename": spec.get("file", "trino_federated.py"),
"language": "python", "lines": src.count("\n") + 1, "source": src,
})
data = {"ok": True, "scripts": scripts}
_scripts_cache["data"] = data
_scripts_cache["ts"] = now
return JSONResponse(data)
@router.post("/{movement_id}/run") @router.post("/{movement_id}/run")
async def run_dataflow_movement(movement_id: str, body: dict[str, Any] = Body(default={})) -> JSONResponse: async def run_dataflow_movement(movement_id: str, body: dict[str, Any] = Body(default={})) -> JSONResponse:
try: try:
+135 -24
View File
@@ -392,7 +392,17 @@ def _gen_reset(key: str):
_gen_conns[key] = None _gen_conns[key] = None
def _gen_orders(n: int): # Row builders — shared by the background streamer and the on-demand "Generate
# data" button, so both produce identical, realistic business rows.
_PG_INSERT = ("INSERT INTO public.sales_orders "
"(customer_id,product_id,region,sales_channel,order_ts,amount,currency,order_status) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)")
_MYSQL_INSERT = ("INSERT INTO employee_events "
"(employee_id,department,role_name,region,event_type,salary_change,event_ts) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)")
def _order_rows(n: int):
import datetime as dt import datetime as dt
now = dt.datetime.utcnow() now = dt.datetime.utcnow()
by_r: dict[str, int] = {} by_r: dict[str, int] = {}
@@ -408,49 +418,133 @@ def _gen_orders(n: int):
by_r[r] = by_r.get(r, 0) + 1 by_r[r] = by_r.get(r, 0) + 1
by_s[st] = by_s.get(st, 0) + 1 by_s[st] = by_s.get(st, 0) + 1
val += amt val += amt
cur = _gen_pg().cursor() return rows, by_r, by_s, round(val, 2)
cur.executemany(
"INSERT INTO public.sales_orders "
"(customer_id,product_id,region,sales_channel,order_ts,amount,currency,order_status) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", rows)
return by_r, by_s, round(val, 2)
def _gen_hr(n: int): def _hr_rows(n: int):
import datetime as dt import datetime as dt
now = dt.datetime.utcnow() now = dt.datetime.utcnow()
rows = [(_rnd.randint(1, 100000), _rnd.choice(_DEPTS), _rnd.choice(_ROLES), return [(_rnd.randint(1, 100000), _rnd.choice(_DEPTS), _rnd.choice(_ROLES),
_rnd.choice(_REGIONS), _rnd.choice(_EVT), round(_rnd.uniform(-2000, 6000), 2), now) _rnd.choice(_REGIONS), _rnd.choice(_EVT), round(_rnd.uniform(-2000, 6000), 2), now)
for _ in range(n)] for _ in range(n)]
cur = _gen_mysql().cursor()
cur.executemany(
"INSERT INTO employee_events "
"(employee_id,department,role_name,region,event_type,salary_change,event_ts) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)", rows)
def _gen_supply(n: int): def _supply_docs(n: int):
import datetime as dt import datetime as dt
import uuid import uuid
now = dt.datetime.utcnow() now = dt.datetime.utcnow()
docs = [{"event_id": str(uuid.uuid4()), "type": _rnd.choice(_SUPPLY), return [{"event_id": str(uuid.uuid4()), "type": _rnd.choice(_SUPPLY),
"region": _rnd.choice(_REGIONS), "source": _rnd.choice(_SRC), "region": _rnd.choice(_REGIONS), "source": _rnd.choice(_SRC),
"amount": round(_rnd.uniform(10, 40000), 2), "ts": now.isoformat()} "amount": round(_rnd.uniform(10, 40000), 2), "ts": now.isoformat()}
for _ in range(n)] for _ in range(n)]
def _tel_rows(n: int):
import datetime as dt
now = dt.datetime.utcnow()
return [(f"device-{_rnd.randint(1, 99999)}", now, _rnd.choice(_METRICS),
round(_rnd.uniform(0, 100), 3), "") for _ in range(n)]
_TEL_INSERT_TPL = ("INSERT INTO {ks}.device_metrics "
"(device_id, metric_ts, metric_type, metric_value, payload) VALUES (%s,%s,%s,%s,%s)")
def _gen_orders(n: int):
rows, by_r, by_s, val = _order_rows(n)
_gen_pg().cursor().executemany(_PG_INSERT, rows)
return by_r, by_s, val
def _gen_hr(n: int):
_gen_mysql().cursor().executemany(_MYSQL_INSERT, _hr_rows(n))
def _gen_supply(n: int):
docs = _supply_docs(n)
if docs: if docs:
_gen_mongo()["events"].insert_many(docs) _gen_mongo()["events"].insert_many(docs)
def _gen_tel(n: int): def _gen_tel(n: int):
import datetime as dt
now = dt.datetime.utcnow()
sess = _gen_cass()
import sql_console as s import sql_console as s
cql = (f"INSERT INTO {s.CASS_KS}.device_metrics " sess = _gen_cass()
"(device_id, metric_ts, metric_type, metric_value, payload) VALUES (%s,%s,%s,%s,%s)") cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS)
for _ in range(n): for row in _tel_rows(n):
sess.execute(cql, (f"device-{_rnd.randint(1, 99999)}", now, sess.execute(cql, row)
_rnd.choice(_METRICS), round(_rnd.uniform(0, 100), 3), ""))
def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any]:
"""On-demand burst using FRESH short-lived connections (safe to run from a
request thread alongside the background streamer). Returns inserted counts."""
import sql_console as s
out = {"orders": 0, "hr_events": 0, "supply_events": 0, "telemetry": 0}
by_r: dict[str, int] = {}
by_s: dict[str, int] = {}
val = 0.0
if orders > 0:
try:
import psycopg2
rows, by_r, by_s, val = _order_rows(orders)
c = psycopg2.connect(host=s.DB_HOST, port=s.PG_PORT, user=s.PG_USER, password=s.PG_PASS, dbname=s.PG_DB, connect_timeout=8)
try:
c.autocommit = True
c.cursor().executemany(_PG_INSERT, rows)
out["orders"] = orders
finally:
c.close()
except Exception:
pass
if hr > 0:
try:
import pymysql
c = pymysql.connect(host=s.DB_HOST, port=s.MYSQL_PORT, user=s.MYSQL_USER, password=s.MYSQL_PASS, database=s.MYSQL_DB, connect_timeout=8, autocommit=True)
try:
c.cursor().executemany(_MYSQL_INSERT, _hr_rows(hr))
out["hr_events"] = hr
finally:
c.close()
except Exception:
pass
if supply > 0:
try:
cli = s._mongo_client()
try:
cli[s.MONGO_DB]["events"].insert_many(_supply_docs(supply))
out["supply_events"] = supply
finally:
cli.close()
except Exception:
pass
if tel > 0:
try:
cluster = s._cass_cluster()
sess = cluster.connect()
try:
cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS)
for row in _tel_rows(tel):
sess.execute(cql, row)
out["telemetry"] = tel
finally:
cluster.shutdown()
except Exception:
pass
# fold into the live counters + feed so the dashboard reflects it instantly
with _gen_lock:
c = _GEN["counts"]
for k in out:
c[k] += out[k]
if out["orders"]:
_GEN["by_region"] = by_r
_GEN["by_status"] = by_s
_GEN["tick_value"] = val
top = max(by_r, key=by_r.get) if by_r else ""
_GEN["feed"].appendleft({
"ts": datetime.now(timezone.utc).isoformat(),
"text": f"⚡ manual burst: +{out['orders']} orders · €{int(val):,} · top {top} · +{out['telemetry']} telemetry · +{out['hr_events']} HR · +{out['supply_events']} supply",
})
out["revenue"] = val
return out
def _gen_tick(): def _gen_tick():
@@ -528,6 +622,23 @@ async def toggle_generator(body: dict = Body(default={})):
return {"ok": True, "enabled": _GEN["enabled"], "interval": _GEN["interval"], "running": _GEN["running"]} return {"ok": True, "enabled": _GEN["enabled"], "interval": _GEN["interval"], "running": _GEN["running"]}
@router.post("/generate")
async def generate_now(body: dict = Body(default={})):
"""Manual one-shot burst into the source systems (the Data Flow "Generate
data" button). `rows` controls the order volume; the other sources scale
with it. CDC streams everything downstream automatically."""
from starlette.concurrency import run_in_threadpool
rows = int(body.get("rows", 500) or 500)
rows = max(1, min(20000, rows))
orders = rows
hr = max(1, rows // 4)
supply = max(1, rows // 4)
tel = max(1, rows // 2)
out = await run_in_threadpool(_generate_once, orders, hr, supply, tel)
total = out["orders"] + out["hr_events"] + out["supply_events"] + out["telemetry"]
return {"ok": True, "requested": rows, "inserted": out, "total": total}
@router.get("/live") @router.get("/live")
async def get_live(): async def get_live():
import sql_console as s import sql_console as s
+1
View File
@@ -55,6 +55,7 @@ services:
volumes: volumes:
- api_data:/data - api_data:/data
- /root/.ssh:/root/.ssh:ro - /root/.ssh:/root/.ssh:ro
- ./infra/airflow:/app/gen_scripts:ro
depends_on: depends_on:
redis: redis:
condition: service_started condition: service_started
+150 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { GitBranch, Lock, LockOpen, Play, Pause, Square, RefreshCw, ShieldAlert, ShieldCheck, Loader2, Bot } from 'lucide-react' import { GitBranch, Lock, LockOpen, Play, Pause, Square, RefreshCw, ShieldAlert, ShieldCheck, Loader2, Bot, Zap, Code2, X, FileCode2 } from 'lucide-react'
import type { DataflowEdge, DataflowGraph, DataflowNode } from '../../types' import type { DataflowEdge, DataflowGraph, DataflowNode } from '../../types'
import { fetchDataflow, runDataflowMovement, toggleEtlAgent, toggleCustodianOffload, fetchAgentOpsStatus, setPiiMask, setStreamingFlow } from '../../lib/api' import { fetchDataflow, runDataflowMovement, toggleEtlAgent, toggleCustodianOffload, fetchAgentOpsStatus, setPiiMask, setStreamingFlow } from '../../lib/api'
import { SparkKafkaPanel } from './SparkKafkaPanel' import { SparkKafkaPanel } from './SparkKafkaPanel'
@@ -85,6 +85,10 @@ export function DataFlowView() {
const [triggering, setTriggering] = useState<string | null>(null) const [triggering, setTriggering] = useState<string | null>(null)
const [etlEnabled, setEtlEnabled] = useState<boolean | null>(null) const [etlEnabled, setEtlEnabled] = useState<boolean | null>(null)
const [custEnabled, setCustEnabled] = useState<boolean | null>(null) const [custEnabled, setCustEnabled] = useState<boolean | null>(null)
const [genRows, setGenRows] = useState(2000)
const [genBusy, setGenBusy] = useState(false)
const [genToast, setGenToast] = useState<string | null>(null)
const [showScripts, setShowScripts] = useState(false)
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({}) const nodeRefs = useRef<Record<string, HTMLButtonElement | null>>({})
@@ -181,6 +185,26 @@ export function DataFlowView() {
setTimeout(() => setRefreshing(false), 400) setTimeout(() => setRefreshing(false), 400)
}, [load]) }, [load])
const onGenerate = useCallback(async () => {
setGenBusy(true)
setGenToast(null)
try {
const r = await fetch('/api/federated/generate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ rows: genRows }),
})
const d = await r.json()
const i = d?.inserted || {}
setGenToast(`Generated +${(i.orders ?? 0).toLocaleString()} orders · +${(i.hr_events ?? 0).toLocaleString()} HR · +${(i.supply_events ?? 0).toLocaleString()} supply · +${(i.telemetry ?? 0).toLocaleString()} telemetry → streaming via CDC`)
setTimeout(() => load(true), 700)
setTimeout(() => setGenToast(null), 7000)
} catch {
setGenToast('Generation failed — check the API logs')
setTimeout(() => setGenToast(null), 5000)
} finally {
setGenBusy(false)
}
}, [genRows, load])
const flowMode = (graph as unknown as { flow?: string })?.flow ?? 'running' const flowMode = (graph as unknown as { flow?: string })?.flow ?? 'running'
const onFlow = useCallback(async (action: 'pause' | 'resume' | 'stop') => { const onFlow = useCallback(async (action: 'pause' | 'resume' | 'stop') => {
await setStreamingFlow(action) await setStreamingFlow(action)
@@ -282,6 +306,34 @@ export function DataFlowView() {
> >
<Bot className="h-3 w-3" /> Hadoop offload {custEnabled == null ? '' : custEnabled ? 'on' : 'off'} <Bot className="h-3 w-3" /> Hadoop offload {custEnabled == null ? '' : custEnabled ? 'on' : 'off'}
</button> </button>
<div className="inline-flex items-center gap-0.5 rounded border border-emerald-400/40 bg-emerald-500/10 p-0.5" title="Insert a burst of fresh business rows into the source databases — CDC streams them downstream instantly">
<button
type="button"
onClick={onGenerate}
disabled={genBusy}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold text-emerald-200 transition-colors hover:bg-emerald-500/20 disabled:opacity-60"
>
{genBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />} Generate data
</button>
<select
value={genRows}
onChange={(e) => setGenRows(Number(e.target.value))}
className="rounded bg-transparent text-[9px] text-emerald-200 outline-none"
title="Rows to generate (orders; other sources scale with it)"
>
<option className="bg-surface text-foreground" value={500}>500</option>
<option className="bg-surface text-foreground" value={2000}>2K</option>
<option className="bg-surface text-foreground" value={10000}>10K</option>
</select>
</div>
<button
type="button"
onClick={() => setShowScripts(true)}
title="See the data-generation scripts powering this pipeline"
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[9px] text-foreground-muted hover:text-foreground"
>
<FileCode2 className="h-3 w-3" /> Scripts
</button>
<div className="inline-flex items-center gap-0.5 rounded border border-border p-0.5" title="Master pulse control"> <div className="inline-flex items-center gap-0.5 rounded border border-border p-0.5" title="Master pulse control">
<button type="button" onClick={() => onFlow('resume')} <button type="button" onClick={() => onFlow('resume')}
className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors', flowMode === 'running' ? 'bg-emerald-500/25 text-emerald-200' : 'text-foreground-muted hover:text-foreground')}> className={cn('inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-medium transition-colors', flowMode === 'running' ? 'bg-emerald-500/25 text-emerald-200' : 'text-foreground-muted hover:text-foreground')}>
@@ -315,6 +367,14 @@ export function DataFlowView() {
</div> </div>
</header> </header>
{genToast && (
<div className="shrink-0 border-b border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-[10px] text-emerald-200">
<span className="inline-flex items-center gap-1.5"><Zap className="h-3 w-3" /> {genToast}</span>
</div>
)}
{showScripts && <ScriptsModal onClose={() => setShowScripts(false)} onGenerate={onGenerate} genBusy={genBusy} />}
{/* canvas */} {/* canvas */}
<div ref={canvasRef} className="topo-canvas relative flex min-h-0 flex-1"> <div ref={canvasRef} className="topo-canvas relative flex min-h-0 flex-1">
{loading && ( {loading && (
@@ -402,6 +462,20 @@ export function DataFlowView() {
{(selNode.id === 'spark' || selNode.id === 'kafka') && ( {(selNode.id === 'spark' || selNode.id === 'kafka') && (
<p className="mt-1 text-[8px] text-docker/80">See Spark/Kafka panel below for full UI + job control.</p> <p className="mt-1 text-[8px] text-docker/80">See Spark/Kafka panel below for full UI + job control.</p>
)} )}
{selNode.id === 'generator' && (
<div className="mt-1.5 border-t border-border pt-1.5">
<p className="mb-1 text-[8px] leading-tight text-foreground-faint">
Generates realistic business rows into every source database. Stream it live with Generate data, or open the actual scripts.
</p>
<button
type="button"
onClick={() => setShowScripts(true)}
className="inline-flex w-full items-center justify-center gap-1 rounded border border-emerald-400/40 bg-emerald-500/15 px-1.5 py-1 text-[8px] font-medium text-emerald-200 hover:bg-emerald-500/25"
>
<Code2 className="h-3 w-3" /> View generation scripts
</button>
</div>
)}
{selNode.pii?.has_pii ? ( {selNode.pii?.has_pii ? (
<div className="mt-1.5 border-t border-border pt-1.5"> <div className="mt-1.5 border-t border-border pt-1.5">
<div className="mb-1 flex items-center justify-between gap-1 text-[9px] font-medium text-rose-300"> <div className="mb-1 flex items-center justify-between gap-1 text-[9px] font-medium text-rose-300">
@@ -557,3 +631,78 @@ function NodeCard({
</button> </button>
) )
} }
type GenScript = { id: string; title: string; engine: string; desc: string; filename: string; language: string; lines: number; source: string }
function ScriptsModal({ onClose, onGenerate, genBusy }: { onClose: () => void; onGenerate: () => void; genBusy: boolean }) {
const [scripts, setScripts] = useState<GenScript[]>([])
const [active, setActive] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState(false)
useEffect(() => {
let alive = true
fetch('/api/dataflow/scripts')
.then((r) => r.json())
.then((d) => {
if (!alive) return
const list: GenScript[] = d?.scripts || []
setScripts(list)
setActive(list[0]?.id ?? null)
setLoading(false)
})
.catch(() => { if (alive) { setErr(true); setLoading(false) } })
return () => { alive = false }
}, [])
const sel = scripts.find((s) => s.id === active) || null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="flex h-[85vh] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-border bg-surface shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-300"><Code2 className="h-4 w-4" /></span>
<div>
<h3 className="text-sm font-semibold text-foreground">Data generation scripts</h3>
<p className="text-[10px] text-foreground-muted">How the Data Generator builds &amp; writes data into every source system</p>
</div>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={onGenerate} disabled={genBusy} className="inline-flex items-center gap-1 rounded-md border border-emerald-400/40 bg-emerald-500/15 px-2 py-1 text-[11px] font-medium text-emerald-200 hover:bg-emerald-500/25 disabled:opacity-60">
{genBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <Zap className="h-3 w-3" />} Run a burst now
</button>
<button type="button" onClick={onClose} className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground"><X className="h-4 w-4" /></button>
</div>
</div>
{loading ? (
<div className="flex flex-1 items-center justify-center text-xs text-foreground-muted"><Loader2 className="mr-2 h-4 w-4 animate-spin" /> loading scripts</div>
) : err ? (
<div className="flex flex-1 items-center justify-center text-xs text-rose-300">Could not load scripts.</div>
) : (
<div className="flex min-h-0 flex-1">
<div className="w-56 shrink-0 overflow-y-auto border-r border-border p-2">
{scripts.map((s) => (
<button key={s.id} type="button" onClick={() => setActive(s.id)}
className={cn('mb-1 w-full rounded-md border px-2 py-1.5 text-left transition-colors',
active === s.id ? 'border-emerald-400/50 bg-emerald-500/10' : 'border-transparent hover:bg-surface-overlay')}>
<span className="block truncate text-[11px] font-medium text-foreground">{s.title}</span>
<span className="block truncate text-[9px] text-foreground-muted">{s.engine}</span>
<span className="mt-0.5 inline-block font-mono text-[8px] text-foreground-faint">{s.filename} · {s.lines} lines</span>
</button>
))}
</div>
<div className="flex min-w-0 flex-1 flex-col">
{sel && (
<>
<div className="shrink-0 border-b border-border bg-surface-overlay/40 px-3 py-1.5">
<p className="font-mono text-[10px] text-emerald-300">{sel.filename}</p>
<p className="text-[10px] text-foreground-muted">{sel.desc}</p>
</div>
<pre className="min-h-0 flex-1 overflow-auto bg-[#0b1020] p-3 font-mono text-[10px] leading-relaxed text-blue-100/90"><code>{sel.source}</code></pre>
</>
)}
</div>
</div>
)}
</div>
</div>
)
}
@@ -42,6 +42,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const [activeStage, setActiveStage] = useState<number | null>(null) const [activeStage, setActiveStage] = useState<number | null>(null)
const [traceCfg, setTraceCfg] = useState<{ tracing: boolean; project: string; smith_url: string } | null>(null) const [traceCfg, setTraceCfg] = useState<{ tracing: boolean; project: string; smith_url: string } | null>(null)
const [showTraces, setShowTraces] = useState(false) const [showTraces, setShowTraces] = useState(false)
const [showVdb, setShowVdb] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const loadMeta = useCallback(async () => { const loadMeta = useCallback(async () => {
@@ -362,6 +363,14 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
<StatusPill ok={health.llm} label="LLM" /> <StatusPill ok={health.llm} label="LLM" />
</> </>
)} )}
<button
type="button"
onClick={() => setShowVdb(true)}
className="inline-flex items-center gap-1 rounded border border-teal-400/50 bg-teal-500/10 px-2 py-1 text-[10px] font-medium text-teal-300 transition-colors hover:bg-teal-500/20"
title="Inspect the ChromaDB vector store — chunks, splitting & embeddings"
>
<Database className="h-3.5 w-3.5" /> Vector DB
</button>
<button <button
type="button" type="button"
onClick={() => setShowTraces(true)} onClick={() => setShowTraces(true)}
@@ -398,6 +407,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
</header> </header>
<TraceViewer open={showTraces} onClose={() => setShowTraces(false)} smith={traceCfg?.tracing ? traceCfg.smith_url : null} /> <TraceViewer open={showTraces} onClose={() => setShowTraces(false)} smith={traceCfg?.tracing ? traceCfg.smith_url : null} />
{showVdb && <VectorDbExplorer onClose={() => setShowVdb(false)} />}
{showArch && ( {showArch && (
<div className="shrink-0 border-b border-border bg-surface-overlay/20 px-4 py-3"> <div className="shrink-0 border-b border-border bg-surface-overlay/20 px-4 py-3">
<RagFlow loading={loading} ingesting={ingesting} activeStage={activeStage} smithUrl={traceCfg?.tracing ? traceCfg.smith_url : null} project={traceCfg?.project} /> <RagFlow loading={loading} ingesting={ingesting} activeStage={activeStage} smithUrl={traceCfg?.tracing ? traceCfg.smith_url : null} project={traceCfg?.project} />
@@ -930,3 +940,161 @@ function StatusPill({ ok, label }: { ok: boolean; label: string }) {
</span> </span>
) )
} }
type VdbConfig = { store: string; host: string; splitter: string; chunk_size: number; chunk_overlap: number; embed_model: string; embed_dim: number; index: string }
type VdbFile = { id: string; filename: string; chunks: number; characters: number; bytes: number; source: string; ingested_at: string }
type VdbCollection = { name: string; vectors: number; documents: number; characters: number; files: VdbFile[] }
type VdbChunk = { id: string; chunk_index: number | null; metadata: Record<string, unknown>; text: string; chars: number; tokens_est: number; embedding_dim: number | null; embedding_preview: number[] }
function EmbeddingBars({ vals }: { vals: number[] }) {
if (!vals.length) return null
const max = Math.max(0.01, ...vals.map((v) => Math.abs(v)))
return (
<span className="inline-flex h-4 items-center gap-px align-middle">
{vals.map((v, i) => (
<span key={i} className="w-1 rounded-sm" style={{
height: `${Math.max(10, (Math.abs(v) / max) * 100)}%`,
backgroundColor: v >= 0 ? '#2dd4bf' : '#fb7185',
}} />
))}
</span>
)
}
function VectorDbExplorer({ onClose }: { onClose: () => void }) {
const [cfg, setCfg] = useState<VdbConfig | null>(null)
const [cols, setCols] = useState<VdbCollection[]>([])
const [totalVec, setTotalVec] = useState(0)
const [col, setCol] = useState<string | null>(null)
const [docId, setDocId] = useState<string | null>(null)
const [chunks, setChunks] = useState<VdbChunk[]>([])
const [loading, setLoading] = useState(true)
const [loadingChunks, setLoadingChunks] = useState(false)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
fetch('/rag/vectordb').then((r) => r.json()).then((d) => {
if (!d.ok) { setErr(d.error || 'failed to load vector store'); setLoading(false); return }
setCfg(d.config); setCols(d.collections || []); setTotalVec(d.total_vectors || 0)
setCol((d.collections || [])[0]?.name ?? null)
setLoading(false)
}).catch((e) => { setErr(String(e)); setLoading(false) })
}, [])
const loadChunks = useCallback((collection: string, doc: string | null) => {
setLoadingChunks(true)
const q = new URLSearchParams({ collection, limit: '24' })
if (doc) q.set('doc_id', doc)
fetch(`/rag/vectordb/chunks?${q.toString()}`).then((r) => r.json()).then((d) => {
setChunks(d.chunks || []); setLoadingChunks(false)
}).catch(() => setLoadingChunks(false))
}, [])
useEffect(() => { if (col) loadChunks(col, docId) }, [col, docId, loadChunks])
const selCol = cols.find((c) => c.name === col) || null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="flex h-[88vh] w-full max-w-6xl flex-col overflow-hidden rounded-xl border border-border bg-surface shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-teal-500/15 text-teal-300"><Database className="h-4 w-4" /></span>
<div>
<h3 className="text-sm font-semibold text-foreground">Vector store explorer ChromaDB</h3>
<p className="text-[10px] text-foreground-muted">How your documents are split into chunks and written as embeddings</p>
</div>
</div>
<button type="button" onClick={onClose} className="rounded p-1 text-foreground-muted hover:bg-surface-overlay hover:text-foreground"><X className="h-4 w-4" /></button>
</div>
{/* chunking / embedding config strip */}
{cfg && (
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-surface-overlay/30 px-4 py-2 text-[10px]">
<span className="inline-flex items-center gap-1 rounded border border-border bg-surface px-2 py-1 text-foreground-muted"><Scissors className="h-3 w-3 text-docker" /> {cfg.splitter}</span>
<span className="inline-flex items-center gap-1 rounded border border-border bg-surface px-2 py-1 text-foreground-muted"><Layers className="h-3 w-3 text-docker" /> chunk {cfg.chunk_size} · overlap {cfg.chunk_overlap}</span>
<span className="inline-flex items-center gap-1 rounded border border-border bg-surface px-2 py-1 text-foreground-muted"><Cpu className="h-3 w-3 text-docker" /> {cfg.embed_model}</span>
<span className="inline-flex items-center gap-1 rounded border border-border bg-surface px-2 py-1 text-foreground-muted"><Binary className="h-3 w-3 text-docker" /> {cfg.embed_dim}-dim</span>
<span className="inline-flex items-center gap-1 rounded border border-border bg-surface px-2 py-1 text-foreground-muted">{cfg.index}</span>
<span className="ml-auto inline-flex items-center gap-1 rounded border border-teal-400/40 bg-teal-500/10 px-2 py-1 font-mono text-teal-300">{totalVec.toLocaleString()} vectors total</span>
</div>
)}
{loading ? (
<div className="flex flex-1 items-center justify-center text-xs text-foreground-muted"><Loader2 className="mr-2 h-4 w-4 animate-spin" /> loading vector store</div>
) : err ? (
<div className="flex flex-1 items-center justify-center text-xs text-rose-300">{err}</div>
) : (
<div className="flex min-h-0 flex-1">
{/* collections + documents */}
<div className="w-64 shrink-0 overflow-y-auto border-r border-border p-2">
<p className="mb-1 px-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">Collections</p>
{cols.map((c) => (
<button key={c.name} type="button" onClick={() => { setCol(c.name); setDocId(null) }}
className={cn('mb-1 w-full rounded-md border px-2 py-1.5 text-left transition-colors',
col === c.name ? 'border-teal-400/50 bg-teal-500/10' : 'border-transparent hover:bg-surface-overlay')}>
<span className="flex items-center justify-between">
<span className="truncate text-[11px] font-medium text-foreground">{c.name}</span>
<span className="font-mono text-[9px] text-teal-300">{c.vectors.toLocaleString()}</span>
</span>
<span className="text-[9px] text-foreground-muted">{c.documents} docs · {c.vectors.toLocaleString()} chunks</span>
</button>
))}
{selCol && selCol.files.length > 0 && (
<>
<p className="mb-1 mt-3 px-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">Documents</p>
<button type="button" onClick={() => setDocId(null)}
className={cn('mb-1 w-full rounded-md border px-2 py-1 text-left text-[10px] transition-colors',
docId === null ? 'border-teal-400/50 bg-teal-500/10 text-foreground' : 'border-transparent text-foreground-muted hover:bg-surface-overlay')}>
All documents
</button>
{selCol.files.map((f) => (
<button key={f.id} type="button" onClick={() => setDocId(f.id)}
className={cn('mb-1 w-full rounded-md border px-2 py-1 text-left transition-colors',
docId === f.id ? 'border-teal-400/50 bg-teal-500/10' : 'border-transparent hover:bg-surface-overlay')}>
<span className="flex items-center gap-1">
<FileText className="h-3 w-3 shrink-0 text-foreground-muted" />
<span className="truncate text-[10px] text-foreground">{f.filename}</span>
</span>
<span className="text-[9px] text-foreground-muted">{f.chunks} chunks · {(f.characters || 0).toLocaleString()} chars</span>
</button>
))}
</>
)}
</div>
{/* chunk viewer */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border bg-surface-overlay/30 px-3 py-1.5 text-[10px] text-foreground-muted">
Showing chunks for <span className="font-mono text-foreground">{col}</span>
{docId ? <> · doc <span className="font-mono text-foreground">{docId}</span></> : ' · all documents'}
<span className="text-foreground-faint"> each card is one vector row in ChromaDB</span>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
{loadingChunks ? (
<div className="flex items-center justify-center py-10 text-xs text-foreground-muted"><Loader2 className="mr-2 h-4 w-4 animate-spin" /> loading chunks</div>
) : chunks.length === 0 ? (
<p className="py-10 text-center text-xs text-foreground-faint">No chunks in this selection.</p>
) : chunks.map((ch) => (
<div key={ch.id} className="rounded-lg border border-border bg-surface-raised/60 p-2.5">
<div className="mb-1.5 flex flex-wrap items-center gap-2 text-[9px]">
<span className="rounded bg-teal-500/15 px-1.5 py-0.5 font-mono font-semibold text-teal-300">chunk #{ch.chunk_index ?? '—'}</span>
<span className="text-foreground-muted">{ch.chars} chars · ~{ch.tokens_est} tokens</span>
{typeof ch.metadata?.source === 'string' && <span className="truncate text-foreground-faint">{ch.metadata.source as string}</span>}
<span className="ml-auto inline-flex items-center gap-1 text-foreground-muted">
<EmbeddingBars vals={ch.embedding_preview} />
<span className="font-mono text-teal-300">{ch.embedding_dim}-dim</span>
</span>
</div>
<p className="max-h-28 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[10px] leading-relaxed text-foreground/90">{ch.text}</p>
<p className="mt-1 truncate font-mono text-[8px] text-foreground-faint">vector id: {ch.id} · embedding [{ch.embedding_preview.slice(0, 6).map((v) => v.toFixed(3)).join(', ')}, ]</p>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
)
}