1import { useState, type FormEvent } from "react";2import { Link } from "react-router";3import { api, ApiError, type Dataset, type Project, type Recipe } from "../api";4import { useFetch, fmtDate, short } from "../hooks";5import { Badge, Empty, ErrorBox, JsonTree, Loading, Td } from "../components/ui";67export default function Lineage() {8 const { data, error, loading, reload } = useFetch<{ datasets: Dataset[] }>("datasets");9 const recipes = useFetch<{ recipes: Recipe[] }>("recipes");10 const projects = useFetch<{ projects: Project[] }>("projects");11 const [tab, setTab] = useState<"none" | "dataset" | "recipe">("none");12 const [ds, setDs] = useState({ project_id: "default", name: "", execution_ids: "", records_path: "", identifiers: "", units: "", granularity: "" });13 const [rc, setRc] = useState({ project_id: "default", name: "", left: "", right: "", on: "", how: "inner", derive: "", aggregate: "", allow_m2m: false, output_name: "" });14 const [err, setErr] = useState<ApiError | null>(null); const [busy, setBusy] = useState(false); const [result, setResult] = useState<Record<string, unknown> | null>(null);15 const parseKV = (s: string) => Object.fromEntries(s.split(",").map((x) => x.trim()).filter(Boolean).map((x) => { const [k, v] = x.split("=", 2); return [k.trim(), (v ?? "").trim()]; }));16 const createDataset = async (e: FormEvent) => {17 e.preventDefault(); setBusy(true); setErr(null); setResult(null);18 try { const r = await api<Record<string, unknown>>("datasets", { body: { project_id: ds.project_id, name: ds.name, execution_ids: ds.execution_ids.split(/[\s,]+/).filter(Boolean), records_path: ds.records_path || null, identifiers: ds.identifiers.split(",").map((s) => s.trim()).filter(Boolean), units: parseKV(ds.units), granularity: ds.granularity || null } }); setResult({ created: r.id, verification: r.verification, rows: r.row_count }); reload(); }19 catch (ex) { setErr(ex as ApiError); } finally { setBusy(false); }20 };21 const compose = async (e: FormEvent) => {22 e.preventDefault(); setBusy(true); setErr(null); setResult(null);23 try {24 const on = rc.on.split(",").map((x) => x.trim()).filter(Boolean).map((x) => { const [l, r] = x.split("=", 2); return { left: l.trim(), right: (r ?? l).trim() }; });25 const derive = rc.derive.split("\n").map((x) => x.trim()).filter(Boolean).map((x) => { const [name, expr] = x.split("=", 2); const m = /^(.*)\s+\[(.+)\]$/.exec(expr?.trim() ?? ""); return { name: name.trim(), expr: (m ? m[1] : expr ?? "").trim(), unit: m ? m[2] : undefined }; });26 let aggregate: unknown = null;27 if (rc.aggregate.trim()) aggregate = JSON.parse(rc.aggregate);28 const r = await api<Record<string, unknown>>("recipes/compose", { body: { project_id: rc.project_id, name: rc.name, left_dataset_id: rc.left, right_dataset_id: rc.right, on, how: rc.how, derive, aggregate, allow_many_to_many: rc.allow_m2m, output_name: rc.output_name || null } });29 setResult(r); recipes.reload();30 } catch (ex) { setErr(ex instanceof ApiError ? ex : new ApiError(0, "input", String(ex))); } finally { setBusy(false); }31 };32 const runRecipe = async (id: string) => { setErr(null); setBusy(true); try { setResult(await api<Record<string, unknown>>(`recipes/${id}/run`, { method: "POST" })); reload(); recipes.reload(); } catch (ex) { setErr(ex as ApiError); } finally { setBusy(false); } };33 const projs = projects.data?.projects ?? [];34 return (35 <div>36 <div className="row between"><h1>Research Lineage</h1><div className="row"><button className="btn" onClick={() => setTab(tab === "dataset" ? "none" : "dataset")}>Create dataset</button><button className="btn" onClick={() => setTab(tab === "recipe" ? "none" : "recipe")}>Compose recipe</button></div></div>37 <p className="hint">Datasets are materialised from succeeded executions with retained snapshots; recipes join two datasets after validating keys, cardinality, granularity and units. Verification is computed from evidence, never set by hand.</p>38 <ErrorBox error={err} />39 {result && <div className="card"><h3>Result</h3><JsonTree data={result} open />{typeof result.id === "string" && typeof result.status === "string" && result.status === "validated" && <button className="btn primary" style={{ marginTop: 8 }} disabled={busy} onClick={() => runRecipe(result.id as string)}>Run recipe → create joined dataset</button>}{typeof result.created === "string" && <p><Link to={`/datasets/${result.created}`}>open dataset →</Link></p>}</div>}40 {tab === "dataset" && (41 <form className="card" onSubmit={createDataset}><h3>Create dataset from executions</h3>42 <div className="form-grid">43 <div className="field"><label htmlFor="ds-proj">Project</label><select id="ds-proj" value={ds.project_id} onChange={(e) => setDs({ ...ds, project_id: e.target.value })}>{projs.map((p) => <option key={p.id} value={p.slug}>{p.name}</option>)}</select></div>44 <div className="field"><label htmlFor="ds-name">Name</label><input id="ds-name" required value={ds.name} onChange={(e) => setDs({ ...ds, name: e.target.value })} /></div>45 <div className="field span2"><label htmlFor="ds-ex">Execution ids (comma/space separated, same project, state succeeded)</label><textarea id="ds-ex" required value={ds.execution_ids} onChange={(e) => setDs({ ...ds, execution_ids: e.target.value })} /></div>46 <div className="field"><label htmlFor="ds-rp">Records path (e.g. $[1], $.data; default from AKP recipe)</label><input id="ds-rp" value={ds.records_path} onChange={(e) => setDs({ ...ds, records_path: e.target.value })} /></div>47 <div className="field"><label htmlFor="ds-id">Identifiers (comma separated columns)</label><input id="ds-id" value={ds.identifiers} onChange={(e) => setDs({ ...ds, identifiers: e.target.value })} /></div>48 <div className="field"><label htmlFor="ds-units">Units (col=unit, comma separated)</label><input id="ds-units" value={ds.units} onChange={(e) => setDs({ ...ds, units: e.target.value })} placeholder="value=USD, rate=CAD/USD" /></div>49 <div className="field"><label htmlFor="ds-gran">Granularity</label><input id="ds-gran" value={ds.granularity} onChange={(e) => setDs({ ...ds, granularity: e.target.value })} placeholder="country-year" /></div>50 </div><button className="btn primary" disabled={busy}>{busy ? "Creating…" : "Create dataset"}</button></form>)}51 {tab === "recipe" && (52 <form className="card" onSubmit={compose}><h3>Compose a cross-source recipe</h3>53 <div className="form-grid">54 <div className="field"><label htmlFor="rc-proj">Project</label><select id="rc-proj" value={rc.project_id} onChange={(e) => setRc({ ...rc, project_id: e.target.value })}>{projs.map((p) => <option key={p.id} value={p.slug}>{p.name}</option>)}</select></div>55 <div className="field"><label htmlFor="rc-name">Recipe name</label><input id="rc-name" required value={rc.name} onChange={(e) => setRc({ ...rc, name: e.target.value })} /></div>56 <div className="field"><label htmlFor="rc-left">Left dataset</label><select id="rc-left" required value={rc.left} onChange={(e) => setRc({ ...rc, left: e.target.value })}><option value="">—</option>{data?.datasets.map((d) => <option key={d.id} value={d.id}>{d.name} ({d.granularity ?? "?"}, {d.row_count} rows)</option>)}</select></div>57 <div className="field"><label htmlFor="rc-right">Right dataset</label><select id="rc-right" required value={rc.right} onChange={(e) => setRc({ ...rc, right: e.target.value })}><option value="">—</option>{data?.datasets.map((d) => <option key={d.id} value={d.id}>{d.name} ({d.granularity ?? "?"}, {d.row_count} rows)</option>)}</select></div>58 <div className="field"><label htmlFor="rc-on">Join keys (left=right, comma separated)</label><input id="rc-on" required value={rc.on} onChange={(e) => setRc({ ...rc, on: e.target.value })} placeholder="date=year, country=iso3" /></div>59 <div className="field"><label htmlFor="rc-how">How</label><select id="rc-how" value={rc.how} onChange={(e) => setRc({ ...rc, how: e.target.value })}><option>inner</option><option>left</option></select></div>60 <div className="field span2"><label htmlFor="rc-der">Derived columns (one per line: name = left.col * right.col [unit])</label><textarea id="rc-der" value={rc.derive} onChange={(e) => setRc({ ...rc, derive: e.target.value })} /></div>61 <div className="field span2"><label htmlFor="rc-agg">Aggregate step (JSON, optional; needed when granularities differ) e.g. {`{"side":"right","by":["year"],"agg":{"rate":"mean"}}`}</label><textarea id="rc-agg" value={rc.aggregate} onChange={(e) => setRc({ ...rc, aggregate: e.target.value })} /></div>62 <div className="field"><label htmlFor="rc-out">Output dataset name</label><input id="rc-out" value={rc.output_name} onChange={(e) => setRc({ ...rc, output_name: e.target.value })} /></div>63 <div className="field"><label><input type="checkbox" style={{ width: "auto", minHeight: 0 }} checked={rc.allow_m2m} onChange={(e) => setRc({ ...rc, allow_m2m: e.target.checked })} /> allow many-to-many</label></div>64 </div><button className="btn primary" disabled={busy}>{busy ? "Validating…" : "Validate recipe"}</button></form>)}65 <div className="card"><h3>Datasets</h3>{loading && <Loading />}<ErrorBox error={error} />66 {data && data.datasets.length === 0 && <Empty>No datasets. Run executions, then “Create dataset”.</Empty>}67 {data && data.datasets.length > 0 && <table className="tbl"><thead><tr><th>Name</th><th>Project</th><th>Verification</th><th>Rows</th><th>Granularity</th><th>Sources</th><th>Hash</th><th>Created</th></tr></thead><tbody>68 {data.datasets.map((d) => <tr key={d.id}><Td l="name"><Link to={`/datasets/${d.id}`}><b>{d.name}</b></Link></Td><Td l="project">{d.project_slug}</Td><Td l="verification"><Badge v={d.verification} /></Td><Td l="rows" className="num">{d.row_count ?? "—"}</Td><Td l="granularity">{d.granularity ?? "—"}</Td><Td l="sources">{d.executions ?? 0} exec · {d.transformations ?? 0} transf.</Td><Td l="hash"><code>{short(d.hash, 12)}</code></Td><Td l="created">{fmtDate(d.created_at)}</Td></tr>)}69 </tbody></table>}</div>70 <div className="card"><h3>Recipes</h3>{recipes.loading && <Loading />}<ErrorBox error={recipes.error} />71 {recipes.data && recipes.data.recipes.length === 0 && <Empty>No recipes composed.</Empty>}72 {recipes.data && recipes.data.recipes.length > 0 && <table className="tbl"><thead><tr><th>Name</th><th>Status</th><th>Validation</th><th>Output</th><th>Created</th><th></th></tr></thead><tbody>73 {recipes.data.recipes.map((r) => { const v = r.validation as { ok?: boolean; cardinality?: string; matched_keys?: number; unmatched_left?: number; unmatched_right?: number; errors?: string[] }; return <tr key={r.id}><Td l="name"><b>{r.name}</b> v{r.version}</Td><Td l="status"><Badge v={r.status} /></Td><Td l="validation" wide>{v.ok ? `${v.cardinality} · matched ${v.matched_keys} · unmatched L/R ${v.unmatched_left}/${v.unmatched_right}` : <span style={{ color: "var(--critical)" }}>{(v.errors ?? []).join("; ")}</span>}</Td><Td l="output">{r.output_dataset_id ? <Link to={`/datasets/${r.output_dataset_id}`}>dataset</Link> : "—"}</Td><Td l="created">{fmtDate(r.created_at)}</Td><Td l="">{r.status === "validated" && !r.output_dataset_id && <button className="btn sm" disabled={busy} onClick={() => runRecipe(r.id)}>Run</button>}</Td></tr>; })}74 </tbody></table>}</div>75 </div>76 );77}78