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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 & 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user