= {
@@ -115,9 +116,12 @@ export default function RunDetailPage({ params }: { params: Promise<{ id: string
);
}
- const isGlanceTrace = run.source === "glance_db3";
+ const isGlanceTrace = run.source === "glance" || run.source === "glance_db3";
const badges: { label: string; className: string }[] = [];
- if (isGlanceTrace) badges.push({ label: "Imported GLANCE Trace", className: "text-violet-500 bg-violet-500/10" });
+ if (isGlanceTrace) badges.push({
+ label: run.source === "glance" ? "Live GLANCE Trace" : "Imported GLANCE Trace",
+ className: "text-violet-500 bg-violet-500/10",
+ });
if (run.is_calibration) badges.push({ label: "Calibration", className: "text-blue-500 bg-blue-500/10" });
if (run.is_outlier) badges.push({ label: "Outlier", className: "text-amber-500 bg-amber-500/10" });
if (!isGlanceTrace && badges.length === 0) badges.push({ label: "Clean Validated", className: "text-emerald-500 bg-emerald-500/10" });
@@ -132,6 +136,8 @@ export default function RunDetailPage({ params }: { params: Promise<{ id: string
run.outputs && Object.keys(run.outputs).length > 0 ? run.outputs : null;
const isEtcher = run.equipment_id === "etcher" || run.avg_etch_rate !== null;
const glanceProvenance = run.raw_payload?.glance;
+ const liveRecipe = glanceProvenance?.recipe;
+ const liveRecipeId = liveRecipe?.source_recipe_id;
const sourceFile = run.raw_payload?.source_file;
return (
@@ -196,6 +202,28 @@ export default function RunDetailPage({ params }: { params: Promise<{ id: string
)}
+ {run.source === "glance" &&
+ run.equipment_id &&
+ liveRecipeId !== null &&
+ liveRecipeId !== undefined && (
+
+
+
+ Recipe {String(liveRecipeId)} · {liveRecipe?.name || "Unnamed recipe"}
+
+
+ View this equipment-specific recipe version using its GLANCE ID, hash, and timestamp.
+
+
+
+
Open recipe viewer
+
+
+ )}
+
{/* Project & ownership */}
diff --git a/web/app/data/catalog/page.tsx b/web/app/data/catalog/page.tsx
index e07b024..6b02457 100644
--- a/web/app/data/catalog/page.tsx
+++ b/web/app/data/catalog/page.tsx
@@ -14,7 +14,8 @@ import {
Loader2,
} from "lucide-react";
-const isGlanceTrace = (run: V2Run) => run.source === "glance_db3";
+const isGlanceTrace = (run: V2Run) =>
+ run.source === "glance" || run.source === "glance_db3";
const isCleanValidated = (run: V2Run) =>
!isGlanceTrace(run) && !run.is_outlier && !run.is_calibration;
@@ -188,7 +189,10 @@ export default function DataCatalogPage() {
const glanceTrace = isGlanceTrace(r);
const badges: { label: string; className: string }[] = [];
if (glanceTrace) {
- badges.push({ label: "Imported GLANCE Trace", className: "text-violet-500 bg-violet-500/10" });
+ badges.push({
+ label: r.source === "glance" ? "Live GLANCE Trace" : "Imported GLANCE Trace",
+ className: "text-violet-500 bg-violet-500/10",
+ });
}
if (r.is_calibration) {
badges.push({ label: "Calibration", className: "text-blue-500 bg-blue-500/10" });
diff --git a/web/app/data/recipes/page.tsx b/web/app/data/recipes/page.tsx
new file mode 100644
index 0000000..3a4c830
--- /dev/null
+++ b/web/app/data/recipes/page.tsx
@@ -0,0 +1,17 @@
+import { Suspense } from "react";
+
+import { RecipeViewer } from "@/components/recipe-viewer";
+
+export default function RecipeViewerPage() {
+ return (
+
+ Loading recipe viewer…
+
+ }
+ >
+
+
+ );
+}
diff --git a/web/components/cross-run-trace-analysis.tsx b/web/components/cross-run-trace-analysis.tsx
index b8b7897..b9784ad 100644
--- a/web/components/cross-run-trace-analysis.tsx
+++ b/web/components/cross-run-trace-analysis.tsx
@@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
-import { Activity, AlertCircle, Loader2 } from "lucide-react";
+import { Activity, AlertCircle, Download, Loader2 } from "lucide-react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import {
@@ -17,6 +17,13 @@ import {
formatTrendMetric,
trimRepeatedEdgeZeros,
} from "@/lib/trace-plot";
+import {
+ buildTraceCsv,
+ downloadCsv,
+ safeDownloadStem,
+ traceAxisLabel,
+ traceParameterUnit,
+} from "@/lib/trace-export";
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
const MAX_SELECTED_RUNS = 10;
@@ -278,6 +285,18 @@ export function CrossRunTraceAnalysis() {
if (next.length === 0) setWarning(null);
}
+ function exportRawTraceCsv() {
+ if (!selected || visibleTraces.length === 0) return;
+ const csv = buildTraceCsv(visibleTraces, selected);
+ const runSuffix = `${visibleTraces.length}-run${
+ visibleTraces.length === 1 ? "" : "s"
+ }`;
+ downloadCsv(
+ csv,
+ `${safeDownloadStem(`trace-${selected.key}-${runSuffix}`)}.csv`,
+ );
+ }
+
return (
@@ -374,6 +393,15 @@ export function CrossRunTraceAnalysis() {
/>
Apply trendline
+
+ Export raw CSV
+
{selectedIds.length > 0 && warning && (
@@ -398,6 +426,25 @@ export function CrossRunTraceAnalysis() {
)}
+ {selected && visibleTraces.length > 0 && (
+
+
+ Y-axis: {traceAxisLabel(selected)}
+
+
+ X-axis: {" "}
+ {axisMode === "relative"
+ ? "Seconds from each run's first sample"
+ : "GLANCE source timestamp"}
+
+ {!traceParameterUnit(selected) && (
+
+ GLANCE did not provide a unit for this parameter.
+
+ )}
+
+ )}
+
{selected && visibleTraces.length ? (
diff --git a/web/components/recipe-viewer.tsx b/web/components/recipe-viewer.tsx
new file mode 100644
index 0000000..085e61d
--- /dev/null
+++ b/web/components/recipe-viewer.tsx
@@ -0,0 +1,416 @@
+"use client";
+
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+import {
+ AlertCircle,
+ BookOpen,
+ Download,
+ ExternalLink,
+ FileCode2,
+ Loader2,
+ Search,
+} from "lucide-react";
+
+import {
+ getV2EquipmentRecipe,
+ getV2EquipmentRecipeExportUrl,
+ getV2EquipmentRecipes,
+ getV2RecipeEquipmentOptions,
+ type V2EquipmentRecipeDetail,
+ type V2EquipmentRecipeSummary,
+ type V2RecipeEquipmentOption,
+} from "@/lib/api-client";
+import { cn } from "@/lib/utils";
+
+function sourceTimestamp(value: string | null | undefined): string {
+ if (!value) return "Not recorded";
+ const normalized = value.replace("T", " ");
+ return /(?:z|[+-]\d{2}:?\d{2})$/i.test(value)
+ ? new Date(value).toLocaleString()
+ : `${normalized} (source time)`;
+}
+
+function fileSize(value: number | null | undefined): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) return "Unknown";
+ if (value < 1_024) return `${value.toLocaleString()} bytes`;
+ return `${(value / 1_024).toFixed(1)} KiB`;
+}
+
+function shortHash(value: string | null | undefined): string {
+ if (!value) return "No source hash";
+ return value.length > 18 ? `${value.slice(0, 10)}…${value.slice(-6)}` : value;
+}
+
+export function RecipeViewer() {
+ const searchParams = useSearchParams();
+ const requestedEquipment = searchParams.get("equipment")?.trim() ?? "";
+ const requestedRecipe = Number(searchParams.get("recipe"));
+ const [equipment, setEquipment] = useState([]);
+ const [selectedEquipment, setSelectedEquipment] = useState("");
+ const [recipes, setRecipes] = useState([]);
+ const [selectedRecipeId, setSelectedRecipeId] = useState(null);
+ const [detail, setDetail] = useState(null);
+ const [search, setSearch] = useState("");
+ const [loadingEquipment, setLoadingEquipment] = useState(true);
+ const [loadingRecipes, setLoadingRecipes] = useState(false);
+ const [loadingDetail, setLoadingDetail] = useState(false);
+ const [downloading, setDownloading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void getV2RecipeEquipmentOptions().then((records) => {
+ if (cancelled) return;
+ const available = records ?? [];
+ setEquipment(available);
+ const requestedIsVisible = available.some(
+ (item) => item.equipment_id === requestedEquipment,
+ );
+ setSelectedEquipment(
+ requestedIsVisible ? requestedEquipment : available[0]?.equipment_id ?? "",
+ );
+ setLoadingEquipment(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [requestedEquipment]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setRecipes([]);
+ setDetail(null);
+ setSelectedRecipeId(null);
+ setError(null);
+ if (!selectedEquipment) return;
+ setLoadingRecipes(true);
+ void getV2EquipmentRecipes(selectedEquipment).then((records) => {
+ if (cancelled) return;
+ const available = records ?? [];
+ setRecipes(available);
+ const requestedIsVisible =
+ Number.isInteger(requestedRecipe) &&
+ requestedRecipe > 0 &&
+ available.some((item) => item.source_recipe_id === requestedRecipe);
+ setSelectedRecipeId(
+ requestedIsVisible
+ ? requestedRecipe
+ : available[0]?.source_recipe_id ?? null,
+ );
+ setLoadingRecipes(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [requestedRecipe, selectedEquipment]);
+
+ useEffect(() => {
+ let cancelled = false;
+ setDetail(null);
+ setError(null);
+ if (!selectedEquipment || selectedRecipeId === null) return;
+ setLoadingDetail(true);
+ void getV2EquipmentRecipe(selectedEquipment, selectedRecipeId).then(
+ (record) => {
+ if (cancelled) return;
+ setDetail(record);
+ if (!record) {
+ setError(
+ "This recipe could not be decoded or is no longer visible to your account.",
+ );
+ }
+ setLoadingDetail(false);
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [selectedEquipment, selectedRecipeId]);
+
+ const filteredRecipes = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ if (!query) return recipes;
+ return recipes.filter((recipe) =>
+ [
+ recipe.recipe_name,
+ recipe.source_recipe_id,
+ recipe.source_hash,
+ recipe.recipe_timestamp,
+ ].some((value) => String(value ?? "").toLowerCase().includes(query)),
+ );
+ }, [recipes, search]);
+
+ async function exportRecipeCsv() {
+ if (!detail || downloading) return;
+ setDownloading(true);
+ setError(null);
+ try {
+ const response = await fetch(
+ getV2EquipmentRecipeExportUrl(
+ detail.equipment_id,
+ detail.source_recipe_id,
+ ),
+ { cache: "no-store" },
+ );
+ if (!response.ok) {
+ throw new Error(`Recipe export failed with status ${response.status}`);
+ }
+ const blob = await response.blob();
+ const contentDisposition = response.headers.get("content-disposition") ?? "";
+ const filename =
+ contentDisposition.match(/filename=([^;]+)/i)?.[1]?.replaceAll('"', "") ??
+ `glance_recipe_${detail.source_recipe_id}.csv`;
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+ } catch (reason) {
+ setError(
+ reason instanceof Error ? reason.message : "Recipe export failed.",
+ );
+ } finally {
+ setDownloading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
GLANCE Recipe Viewer
+
+
+ Inspect versioned recipes for one equipment item at a time. Recipe IDs,
+ source hashes, and timestamps remain visible so identical names are never
+ treated as the same version.
+
+
+
+
+
+ Equipment
+ setSelectedEquipment(event.target.value)}
+ disabled={loadingEquipment || equipment.length === 0}
+ className="mt-1.5 w-full rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] px-3 py-2 text-sm text-[hsl(var(--foreground))] disabled:opacity-60"
+ >
+ {equipment.map((item) => (
+
+ {item.equipment_name} · {item.recipe_count.toLocaleString()} recipes
+
+ ))}
+
+
+
+ Only recipes linked to projects you can already access are listed.
+
+
+
+ {loadingEquipment ? (
+
+ Loading recipe equipment…
+
+ ) : equipment.length === 0 ? (
+
+
+
No visible GLANCE recipes
+
+ Ask a project PI to grant access to a project containing live GLANCE
+ runs.
+
+
+ ) : (
+
+
+
+
+ setSearch(event.target.value)}
+ placeholder="Search name, ID, hash, or date"
+ className="w-full rounded-md border border-[hsl(var(--border))] bg-[hsl(var(--background))] py-2 pl-9 pr-3 text-sm"
+ />
+
+ {loadingRecipes ? (
+
+ Loading recipes…
+
+ ) : (
+
+ {filteredRecipes.map((recipe) => (
+
setSelectedRecipeId(recipe.source_recipe_id)}
+ className={cn(
+ "w-full rounded-md border px-3 py-3 text-left transition-colors",
+ selectedRecipeId === recipe.source_recipe_id
+ ? "border-[hsl(var(--primary))] bg-[hsl(var(--primary))/0.08]"
+ : "border-transparent hover:border-[hsl(var(--border))] hover:bg-[hsl(var(--accent))]",
+ )}
+ >
+
+ {recipe.recipe_name || `Recipe ${recipe.source_recipe_id}`}
+
+
+ ID {recipe.source_recipe_id} · {shortHash(recipe.source_hash)}
+
+
+ {sourceTimestamp(recipe.recipe_timestamp)} · used by{" "}
+ {recipe.run_count.toLocaleString()} visible run
+ {recipe.run_count === 1 ? "" : "s"}
+
+
+ ))}
+ {filteredRecipes.length === 0 && (
+
+ No recipe matches this search.
+
+ )}
+
+ )}
+
+
+
+ {loadingDetail ? (
+
+ Safely decoding recipe…
+
+ ) : error && !detail ? (
+
+ ) : detail ? (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ Recipe {detail.source_recipe_id}
+
+
+ {detail.name || `Recipe ${detail.source_recipe_id}`}
+
+
+ {detail.steps.length.toLocaleString()} steps ·{" "}
+ {detail.rows.length.toLocaleString()} parameters ·{" "}
+ {fileSize(detail.decoded_size_bytes)} decoded
+
+
+
void exportRecipeCsv()}
+ disabled={downloading}
+ className="inline-flex items-center gap-2 rounded-md bg-[hsl(var(--primary))] px-3 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-60"
+ >
+ {downloading ? (
+
+ ) : (
+
+ )}
+ Export recipe CSV
+
+
+
+
+
+
Version timestamp
+
+ {sourceTimestamp(detail.timestamp)}
+
+
+
+
GLANCE source hash
+ {detail.source_hash || "—"}
+
+
+
Decoded SHA-256
+ {detail.file_sha256 || "—"}
+
+
+
Latest visible run
+
+
+ GLANCE {detail.latest_source_run_id ?? "—"}
+
+
+
+
+
+
+ {detail.warnings.length > 0 && (
+
+ {detail.warnings.map((warning) => (
+
{warning}
+ ))}
+
+ )}
+
+
+
+
+
+
+ Parameter
+
+ {detail.steps.map((step) => (
+
+
+ Step {step.index}
+
+ {step.name || "Unnamed"}
+
+ ))}
+
+
+
+ {detail.rows.map((row) => (
+
+
+ {row.parameter}
+
+ {row.values.map((value, index) => (
+
+ {value === " " ? — : value}
+
+ ))}
+
+ ))}
+
+
+
+
+ ) : (
+
+ Select a recipe to inspect it.
+
+ )}
+
+
+ )}
+
+ );
+}
diff --git a/web/components/run-trace-explorer.tsx b/web/components/run-trace-explorer.tsx
index 70f42af..423e8be 100644
--- a/web/components/run-trace-explorer.tsx
+++ b/web/components/run-trace-explorer.tsx
@@ -2,7 +2,13 @@
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
-import { Activity, AlertCircle, Clock3, Loader2 } from "lucide-react";
+import {
+ Activity,
+ AlertCircle,
+ Clock3,
+ Download,
+ Loader2,
+} from "lucide-react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import {
@@ -17,6 +23,13 @@ import {
formatTrendMetric,
trimRepeatedEdgeZeros,
} from "@/lib/trace-plot";
+import {
+ buildTraceCsv,
+ downloadCsv,
+ safeDownloadStem,
+ traceAxisLabel,
+ traceParameterUnit,
+} from "@/lib/trace-export";
import { cn } from "@/lib/utils";
const Plot = dynamic(() => import("react-plotly.js"), { ssr: false });
@@ -422,6 +435,18 @@ export function RunTraceExplorer({ run }: RunTraceExplorerProps) {
});
}
+ function exportRawTraceCsv() {
+ if (!parameter || displayedTraces.length === 0) return;
+ const csv = buildTraceCsv(displayedTraces, parameter);
+ const runSuffix = `${displayedTraces.length}-run${
+ displayedTraces.length === 1 ? "" : "s"
+ }`;
+ downloadCsv(
+ csv,
+ `${safeDownloadStem(`trace-${parameter.key}-${runSuffix}`)}.csv`,
+ );
+ }
+
if (initialLoading) {
return (
@@ -535,6 +560,15 @@ export function RunTraceExplorer({ run }: RunTraceExplorerProps) {
/>
Apply trendline
+
+ Export raw CSV
+
Plot-only; source and stored values remain unchanged.
@@ -634,6 +668,24 @@ export function RunTraceExplorer({ run }: RunTraceExplorerProps) {
: ""}
)}
+ {parameter && (
+
+
+ Y-axis: {traceAxisLabel(parameter)}
+
+
+ X-axis: {" "}
+ {axisMode === "relative"
+ ? "Seconds from each run's first sample"
+ : "GLANCE source timestamp"}
+
+ {!traceParameterUnit(parameter) && (
+
+ GLANCE did not provide a unit for this parameter.
+
+ )}
+
+ )}
{parameter ? (
@@ -643,7 +695,7 @@ export function RunTraceExplorer({ run }: RunTraceExplorerProps) {
layout={{
autosize: true,
height: 480,
- margin: { l: 70, r: 25, t: 115, b: 105 },
+ margin: { l: 95, r: 25, t: 115, b: 120 },
paper_bgcolor: "rgba(0,0,0,0)",
plot_bgcolor: "rgba(0,0,0,0)",
font: { color: "#94a3b8", family: "Inter, system-ui, sans-serif" },
@@ -655,17 +707,21 @@ export function RunTraceExplorer({ run }: RunTraceExplorerProps) {
axisMode === "relative"
? "Seconds from first sample in each run"
: "GLANCE source timestamp",
+ standoff: 18,
+ font: { color: "#cbd5e1", size: 13 },
},
+ automargin: true,
type: axisMode === "relative" ? "linear" : "date",
gridcolor: "rgba(148,163,184,0.12)",
zeroline: false,
},
yaxis: {
title: {
- text: parameter.unit
- ? `${parameter.name} (${parameter.unit})`
- : parameter.name,
+ text: traceAxisLabel(parameter),
+ standoff: 18,
+ font: { color: "#cbd5e1", size: 13 },
},
+ automargin: true,
gridcolor: "rgba(148,163,184,0.12)",
zeroline: false,
},
@@ -706,6 +762,11 @@ export function RunTraceExplorer({ run }: RunTraceExplorerProps) {
displayModeBar: true,
displaylogo: false,
scrollZoom: true,
+ toImageButtonOptions: {
+ format: "png",
+ filename: safeDownloadStem(`trace-${parameter.key}`),
+ scale: 2,
+ },
}}
useResizeHandler
style={{ width: "100%" }}
diff --git a/web/components/sidebar.tsx b/web/components/sidebar.tsx
index 4ac1973..6c03127 100644
--- a/web/components/sidebar.tsx
+++ b/web/components/sidebar.tsx
@@ -30,6 +30,7 @@ import {
Globe,
GitBranch,
Sparkles,
+ BookOpen,
} from "lucide-react";
import { useTheme } from "next-themes";
import { useState } from "react";
@@ -58,6 +59,7 @@ const NAV_SECTIONS = [
{ id: "ingestion", label: "Ingestion", href: "/data/monitor", icon: Activity },
{ id: "catalog", label: "Catalog", href: "/data/catalog", icon: Search },
{ id: "analysis", label: "Trace Analysis", href: "/data/analysis", icon: Activity },
+ { id: "recipes", label: "Recipe Viewer", href: "/data/recipes", icon: BookOpen },
],
},
{
diff --git a/web/lib/api-client.ts b/web/lib/api-client.ts
index 9e2d943..f2fb2a3 100644
--- a/web/lib/api-client.ts
+++ b/web/lib/api-client.ts
@@ -188,6 +188,26 @@ export interface V2Run {
run_status?: string | null;
sample_count?: number | null;
run_id?: number | string | null;
+ run?: {
+ idruns?: number | string | null;
+ idtools?: number | string | null;
+ idrecipes?: number | string | null;
+ lotname?: string | null;
+ materialname?: string | null;
+ starttime?: string | null;
+ endtime?: string | null;
+ status?: string | null;
+ [key: string]: unknown;
+ };
+ recipe?: {
+ source_recipe_id?: number | string | null;
+ name?: string | null;
+ source_hash?: string | null;
+ timestamp?: string | null;
+ file_sha256?: string | null;
+ file_size_bytes?: number | null;
+ [key: string]: unknown;
+ };
};
source_file?: {
type?: string | null;
@@ -563,6 +583,91 @@ export function getV2RunTrace(
);
}
+export interface V2RecipeEquipmentOption {
+ equipment_id: string;
+ equipment_name: string;
+ recipe_count: number;
+}
+
+export interface V2EquipmentRecipeSummary {
+ equipment_id: string;
+ source_tool_id: number | string | null;
+ source_recipe_id: number;
+ recipe_name: string | null;
+ source_hash: string | null;
+ recipe_timestamp: string | null;
+ file_sha256: string | null;
+ file_size_bytes: number | null;
+ run_count: number;
+ latest_run_at: string | null;
+ latest_source_run_id: number | null;
+ latest_catalog_run_id: number;
+ latest_project_id: string | null;
+}
+
+export interface V2DecodedRecipeStep {
+ index: number;
+ source_number: number;
+ source_tag: string;
+ name: string;
+ label: string;
+}
+
+export interface V2DecodedRecipeRow {
+ parameter: string;
+ values: string[];
+}
+
+export interface V2EquipmentRecipeDetail {
+ equipment_id: string;
+ source_tool_id: number | string | null;
+ source_recipe_id: number;
+ name: string;
+ source_hash: string | null;
+ timestamp: string | null;
+ file_sha256: string | null;
+ file_size_bytes: number | null;
+ latest_run_at: string | null;
+ latest_source_run_id: number | null;
+ latest_catalog_run_id: number;
+ latest_project_id: string | null;
+ steps: V2DecodedRecipeStep[];
+ rows: V2DecodedRecipeRow[];
+ warnings: string[];
+ decoded_size_bytes: number;
+}
+
+export function getV2RecipeEquipmentOptions() {
+ return apiFetch(
+ "/dataset/v2/recipes/equipment-options",
+ );
+}
+
+export function getV2EquipmentRecipes(equipmentId: string) {
+ const query = new URLSearchParams({ equipment_id: equipmentId });
+ return apiFetch(
+ `/dataset/v2/recipes?${query.toString()}`,
+ );
+}
+
+export function getV2EquipmentRecipe(
+ equipmentId: string,
+ sourceRecipeId: number,
+) {
+ const query = new URLSearchParams({ equipment_id: equipmentId });
+ return apiFetch(
+ `/dataset/v2/recipes/${encodeURIComponent(String(sourceRecipeId))}?${query.toString()}`,
+ );
+}
+
+export function getV2EquipmentRecipeExportUrl(
+ equipmentId: string,
+ sourceRecipeId: number,
+): string {
+ const query = new URLSearchParams({ equipment_id: equipmentId });
+ return `${API_URL}/dataset/v2/recipes/${encodeURIComponent(String(sourceRecipeId))}/export?${query.toString()}`;
+}
+
export interface ParityData {
source: string;
model_type?: string;
diff --git a/web/lib/auth-context.tsx b/web/lib/auth-context.tsx
index 7ebb1ba..1c5cfc3 100644
--- a/web/lib/auth-context.tsx
+++ b/web/lib/auth-context.tsx
@@ -82,7 +82,7 @@ export const ROLE_PERMISSIONS: Record<
visibleNavSections: ["main", "data", "ml", "admin"],
visibleNavItems: [
"dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics",
- "samples", "upload", "ingestion", "catalog", "analysis", "parity", "importance",
+ "samples", "upload", "ingestion", "catalog", "analysis", "recipes", "parity", "importance",
"convergence", "proposals", "users", "reviews", "execution_queue", "settings",
],
},
@@ -98,7 +98,7 @@ export const ROLE_PERMISSIONS: Record<
visibleNavSections: ["main", "data", "ml", "admin"],
visibleNavItems: [
"dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics",
- "samples", "upload", "ingestion", "catalog", "analysis", "parity", "importance",
+ "samples", "upload", "ingestion", "catalog", "analysis", "recipes", "parity", "importance",
"convergence", "proposals", "settings",
],
},
@@ -114,7 +114,7 @@ export const ROLE_PERMISSIONS: Record<
visibleNavSections: ["main", "data", "ml", "admin"],
visibleNavItems: [
"dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "analytics",
- "samples", "upload", "ingestion", "catalog", "analysis", "execution_queue", "settings",
+ "samples", "upload", "ingestion", "catalog", "analysis", "recipes", "execution_queue", "settings",
],
},
researcher: {
@@ -129,7 +129,7 @@ export const ROLE_PERMISSIONS: Record<
visibleNavSections: ["main", "data", "ml", "admin"],
visibleNavItems: [
"dashboard", "assistant", "equipment", "projects", "experiments", "processes", "templates", "library", "public_library", "optimize", "analytics",
- "samples", "upload", "catalog", "analysis", "parity", "importance", "convergence",
+ "samples", "upload", "catalog", "analysis", "recipes", "parity", "importance", "convergence",
"proposals", "settings",
],
},
diff --git a/web/lib/trace-export.ts b/web/lib/trace-export.ts
new file mode 100644
index 0000000..18a72fa
--- /dev/null
+++ b/web/lib/trace-export.ts
@@ -0,0 +1,105 @@
+import type {
+ V2RunTrace,
+ V2RunTraceParameter,
+} from "@/lib/api-client";
+
+const TRACE_CSV_COLUMNS = [
+ "catalog_run_id",
+ "glance_run_id",
+ "lot_name",
+ "source_system",
+ "source_tool_id",
+ "parameter_key",
+ "source_parameter_id",
+ "parameter_name",
+ "unit",
+ "sample_record_id",
+ "source_timestamp",
+ "relative_seconds",
+ "recorded_value",
+] as const;
+
+function csvCell(value: unknown): string {
+ if (value === null || value === undefined) return "";
+ const text =
+ typeof value === "boolean" ? (value ? "true" : "false") : String(value);
+ return `"${text.replaceAll('"', '""')}"`;
+}
+
+export function traceParameterUnit(
+ parameter: V2RunTraceParameter | null | undefined,
+): string {
+ return parameter?.unit?.trim() || "";
+}
+
+export function traceAxisLabel(
+ parameter: V2RunTraceParameter | null | undefined,
+): string {
+ if (!parameter) return "Recorded value (unit not provided)";
+ const name =
+ parameter.registered && parameter.registered_name
+ ? parameter.registered_name
+ : parameter.name || parameter.key;
+ const unit = traceParameterUnit(parameter);
+ return unit ? `${name} (${unit})` : `${name} (unit not provided)`;
+}
+
+export function buildTraceCsv(
+ traces: V2RunTrace[],
+ selectedParameter: V2RunTraceParameter,
+): string {
+ const rows: unknown[][] = [Array.from(TRACE_CSV_COLUMNS)];
+
+ traces.forEach((trace) => {
+ const traceParameter =
+ trace.parameters.find(
+ (parameter) => parameter.key === selectedParameter.key,
+ ) ?? selectedParameter;
+ const parameterName =
+ traceParameter.registered && traceParameter.registered_name
+ ? traceParameter.registered_name
+ : traceParameter.name;
+ const unit = traceParameterUnit(traceParameter);
+
+ trace.samples.forEach((sample) => {
+ rows.push([
+ trace.run_id,
+ trace.source_run_id,
+ trace.lot_name,
+ trace.source_system ?? "",
+ trace.source_tool_id ?? sample.source_tool_id,
+ selectedParameter.key,
+ traceParameter.source_parameter_id ?? "",
+ parameterName,
+ unit,
+ sample.sample_record_id,
+ sample.timestamp,
+ sample.relative_seconds,
+ sample.values[selectedParameter.key],
+ ]);
+ });
+ });
+
+ return `${rows.map((row) => row.map(csvCell).join(",")).join("\r\n")}\r\n`;
+}
+
+export function safeDownloadStem(value: string): string {
+ const normalized = value
+ .trim()
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80);
+ return normalized || "trace";
+}
+
+export function downloadCsv(content: string, filename: string): void {
+ const blob = new Blob([content], { type: "text/csv;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(url);
+}