import { useState, type FormEvent } from "react"; import { Link } from "react-router"; import { api, ApiError, type Dataset, type Project, type Recipe } from "../api"; import { useFetch, fmtDate, short } from "../hooks"; import { Badge, Empty, ErrorBox, JsonTree, Loading, Td } from "../components/ui"; export default function Lineage() { const { data, error, loading, reload } = useFetch<{ datasets: Dataset[] }>("datasets"); const recipes = useFetch<{ recipes: Recipe[] }>("recipes"); const projects = useFetch<{ projects: Project[] }>("projects"); const [tab, setTab] = useState<"none" | "dataset" | "recipe">("none"); const [ds, setDs] = useState({ project_id: "default", name: "", execution_ids: "", records_path: "", identifiers: "", units: "", granularity: "" }); const [rc, setRc] = useState({ project_id: "default", name: "", left: "", right: "", on: "", how: "inner", derive: "", aggregate: "", allow_m2m: false, output_name: "" }); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const [result, setResult] = useState | null>(null); 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()]; })); const createDataset = async (e: FormEvent) => { e.preventDefault(); setBusy(true); setErr(null); setResult(null); try { const r = await api>("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(); } catch (ex) { setErr(ex as ApiError); } finally { setBusy(false); } }; const compose = async (e: FormEvent) => { e.preventDefault(); setBusy(true); setErr(null); setResult(null); try { 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() }; }); 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 }; }); let aggregate: unknown = null; if (rc.aggregate.trim()) aggregate = JSON.parse(rc.aggregate); const r = await api>("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 } }); setResult(r); recipes.reload(); } catch (ex) { setErr(ex instanceof ApiError ? ex : new ApiError(0, "input", String(ex))); } finally { setBusy(false); } }; const runRecipe = async (id: string) => { setErr(null); setBusy(true); try { setResult(await api>(`recipes/${id}/run`, { method: "POST" })); reload(); recipes.reload(); } catch (ex) { setErr(ex as ApiError); } finally { setBusy(false); } }; const projs = projects.data?.projects ?? []; return (

Research Lineage

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.

{result &&

Result

{typeof result.id === "string" && typeof result.status === "string" && result.status === "validated" && }{typeof result.created === "string" &&

open dataset →

}
} {tab === "dataset" && (

Create dataset from executions

setDs({ ...ds, name: e.target.value })} />