2571 lines
93 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
type Connection,
type Edge,
type Node,
type NodeChange,
type ReactFlowInstance,
type Viewport,
} from "@xyflow/react";
import { ApiClient } from "./api-client.ts";
import {
buildCustomNodeEnvelopePreview,
type CustomNodeValidationIssue,
validateCustomNodeDefinition,
} from "../../../../packages/contracts/src/custom-node.ts";
import {
formatCustomNodeInputModeKey,
formatCustomNodeOutputModeKey,
formatCustomNodeSourceKindKey,
} from "./custom-node-presenter.ts";
import {
localizeNodeDefinition,
type TranslationKey,
useI18n,
} from "./i18n.tsx";
import {
addNodeToDraft,
addNodeToDraftAtPosition,
canConnectNodesInDraft,
connectNodesInDraft,
createDefaultWorkflowDraft,
getNodeRuntimeConfig,
removeNodeFromDraft,
setNodePosition,
resolveDefinitionIdForNode,
serializeWorkflowDraft,
setViewportInDraft,
setNodeRuntimeConfig,
workflowDraftFromVersion,
type WorkflowConnectionValidationReason,
type WorkflowDraft,
type WorkflowNodeRuntimeConfig,
} from "./workflow-editor-state.ts";
const NODE_LIBRARY_MIME = "application/x-emboflow-node-definition";
const ACTIVE_PROJECT_STORAGE_KEY_PREFIX = "emboflow.activeProject";
function navigateTo(pathname: string) {
if (typeof window === "undefined") {
return;
}
if (window.location.pathname === pathname) {
return;
}
window.history.pushState({}, "", pathname);
window.dispatchEvent(new PopStateEvent("popstate"));
}
function getActiveProjectStorageKey(workspaceId: string) {
return `${ACTIVE_PROJECT_STORAGE_KEY_PREFIX}:${workspaceId}`;
}
function normalizePathnameForProjectSwitch(pathname: string) {
if (pathname.startsWith("/assets/")) {
return "/assets";
}
if (pathname.startsWith("/nodes")) {
return "/nodes";
}
if (pathname.startsWith("/workflows/")) {
return "/workflows";
}
if (pathname.startsWith("/runs/")) {
return "/runs";
}
if (pathname.startsWith("/explore/")) {
return "/explore";
}
return pathname === "/" ? "/projects" : pathname;
}
function mapConnectionValidationReasonToKey(
reason: WorkflowConnectionValidationReason | "missing_connection_endpoint",
): TranslationKey {
switch (reason) {
case "missing_source":
case "missing_target":
case "missing_connection_endpoint":
return "invalidConnectionMissingEndpoint";
case "self":
return "invalidConnectionSelf";
case "duplicate":
return "invalidConnectionDuplicate";
case "source_disallows_outgoing":
return "invalidConnectionSourceDisallowsOutgoing";
case "target_disallows_incoming":
return "invalidConnectionTargetDisallowsIncoming";
case "target_already_has_incoming":
return "invalidConnectionTargetAlreadyHasIncoming";
case "cycle":
return "invalidConnectionCycle";
}
}
function mapCustomNodeValidationIssueToKey(issue: CustomNodeValidationIssue): TranslationKey {
switch (issue) {
case "name_required":
return "customNodeValidationNameRequired";
case "name_too_long":
return "customNodeValidationNameTooLong";
case "invalid_category":
return "customNodeValidationInvalidCategory";
case "invalid_source_kind":
return "customNodeValidationInvalidSourceKind";
case "image_required":
return "customNodeValidationImageRequired";
case "dockerfile_required":
return "customNodeValidationDockerfileRequired";
case "dockerfile_missing_from":
return "customNodeValidationDockerfileMissingFrom";
case "invalid_command":
return "customNodeValidationInvalidCommand";
case "invalid_input_mode":
return "customNodeValidationInvalidInputMode";
case "invalid_output_mode":
return "customNodeValidationInvalidOutputMode";
case "invalid_artifact_type":
return "customNodeValidationInvalidArtifactType";
case "source_cannot_be_multi_input":
return "customNodeValidationSourceCannotBeMultiInput";
}
}
type NavItem = "Projects" | "Assets" | "Nodes" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin";
type BootstrapContext = {
userId: string;
workspace: { _id: string; name: string };
project: { _id: string; name: string };
};
type ProjectSummary = {
_id: string;
name: string;
description?: string;
status?: string;
createdAt?: string;
};
type AppProps = {
apiBaseUrl: string;
};
type WorkflowPreflightResult = {
ok: boolean;
issues: Array<{
severity: "error" | "warning";
code: string;
message: string;
nodeId?: string;
nodeDefinitionId?: string;
}>;
summary: {
errorCount: number;
warningCount: number;
};
};
function translateStatus(status: string | undefined, t: ReturnType<typeof useI18n>["t"]) {
switch (status) {
case "success":
return t("success");
case "failed":
return t("failed");
case "running":
return t("running");
case "queued":
case "pending":
return t("queued");
case "cancelled":
return t("cancelled");
default:
return status ?? "unknown";
}
}
function formatTaskSummary(task: any, t: ReturnType<typeof useI18n>["t"]) {
if (task?.summary?.errorMessage) {
return task.summary.errorMessage;
}
const outcome = translateStatus(task?.summary?.outcome ?? task?.status, t);
const executor = task?.summary?.executorType ?? task?.executorType ?? "unknown";
const assetCount = task?.summary?.assetCount ?? task?.assetIds?.length ?? 0;
const artifactCount = task?.summary?.artifactIds?.length ?? task?.outputArtifactIds?.length ?? 0;
return `${t("viaExecutor", { outcome, executor })}; ${t("assetCount", { count: assetCount })}; ${t("artifactCount", { count: artifactCount })}`;
}
function formatRunSummary(run: any, t: ReturnType<typeof useI18n>["t"]) {
const totalTaskCount = run?.summary?.totalTaskCount ?? 0;
const successCount = run?.summary?.taskCounts?.success ?? 0;
const failedCount = run?.summary?.taskCounts?.failed ?? 0;
const runningCount = run?.summary?.taskCounts?.running ?? 0;
const cancelledCount = run?.summary?.taskCounts?.cancelled ?? 0;
const stdoutLineCount = run?.summary?.stdoutLineCount ?? 0;
const stderrLineCount = run?.summary?.stderrLineCount ?? 0;
return [
t("successCount", { count: successCount }),
t("failedCount", { count: failedCount }),
t("runningCount", { count: runningCount }),
t("cancelledCount", { count: cancelledCount }),
t("stdoutLines", { count: stdoutLineCount }),
t("stderrLines", { count: stderrLineCount }),
t("totalTasks", { count: totalTaskCount }),
].join(", ");
}
function formatExecutorConfigLabel(config?: Record<string, unknown>) {
if (!config || Object.keys(config).length === 0) {
return "none";
}
return JSON.stringify(config);
}
function getDefaultExecutorType(definition?: { defaultExecutorType?: "python" | "docker" | "http" } | null) {
return definition?.defaultExecutorType ?? "python";
}
function getDefaultExecutorConfig(definition?: { defaultExecutorConfig?: Record<string, unknown> } | null) {
return definition?.defaultExecutorConfig ? { ...definition.defaultExecutorConfig } : undefined;
}
function getEffectiveNodeRuntimeConfig(
definition: { id: string; defaultExecutorType?: "python" | "docker" | "http"; defaultExecutorConfig?: Record<string, unknown> } | null,
runtimeConfig: WorkflowNodeRuntimeConfig | undefined,
): WorkflowNodeRuntimeConfig {
const executorType = runtimeConfig?.executorType ?? getDefaultExecutorType(definition);
const executorConfig = {
...(getDefaultExecutorConfig(definition) ?? {}),
...(runtimeConfig?.executorConfig ?? {}),
};
return {
definitionId: runtimeConfig?.definitionId ?? definition?.id,
executorType,
executorConfig: Object.keys(executorConfig).length > 0 ? executorConfig : undefined,
codeHookSpec: runtimeConfig?.codeHookSpec,
artifactType: runtimeConfig?.artifactType,
artifactTitle: runtimeConfig?.artifactTitle,
};
}
function usePathname() {
const [pathname, setPathname] = useState(
typeof window === "undefined" ? "/projects" : window.location.pathname || "/projects",
);
useEffect(() => {
const handle = () => setPathname(window.location.pathname || "/assets");
window.addEventListener("popstate", handle);
return () => window.removeEventListener("popstate", handle);
}, []);
return pathname === "/" ? "/projects" : pathname;
}
function AppShell(props: {
workspaceName: string;
projectName: string;
projectControl?: React.ReactNode;
active: NavItem;
children: React.ReactNode;
}) {
const { language, setLanguage, t } = useI18n();
const navItems: Array<{ label: NavItem; href: string; key: "navProjects" | "navAssets" | "navNodes" | "navWorkflows" | "navRuns" | "navExplore" | "navLabels" | "navAdmin" }> = [
{ label: "Projects", href: "/projects", key: "navProjects" },
{ label: "Assets", href: "/assets", key: "navAssets" },
{ label: "Nodes", href: "/nodes", key: "navNodes" },
{ label: "Workflows", href: "/workflows", key: "navWorkflows" },
{ label: "Runs", href: "/runs", key: "navRuns" },
{ label: "Explore", href: "/explore", key: "navExplore" },
{ label: "Labels", href: "/labels", key: "navLabels" },
{ label: "Admin", href: "/admin", key: "navAdmin" },
];
return (
<div className="app-shell">
<header className="app-header">
<div className="app-header__group">
<div className="app-header__pill">
<span className="app-header__label">{t("workspace")}</span>
<strong>{props.workspaceName}</strong>
</div>
<div className="app-header__pill">
<span className="app-header__label">{t("project")}</span>
{props.projectControl ?? <strong>{props.projectName}</strong>}
</div>
</div>
<div className="app-header__group">
<div className="language-switcher" role="group" aria-label={t("language")}>
<button
type="button"
className="button-secondary"
data-active={String(language === "zh")}
onClick={() => setLanguage("zh")}
>
{t("chinese")}
</button>
<button
type="button"
className="button-secondary"
data-active={String(language === "en")}
onClick={() => setLanguage("en")}
>
{t("english")}
</button>
</div>
<span className="app-header__label">{t("runs")}</span>
<span className="app-header__label">{t("localDev")}</span>
</div>
</header>
<aside className="app-sidebar">
<ul>
{navItems.map((item) => (
<li key={item.label}>
<a
href={item.href}
data-active={String(item.label === props.active)}
onClick={(event) => {
event.preventDefault();
navigateTo(item.href);
}}
>
{t(item.key)}
</a>
</li>
))}
</ul>
</aside>
<main className="app-main">{props.children}</main>
</div>
);
}
function ProjectsPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
projects: ProjectSummary[];
activeProjectId: string;
onProjectCreated: (project: ProjectSummary) => Promise<void> | void;
onProjectSelected: (projectId: string, nextPath?: string) => void;
}) {
const { t } = useI18n();
const [projectName, setProjectName] = useState("");
const [projectDescription, setProjectDescription] = useState("");
const [error, setError] = useState<string | null>(null);
return (
<div className="page-stack">
<section className="panel">
<h1>{t("projectsTitle")}</h1>
<p>{t("projectsDescription")}</p>
<div className="field-grid">
<label>
{t("projectNameLabel")}
<input
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
placeholder="Embodied Delivery Project"
/>
</label>
<label>
{t("projectDescriptionLabel")}
<textarea
rows={3}
value={projectDescription}
onChange={(event) => setProjectDescription(event.target.value)}
placeholder="Customer-specific dataset conversion and delivery workflows"
/>
</label>
</div>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
disabled={projectName.trim().length === 0}
onClick={async () => {
setError(null);
try {
const project = await props.api.createProject({
workspaceId: props.bootstrap.workspace._id,
name: projectName.trim(),
description: projectDescription.trim() || undefined,
createdBy: props.bootstrap.userId,
});
setProjectName("");
setProjectDescription("");
await props.onProjectCreated(project);
} catch (createError) {
setError(
createError instanceof Error ? createError.message : t("failedCreateProject"),
);
}
}}
>
{t("createProject")}
</button>
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="panel">
<div className="list-grid">
{props.projects.length === 0 ? (
<p className="empty-state">{t("noProjectsYet")}</p>
) : (
props.projects.map((project) => {
const isActive = project._id === props.activeProjectId;
return (
<article key={project._id} className="asset-card" data-active={String(isActive)}>
<div className="toolbar">
<strong>{project.name}</strong>
{isActive ? (
<span className="status-pill" data-status="running">
{t("activeProject")}
</span>
) : null}
</div>
<p>{project.description || t("notAvailable")}</p>
<p>{t("status")}: {translateStatus(project.status, t)}</p>
<p>{t("createdAt")}: {project.createdAt ?? t("notAvailable")}</p>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className={isActive ? "button-secondary" : "button-primary"}
onClick={() => props.onProjectSelected(project._id, "/workflows")}
>
{t("openProject")}
</button>
</div>
</article>
);
})
)}
</div>
</section>
</div>
);
}
function parseCommandLines(value: string) {
return value
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
function AssetsPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
}) {
const { t } = useI18n();
const [sourcePath, setSourcePath] = useState("");
const [storageName, setStorageName] = useState("");
const [storageProvider, setStorageProvider] = useState<"local" | "minio" | "s3" | "bos" | "oss">("local");
const [storageBucket, setStorageBucket] = useState("");
const [storageEndpoint, setStorageEndpoint] = useState("");
const [storageRegion, setStorageRegion] = useState("");
const [storageBasePath, setStorageBasePath] = useState("");
const [storageRootPath, setStorageRootPath] = useState("");
const [datasetName, setDatasetName] = useState("");
const [datasetDescription, setDatasetDescription] = useState("");
const [datasetStoragePath, setDatasetStoragePath] = useState("");
const [selectedDatasetAssetId, setSelectedDatasetAssetId] = useState("");
const [selectedStorageConnectionId, setSelectedStorageConnectionId] = useState("");
const [assets, setAssets] = useState<any[]>([]);
const [storageConnections, setStorageConnections] = useState<any[]>([]);
const [datasets, setDatasets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const loadData = async () => {
try {
const [nextAssets, nextStorageConnections, nextDatasets] = await Promise.all([
props.api.listAssets(props.bootstrap.project._id),
props.api.listStorageConnections(props.bootstrap.workspace._id),
props.api.listDatasets(props.bootstrap.project._id),
]);
setAssets(nextAssets);
setStorageConnections(nextStorageConnections);
setDatasets(nextDatasets);
setSelectedDatasetAssetId((previous) => {
if (previous && nextAssets.some((asset) => asset._id === previous)) {
return previous;
}
return nextAssets[0]?._id ?? "";
});
setSelectedStorageConnectionId((previous) => {
if (previous && nextStorageConnections.some((connection) => connection._id === previous)) {
return previous;
}
return nextStorageConnections[0]?._id ?? "";
});
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadAssets"));
}
};
useEffect(() => {
void loadData();
}, [props.bootstrap.project._id]);
return (
<div className="page-stack">
<section className="panel">
<h1>{t("assetsTitle")}</h1>
<p>{t("assetsDescription")}</p>
<div className="field-grid">
<label>
{t("localPath")}
<input
value={sourcePath}
onChange={(event) => setSourcePath(event.target.value)}
placeholder="/Users/longtaowu/workspace/emboldata/data"
/>
</label>
</div>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
onClick={async () => {
setError(null);
try {
const asset = await props.api.registerLocalAsset({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
sourcePath,
});
await props.api.probeAsset(asset._id);
await loadData();
} catch (registerError) {
setError(
registerError instanceof Error
? registerError.message
: t("failedRegisterAsset"),
);
}
}}
>
{t("registerLocalPath")}
</button>
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="two-column">
<section className="panel">
<h2>{t("storageConnectionsTitle")}</h2>
<p>{t("storageConnectionsDescription")}</p>
<div className="field-grid">
<label>
{t("templateName")}
<input
value={storageName}
onChange={(event) => setStorageName(event.target.value)}
placeholder="Local Workspace Storage"
/>
</label>
<label>
{t("storageProvider")}
<select
value={storageProvider}
onChange={(event) =>
setStorageProvider(event.target.value as "local" | "minio" | "s3" | "bos" | "oss")
}
>
<option value="local">local</option>
<option value="minio">minio</option>
<option value="s3">s3</option>
<option value="bos">bos</option>
<option value="oss">oss</option>
</select>
</label>
<label>
{t("bucket")}
<input
value={storageBucket}
onChange={(event) => setStorageBucket(event.target.value)}
placeholder="emboflow-datasets"
/>
</label>
<label>
{t("endpoint")}
<input
value={storageEndpoint}
onChange={(event) => setStorageEndpoint(event.target.value)}
placeholder="oss-cn-hangzhou.aliyuncs.com"
/>
</label>
<label>
{t("region")}
<input
value={storageRegion}
onChange={(event) => setStorageRegion(event.target.value)}
placeholder="cn-hangzhou"
/>
</label>
<label>
{t("basePath")}
<input
value={storageBasePath}
onChange={(event) => setStorageBasePath(event.target.value)}
placeholder="datasets/project-a"
/>
</label>
<label>
{t("rootPath")}
<input
value={storageRootPath}
onChange={(event) => setStorageRootPath(event.target.value)}
placeholder="/Users/longtaowu/workspace/emboldata"
/>
</label>
</div>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
onClick={async () => {
setError(null);
try {
await props.api.createStorageConnection({
workspaceId: props.bootstrap.workspace._id,
name: storageName || `${storageProvider} storage`,
provider: storageProvider,
bucket: storageBucket || undefined,
endpoint: storageEndpoint || undefined,
region: storageRegion || undefined,
basePath: storageBasePath || undefined,
rootPath: storageRootPath || undefined,
});
setStorageName("");
setStorageBucket("");
setStorageEndpoint("");
setStorageRegion("");
setStorageBasePath("");
setStorageRootPath("");
await loadData();
} catch (createError) {
setError(
createError instanceof Error
? createError.message
: t("failedCreateStorageConnection"),
);
}
}}
>
{t("createStorageConnection")}
</button>
</div>
<div className="list-grid" style={{ marginTop: 16 }}>
{storageConnections.length === 0 ? (
<p className="empty-state">{t("noStorageConnectionsYet")}</p>
) : (
storageConnections.map((connection) => (
<article key={connection._id} className="asset-card">
<div className="toolbar">
<strong>{connection.name}</strong>
<span className="status-pill">{connection.provider}</span>
</div>
<p>{t("bucket")}: {connection.bucket ?? t("notAvailable")}</p>
<p>{t("endpoint")}: {connection.endpoint ?? connection.rootPath ?? t("notAvailable")}</p>
<p>{t("basePath")}: {connection.basePath ?? t("notAvailable")}</p>
</article>
))
)}
</div>
</section>
<section className="panel">
<h2>{t("datasetsTitle")}</h2>
<p>{t("datasetsDescription")}</p>
<div className="field-grid">
<label>
{t("datasetName")}
<input
value={datasetName}
onChange={(event) => setDatasetName(event.target.value)}
placeholder="Delivery Dataset"
/>
</label>
<label>
{t("datasetDescription")}
<input
value={datasetDescription}
onChange={(event) => setDatasetDescription(event.target.value)}
placeholder="Project level dataset derived from source assets"
/>
</label>
<label>
{t("sourceAsset")}
<select
value={selectedDatasetAssetId}
onChange={(event) => setSelectedDatasetAssetId(event.target.value)}
>
{assets.length === 0 ? <option value="">{t("noAssetsAvailable")}</option> : null}
{assets.map((asset) => (
<option key={asset._id} value={asset._id}>
{asset.displayName}
</option>
))}
</select>
</label>
<label>
{t("storageConnection")}
<select
value={selectedStorageConnectionId}
onChange={(event) => setSelectedStorageConnectionId(event.target.value)}
>
{storageConnections.length === 0 ? <option value="">{t("noStorageConnectionsYet")}</option> : null}
{storageConnections.map((connection) => (
<option key={connection._id} value={connection._id}>
{connection.name}
</option>
))}
</select>
</label>
<label>
{t("storagePathLabel")}
<input
value={datasetStoragePath}
onChange={(event) => setDatasetStoragePath(event.target.value)}
placeholder="delivery/dataset-v1"
/>
</label>
</div>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
disabled={!selectedDatasetAssetId || !selectedStorageConnectionId}
onClick={async () => {
setError(null);
try {
await props.api.createDataset({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
name:
datasetName ||
`Dataset from ${assets.find((asset) => asset._id === selectedDatasetAssetId)?.displayName ?? "asset"}`,
description: datasetDescription || undefined,
sourceAssetIds: [selectedDatasetAssetId],
storageConnectionId: selectedStorageConnectionId,
storagePath:
datasetStoragePath ||
`datasets/${datasetName ? datasetName.toLowerCase().replace(/\s+/gu, "-") : "dataset"}`,
});
setDatasetName("");
setDatasetDescription("");
setDatasetStoragePath("");
await loadData();
} catch (createError) {
setError(
createError instanceof Error ? createError.message : t("failedCreateDataset"),
);
}
}}
>
{t("createDataset")}
</button>
</div>
<div className="list-grid" style={{ marginTop: 16 }}>
{datasets.length === 0 ? (
<p className="empty-state">{t("noDatasetsYet")}</p>
) : (
datasets.map((dataset) => (
<article key={dataset._id} className="asset-card">
<strong>{dataset.name}</strong>
<p>{t("status")}: {translateStatus(dataset.status, t)}</p>
<p>{t("sourceAssets")}: {(dataset.sourceAssetIds ?? []).join(", ") || t("none")}</p>
<p>{t("storageConnection")}: {storageConnections.find((item) => item._id === dataset.storageConnectionId)?.name ?? dataset.storageConnectionId}</p>
<p>{t("storagePathLabel")}: {dataset.storagePath}</p>
<p>{t("latestDatasetVersion")}: {dataset.latestVersionNumber}</p>
</article>
))
)}
</div>
</section>
</section>
<section className="panel">
<div className="list-grid">
{assets.length === 0 ? (
<p className="empty-state">{t("noAssetsYet")}</p>
) : (
assets.map((asset) => (
<article key={asset._id} className="asset-card">
<div className="toolbar">
<a href={`/assets/${asset._id}`}>
<strong>{asset.displayName}</strong>
</a>
<span className="status-pill" data-status={asset.status}>
{translateStatus(asset.status, t)}
</span>
</div>
<p>{t("type")}: {asset.type}</p>
<p>{t("source")}: {asset.sourceType}</p>
<p>{t("detected")}: {(asset.detectedFormats ?? []).join(", ") || t("pending")}</p>
<p>{t("topLevelEntries")}: {(asset.topLevelPaths ?? []).slice(0, 6).join(", ") || t("notAvailable")}</p>
</article>
))
)}
</div>
</section>
</div>
);
}
function NodesPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
}) {
const { t } = useI18n();
const [nodes, setNodes] = useState<any[]>([]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [category, setCategory] = useState<"Transform" | "Inspect" | "Utility" | "Export">("Utility");
const [sourceKind, setSourceKind] = useState<"image" | "dockerfile">("image");
const [image, setImage] = useState("python:3.11-alpine");
const [dockerfileContent, setDockerfileContent] = useState("");
const [commandText, setCommandText] = useState("");
const [inputMode, setInputMode] = useState<"single_asset_set" | "multi_asset_set">("single_asset_set");
const [outputMode, setOutputMode] = useState<"report" | "asset_set" | "asset_set_with_report">("report");
const [artifactType, setArtifactType] = useState<"json" | "directory" | "video">("json");
const [error, setError] = useState<string | null>(null);
const customNodeValidationIssues = useMemo(
() =>
validateCustomNodeDefinition({
name,
category,
source:
sourceKind === "image"
? {
kind: "image",
image,
command: parseCommandLines(commandText),
}
: {
kind: "dockerfile",
dockerfileContent,
command: parseCommandLines(commandText),
},
contract: {
inputMode,
outputMode,
artifactType,
},
}),
[artifactType, category, commandText, dockerfileContent, image, inputMode, name, outputMode, sourceKind],
);
const loadCustomNodes = useCallback(async () => {
try {
setNodes(await props.api.listCustomNodes(props.bootstrap.project._id));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadCustomNodes"));
}
}, [props.api, props.bootstrap.project._id, t]);
useEffect(() => {
void loadCustomNodes();
}, [loadCustomNodes]);
return (
<div className="page-stack">
<section className="panel">
<h1>{t("nodesTitle")}</h1>
<p>{t("nodesDescription")}</p>
{error ? <p>{error}</p> : null}
<div className="field-grid">
<label>
{t("customNodeName")}
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Asset Merge" />
</label>
<label>
{t("customNodeCategory")}
<select value={category} onChange={(event) => setCategory(event.target.value as typeof category)}>
<option value="Transform">Transform</option>
<option value="Inspect">Inspect</option>
<option value="Utility">Utility</option>
<option value="Export">Export</option>
</select>
</label>
<label>
{t("customNodeDescription")}
<textarea value={description} rows={3} onChange={(event) => setDescription(event.target.value)} />
</label>
<label>
{t("customNodeSourceKind")}
<select value={sourceKind} onChange={(event) => setSourceKind(event.target.value as typeof sourceKind)}>
<option value="image">{t("customNodeSourceImage")}</option>
<option value="dockerfile">{t("customNodeSourceDockerfile")}</option>
</select>
</label>
<label>
{t("customNodeInputMode")}
<select value={inputMode} onChange={(event) => setInputMode(event.target.value as typeof inputMode)}>
<option value="single_asset_set">{t("customNodeSingleAssetSet")}</option>
<option value="multi_asset_set">{t("customNodeMultiAssetSet")}</option>
</select>
</label>
<label>
{t("customNodeOutputMode")}
<select value={outputMode} onChange={(event) => setOutputMode(event.target.value as typeof outputMode)}>
<option value="report">{t("customNodeReport")}</option>
<option value="asset_set">{t("customNodeAssetSet")}</option>
<option value="asset_set_with_report">{t("customNodeAssetSetWithReport")}</option>
</select>
</label>
<label>
{t("customNodeArtifactType")}
<select value={artifactType} onChange={(event) => setArtifactType(event.target.value as typeof artifactType)}>
<option value="json">json</option>
<option value="directory">directory</option>
<option value="video">video</option>
</select>
</label>
<label>
{t("customNodeCommand")}
<textarea
rows={4}
value={commandText}
onChange={(event) => setCommandText(event.target.value)}
placeholder={["python3", "-c", "print('custom node')"].join("\n")}
/>
</label>
{sourceKind === "image" ? (
<label>
{t("customNodeImage")}
<input value={image} onChange={(event) => setImage(event.target.value)} placeholder="python:3.11-alpine" />
</label>
) : (
<>
<label>
{t("customNodeDockerfile")}
<textarea
rows={10}
value={dockerfileContent}
onChange={(event) => setDockerfileContent(event.target.value)}
placeholder={["FROM python:3.11-alpine", "CMD [\"python3\", \"-c\", \"print('custom node')\"]"].join("\n")}
/>
</label>
<label>
{t("customNodeDockerfileUpload")}
<input
type="file"
accept=".dockerfile,.Dockerfile,text/plain"
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
setDockerfileContent(await file.text());
}}
/>
</label>
</>
)}
</div>
<div className="button-row" style={{ marginTop: 12 }}>
{customNodeValidationIssues.length > 0 ? (
<div>
{customNodeValidationIssues.map((issue) => (
<p key={issue}>{t(mapCustomNodeValidationIssueToKey(issue))}</p>
))}
</div>
) : null}
<button
className="button-primary"
onClick={async () => {
if (customNodeValidationIssues.length > 0) {
setError(t(mapCustomNodeValidationIssueToKey(customNodeValidationIssues[0])));
return;
}
try {
setError(null);
await props.api.createCustomNode({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
name,
description,
category,
source:
sourceKind === "image"
? {
kind: "image",
image,
command: parseCommandLines(commandText),
}
: {
kind: "dockerfile",
dockerfileContent,
command: parseCommandLines(commandText),
},
contract: {
inputMode,
outputMode,
artifactType,
},
createdBy: props.bootstrap.userId,
});
setName("");
setDescription("");
setCommandText("");
if (sourceKind === "dockerfile") {
setDockerfileContent("");
}
await loadCustomNodes();
} catch (createError) {
setError(createError instanceof Error ? createError.message : t("failedCreateCustomNode"));
}
}}
disabled={customNodeValidationIssues.length > 0}
>
{t("createCustomNode")}
</button>
</div>
</section>
<section className="panel">
<h2>{t("nodesTitle")}</h2>
{nodes.length === 0 ? (
<p className="empty-state">{t("noCustomNodesYet")}</p>
) : (
<div className="list-grid">
{nodes.map((node) => (
<article key={node._id} className="node-card">
<strong>{node.name}</strong>
<p>{node.description || t("none")}</p>
<p>{t("category")}: {node.category}</p>
<p>{t("customNodeSourceKind")}: {node.source?.kind}</p>
<p>{t("customNodeInputMode")}: {node.contract?.inputMode}</p>
<p>{t("customNodeOutputMode")}: {node.contract?.outputMode}</p>
<p>{t("customNodeArtifactType")}: {node.contract?.artifactType}</p>
<p>{t("definition")}: {node.definitionId}</p>
</article>
))}
</div>
)}
</section>
</div>
);
}
function AssetDetailPage(props: {
api: ApiClient;
assetId: string;
}) {
const { t } = useI18n();
const [asset, setAsset] = useState<any | null>(null);
const [probeReport, setProbeReport] = useState<any | null>(null);
const [artifactId, setArtifactId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const [assetData, reportData] = await Promise.all([
props.api.getAsset(props.assetId),
props.api.getProbeReport(props.assetId).catch(() => null),
]);
setAsset(assetData);
setProbeReport(reportData);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load asset detail");
}
})();
}, [props.assetId]);
if (error) {
return <section className="panel">{error}</section>;
}
if (!asset) {
return <section className="panel">{t("loadingAssetDetail")}</section>;
}
return (
<div className="page-stack">
<section className="two-column">
<div className="panel">
<h1>{asset.displayName}</h1>
<p>{t("assetId")}: {asset._id}</p>
<p>{t("type")}: {asset.type}</p>
<p>{t("status")}: {translateStatus(asset.status, t)}</p>
<p>{t("sourcePath")}: {asset.sourcePath ?? t("notAvailable")}</p>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
onClick={async () => {
const report = await props.api.probeAsset(asset._id);
setProbeReport(report);
const reloaded = await props.api.getAsset(asset._id);
setAsset(reloaded);
}}
>
{t("probeAgain")}
</button>
<button
className="button-secondary"
onClick={async () => {
if (!probeReport) {
return;
}
const artifact = await props.api.createArtifact({
type: "json",
title: `Probe Report: ${asset.displayName}`,
producerType: "asset",
producerId: asset._id,
payload: probeReport.rawReport ?? {},
});
setArtifactId(artifact._id);
}}
>
{t("createExploreArtifact")}
</button>
{artifactId ? <a href={`/explore/${artifactId}`}>{t("openExploreView")}</a> : null}
</div>
</div>
<aside className="panel">
<h2>{t("probeSummary")}</h2>
{probeReport ? (
<>
<p>{t("detected")}: {(probeReport.detectedFormatCandidates ?? []).join(", ")}</p>
<p>{t("warnings")}: {(probeReport.warnings ?? []).join(", ") || t("none")}</p>
<p>
{t("recommendedNodes")}: {(probeReport.recommendedNextNodes ?? []).join(", ") || "inspect_asset"}
</p>
<pre className="mono-block">
{JSON.stringify(probeReport.structureSummary ?? {}, null, 2)}
</pre>
</>
) : (
<p className="empty-state">{t("noProbeReportYet")}</p>
)}
</aside>
</section>
</div>
);
}
function WorkflowsPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
}) {
const { t } = useI18n();
const [workflows, setWorkflows] = useState<any[]>([]);
const [templates, setTemplates] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const [nextWorkflows, nextTemplates] = await Promise.all([
props.api.listWorkflows(props.bootstrap.project._id),
props.api.listWorkflowTemplates({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
}),
]);
setWorkflows(nextWorkflows);
setTemplates(nextTemplates);
setError(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadTemplates"));
}
};
useEffect(() => {
void load();
}, [props.bootstrap.project._id, props.bootstrap.workspace._id]);
return (
<div className="page-stack">
<section className="panel">
<div className="toolbar">
<h1 style={{ margin: 0 }}>{t("workflowsTitle")}</h1>
<button
className="button-primary"
onClick={async () => {
const workflow = await props.api.createWorkflow({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
name: t("workflowCreatedName", { count: workflows.length + 1 }),
});
navigateTo(`/workflows/${workflow._id}`);
await load();
}}
>
{t("createBlankWorkflow")}
</button>
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="two-column">
<section className="panel">
<h2>{t("workflowTemplatesTitle")}</h2>
<p>{t("workflowTemplatesDescription")}</p>
<div className="list-grid">
{templates.length === 0 ? (
<p className="empty-state">{t("noWorkflowTemplatesYet")}</p>
) : (
templates.map((template) => (
<article key={template._id} className="asset-card">
<div className="toolbar">
<strong>{template.name}</strong>
<span className="status-pill">{template.projectId ? t("project") : t("workspace")}</span>
</div>
<p>{template.description || t("notAvailable")}</p>
<p>{t("status")}: {translateStatus(template.status, t)}</p>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
onClick={async () => {
try {
setError(null);
const workflow = await props.api.createWorkflowFromTemplate({
templateId: template._id,
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
name: `${template.name} ${workflows.length + 1}`,
createdBy: props.bootstrap.userId,
});
navigateTo(`/workflows/${workflow._id}`);
} catch (createError) {
setError(
createError instanceof Error
? createError.message
: t("failedCreateWorkflowFromTemplate"),
);
}
}}
>
{t("createWorkflowFromTemplate")}
</button>
</div>
</article>
))
)}
</div>
</section>
<section className="panel">
<div className="toolbar">
<h2 style={{ margin: 0 }}>{t("workflowsTitle")}</h2>
</div>
<div className="list-grid" style={{ marginTop: 12 }}>
{workflows.length === 0 ? (
<p className="empty-state">{t("noWorkflowsYet")}</p>
) : (
workflows.map((workflow) => (
<article key={workflow._id} className="asset-card">
<a href={`/workflows/${workflow._id}`}>
<strong>{workflow.name}</strong>
</a>
<p>{t("status")}: {translateStatus(workflow.status, t)}</p>
<p>{t("latestVersion")}: {workflow.latestVersionNumber}</p>
</article>
))
)}
</div>
</section>
</section>
</div>
);
}
function WorkflowEditorPage(props: {
api: ApiClient;
workflowId: string;
}) {
const { language, t } = useI18n();
const [workflow, setWorkflow] = useState<any | null>(null);
const [nodes, setNodes] = useState<any[]>([]);
const [assets, setAssets] = useState<any[]>([]);
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
const [versions, setVersions] = useState<any[]>([]);
const [draft, setDraft] = useState<WorkflowDraft>(() => createDefaultWorkflowDraft());
const [selectedNodeId, setSelectedNodeId] = useState("rename-folder");
const [lastRunId, setLastRunId] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
const [error, setError] = useState<string | null>(null);
const [templateName, setTemplateName] = useState("");
const [templateDescription, setTemplateDescription] = useState("");
const [savedTemplateName, setSavedTemplateName] = useState<string | null>(null);
const [canvasFeedbackKey, setCanvasFeedbackKey] = useState<TranslationKey | null>(null);
const [canvasDropActive, setCanvasDropActive] = useState(false);
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<Node, Edge> | null>(null);
const [preflightResult, setPreflightResult] = useState<WorkflowPreflightResult | null>(null);
const [preflightBusy, setPreflightBusy] = useState(false);
useEffect(() => {
void (async () => {
try {
const workflowDefinition = await props.api.getWorkflowDefinition(props.workflowId);
const [nodeDefs, workflowVersions, workflowAssets] = await Promise.all([
props.api.listNodeDefinitions(workflowDefinition.projectId),
props.api.listWorkflowVersions(props.workflowId),
props.api.listAssets(workflowDefinition.projectId),
]);
setWorkflow(workflowDefinition);
setNodes(nodeDefs);
setAssets(workflowAssets);
setSelectedAssetId((previous) => {
if (previous && workflowAssets.some((asset) => asset._id === previous)) {
return previous;
}
return workflowAssets[0]?._id ?? null;
});
setVersions(workflowVersions);
const nextDraft = workflowDraftFromVersion(workflowVersions[0] ?? null);
setDraft(nextDraft);
setSelectedNodeId(nextDraft.logicGraph.nodes[0]?.id ?? "rename-folder");
setTemplateName(`${workflowDefinition.name} Template`);
setTemplateDescription("");
setSavedTemplateName(null);
setDirty(false);
setCanvasFeedbackKey(null);
setPreflightResult(null);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadWorkflow"));
}
})();
}, [props.workflowId, props.api, t]);
const localizedNodes = useMemo(
() => nodes.map((node) => localizeNodeDefinition(language, node)),
[language, nodes],
);
const nodeDefinitionsById = useMemo(
() => new Map(nodes.map((node) => [node.id, node])),
[nodes],
);
const selectedNodeDefinitionId = useMemo(
() => resolveDefinitionIdForNode(draft, selectedNodeId),
[draft, selectedNodeId],
);
const selectedNodeRaw = useMemo(
() => nodes.find((node) => node.id === selectedNodeDefinitionId) ?? null,
[nodes, selectedNodeDefinitionId],
);
const selectedNode = useMemo(
() => (selectedNodeRaw ? localizeNodeDefinition(language, selectedNodeRaw) : null),
[language, selectedNodeRaw],
);
const selectedNodeSupportsCodeHook = useMemo(
() => {
if (typeof selectedNodeRaw?.supportsCodeHook === "boolean") {
return selectedNodeRaw.supportsCodeHook;
}
return (
selectedNodeRaw?.category === "Transform" ||
selectedNodeRaw?.category === "Inspect" ||
selectedNodeRaw?.category === "Utility"
);
},
[selectedNodeRaw],
);
const selectedNodeRuntimeConfig = useMemo(
() => getNodeRuntimeConfig(draft, selectedNodeId),
[draft, selectedNodeId],
);
const selectedNodeEffectiveRuntimeConfig = useMemo(
() => getEffectiveNodeRuntimeConfig(selectedNodeRaw, selectedNodeRuntimeConfig),
[selectedNodeRaw, selectedNodeRuntimeConfig],
);
const selectedNodeContract = useMemo(
() =>
(selectedNodeEffectiveRuntimeConfig.executorConfig as {
contract?: {
inputMode?: string;
outputMode?: string;
artifactType?: string;
};
} | undefined)?.contract,
[selectedNodeEffectiveRuntimeConfig],
);
const selectedNodeSourceKind = useMemo(
() =>
(selectedNodeEffectiveRuntimeConfig.executorConfig as {
kind?: string;
} | undefined)?.kind,
[selectedNodeEffectiveRuntimeConfig],
);
const selectedNodeEnvelopePreview = useMemo(() => {
if (
!selectedNodeContract ||
typeof selectedNodeContract.inputMode !== "string" ||
typeof selectedNodeContract.outputMode !== "string" ||
typeof selectedNodeContract.artifactType !== "string"
) {
return null;
}
return buildCustomNodeEnvelopePreview({
inputMode: selectedNodeContract.inputMode as "single_asset_set" | "multi_asset_set",
outputMode: selectedNodeContract.outputMode as "report" | "asset_set" | "asset_set_with_report",
artifactType: selectedNodeContract.artifactType as "json" | "directory" | "video",
});
}, [selectedNodeContract]);
const canvasNodes = useMemo<Array<Node>>(
() =>
draft.logicGraph.nodes.map((node) => {
const definitionId = resolveDefinitionIdForNode(draft, node.id);
const definition =
localizedNodes.find((candidate) => candidate.id === definitionId) ??
localizeNodeDefinition(language, {
id: definitionId,
name: definitionId,
description: definitionId,
category: node.type,
});
const position = draft.visualGraph.nodePositions[node.id] ?? { x: 0, y: 0 };
return {
id: node.id,
position,
type:
node.type === "source"
? "input"
: node.type === "export"
? "output"
: "default",
className: `workflow-flow-node workflow-flow-node--${node.type}`,
data: {
label: (
<div className="workflow-flow-node__body">
<strong>{definition.name}</strong>
<span>{node.id}</span>
</div>
),
},
} satisfies Node;
}),
[draft, language, localizedNodes],
);
const canvasEdges = useMemo<Array<Edge>>(
() =>
draft.logicGraph.edges.map((edge) => ({
id: `${edge.from}->${edge.to}`,
source: edge.from,
target: edge.to,
type: "smoothstep",
animated: selectedNodeId === edge.from || selectedNodeId === edge.to,
})),
[draft.logicGraph.edges, selectedNodeId],
);
const onCanvasNodesChange = useCallback((changes: NodeChange[]) => {
const positionChanges = changes.filter(
(change): change is NodeChange & { id: string; position: { x: number; y: number } } =>
change.type === "position" && Boolean(change.position),
);
const selectedChange = changes.find(
(change): change is NodeChange & { id: string; selected: boolean } =>
change.type === "select" && typeof change.selected === "boolean" && change.selected,
);
if (selectedChange) {
setSelectedNodeId(selectedChange.id);
}
if (positionChanges.length === 0) {
return;
}
setDraft((current) =>
positionChanges.reduce(
(nextDraft, change) => setNodePosition(nextDraft, change.id, change.position),
current,
),
);
setDirty(true);
}, []);
const onCanvasConnect = useCallback((connection: Connection) => {
setDraft((current) => {
const validation = canConnectNodesInDraft(current, connection.source, connection.target);
if (!validation.ok) {
setCanvasFeedbackKey(mapConnectionValidationReasonToKey(validation.reason));
return current;
}
setCanvasFeedbackKey(null);
setDirty(true);
return connectNodesInDraft(current, connection.source, connection.target);
});
}, []);
const onViewportChangeEnd = useCallback((viewport: Viewport) => {
setDraft((current) =>
setViewportInDraft(current, {
x: viewport.x,
y: viewport.y,
zoom: viewport.zoom,
}),
);
setDirty(true);
}, []);
const handleNodeLibraryDragStart = useCallback((event: React.DragEvent<HTMLButtonElement>, nodeId: string) => {
event.dataTransfer.setData(NODE_LIBRARY_MIME, nodeId);
event.dataTransfer.effectAllowed = "copy";
}, []);
const handleCanvasDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!event.dataTransfer.types.includes(NODE_LIBRARY_MIME)) {
return;
}
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
setCanvasDropActive(true);
}, []);
const handleCanvasDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const definitionId = event.dataTransfer.getData(NODE_LIBRARY_MIME);
if (!definitionId) {
setCanvasDropActive(false);
return;
}
event.preventDefault();
const definition = nodeDefinitionsById.get(definitionId);
if (!definition) {
setCanvasDropActive(false);
setCanvasFeedbackKey("invalidConnectionMissingEndpoint");
return;
}
const droppedAt = flowInstance
? flowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY })
: { x: event.clientX, y: event.clientY };
const result = addNodeToDraftAtPosition(draft, definition, droppedAt);
setDraft(result.draft);
setSelectedNodeId(result.nodeId);
setDirty(true);
setCanvasFeedbackKey(null);
setCanvasDropActive(false);
}, [draft, flowInstance, nodeDefinitionsById]);
const handleCanvasDragLeave = useCallback(() => {
setCanvasDropActive(false);
}, []);
function updateSelectedNodeRuntimeConfig(
nextConfig: WorkflowNodeRuntimeConfig | ((current: WorkflowNodeRuntimeConfig) => WorkflowNodeRuntimeConfig),
) {
if (!selectedNodeId) {
return;
}
const currentConfig = getEffectiveNodeRuntimeConfig(
selectedNodeRaw,
getNodeRuntimeConfig(draft, selectedNodeId),
);
const resolved = typeof nextConfig === "function" ? nextConfig(currentConfig) : nextConfig;
setDraft(setNodeRuntimeConfig(draft, selectedNodeId, resolved));
setDirty(true);
}
async function runWorkflowChecks(versionId: string) {
if (!selectedAssetId) {
setPreflightResult(null);
setError(t("selectAssetBeforeRun"));
return null;
}
setPreflightBusy(true);
try {
const result = await props.api.preflightRun({
workflowDefinitionId: props.workflowId,
workflowVersionId: versionId,
assetIds: [selectedAssetId],
});
setPreflightResult(result);
return result;
} catch (preflightError) {
setError(preflightError instanceof Error ? preflightError.message : t("checksBlocked"));
return null;
} finally {
setPreflightBusy(false);
}
}
async function saveCurrentDraft() {
const version = await props.api.saveWorkflowVersion(
props.workflowId,
serializeWorkflowDraft(draft),
);
setVersions((previous) => [version, ...previous.filter((item) => item._id !== version._id)]);
setDirty(false);
if (selectedAssetId) {
await runWorkflowChecks(version._id);
} else {
setPreflightResult(null);
}
return version;
}
useEffect(() => {
if (dirty || !selectedAssetId || versions.length === 0) {
return;
}
void runWorkflowChecks(versions[0]._id);
}, [dirty, selectedAssetId, versions, props.workflowId]);
return (
<div className="page-stack">
<section className="panel">
<div className="toolbar">
<h1 style={{ margin: 0 }}>{workflow?.name ?? t("workflowEditor")}</h1>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span>{t("runAsset")}</span>
<select
value={selectedAssetId ?? ""}
onChange={(event) => setSelectedAssetId(event.target.value || null)}
>
{assets.length === 0 ? <option value="">{t("noAssetsAvailable")}</option> : null}
{assets.map((asset) => (
<option key={asset._id} value={asset._id}>
{asset.displayName}
</option>
))}
</select>
</label>
<button
className="button-secondary"
onClick={async () => {
const latestVersion = dirty || versions.length === 0
? await saveCurrentDraft()
: versions[0];
await runWorkflowChecks(latestVersion._id);
}}
disabled={!selectedAssetId || preflightBusy}
>
{t("runChecks")}
</button>
<button
className="button-primary"
onClick={async () => {
await saveCurrentDraft();
}}
>
{t("saveWorkflowVersion")}
</button>
<button
className="button-secondary"
onClick={async () => {
if (!selectedAssetId) {
setError(t("selectAssetBeforeRun"));
return;
}
const latestVersion = dirty || versions.length === 0
? await saveCurrentDraft()
: versions[0];
const preflight = await runWorkflowChecks(latestVersion._id);
if (!preflight?.ok) {
setError(preflight?.issues[0]?.message ?? t("checksBlocked"));
return;
}
const run = await props.api.createRun({
workflowDefinitionId: props.workflowId,
workflowVersionId: latestVersion._id,
assetIds: [selectedAssetId],
});
setLastRunId(run._id);
}}
disabled={!selectedAssetId}
>
{t("triggerWorkflowRun")}
</button>
<button
className="button-secondary"
onClick={() => {
const latestDraft = workflowDraftFromVersion(versions[0] ?? null);
setDraft(latestDraft);
setSelectedNodeId(latestDraft.logicGraph.nodes[0]?.id ?? "rename-folder");
setDirty(false);
}}
>
{t("reloadLatestSaved")}
</button>
{lastRunId ? <a href={`/runs/${lastRunId}`}>{t("openLatestRun")}</a> : null}
</div>
{error ? <p>{error}</p> : null}
<div style={{ marginTop: 12, display: "grid", gap: 8 }}>
<div className="toolbar">
<strong>{t("workflowChecks")}</strong>
{preflightBusy ? <span>{t("running")}</span> : null}
</div>
{preflightResult ? (
<>
<p>
{preflightResult.ok ? t("checksPassed") : t("checksBlocked")} ·{" "}
{t("checkErrors", { count: preflightResult.summary.errorCount })} ·{" "}
{t("checkWarnings", { count: preflightResult.summary.warningCount })}
</p>
{preflightResult.issues.length > 0 ? (
<ul>
{preflightResult.issues.map((issue, index) => (
<li key={`${issue.code}-${issue.nodeId ?? "global"}-${index}`}>
{issue.message}
{issue.nodeId ? ` (${issue.nodeId})` : ""}
</li>
))}
</ul>
) : null}
</>
) : (
<p className="empty-state">{t("noChecksRunYet")}</p>
)}
</div>
</section>
<section className="editor-layout">
<aside className="panel">
<h2>{t("nodeLibrary")}</h2>
<p className="empty-state">{t("nodeLibraryHint")}</p>
<div className="list-grid">
{localizedNodes.map((node) => (
<button
key={node.id}
className="button-secondary workflow-node-library-item"
draggable
onDragStart={(event) => handleNodeLibraryDragStart(event, node.id)}
onClick={() => {
const rawNode = nodeDefinitionsById.get(node.id) ?? node;
const result = addNodeToDraft(draft, rawNode);
setDraft(result.draft);
setSelectedNodeId(result.nodeId);
setDirty(true);
setCanvasFeedbackKey(null);
}}
>
{node.name}
</button>
))}
</div>
</aside>
<section className="panel workflow-canvas-panel">
<div className="toolbar">
<h2 style={{ margin: 0 }}>{t("canvas")}</h2>
<span className="app-header__label">{t("canvasHint")}</span>
{canvasFeedbackKey ? (
<span className="workflow-canvas-feedback" data-tone="danger">
{t(canvasFeedbackKey)}
</span>
) : null}
</div>
<div
className="workflow-canvas-shell"
data-drop-active={String(canvasDropActive)}
onDragOver={handleCanvasDragOver}
onDrop={handleCanvasDrop}
onDragLeave={handleCanvasDragLeave}
>
{canvasDropActive ? (
<div className="workflow-canvas-drop-hint">{t("dragNodeToCanvas")}</div>
) : null}
<ReactFlow
key={versions[0]?._id ?? workflow?._id ?? "workflow-canvas"}
nodes={canvasNodes}
edges={canvasEdges}
defaultViewport={draft.visualGraph.viewport}
onInit={setFlowInstance}
onNodesChange={onCanvasNodesChange}
onConnect={onCanvasConnect}
onNodeClick={(_event, node) => setSelectedNodeId(node.id)}
onMoveEnd={(_event, viewport) => onViewportChangeEnd(viewport)}
fitView
fitViewOptions={{ padding: 0.22 }}
proOptions={{ hideAttribution: true }}
>
<MiniMap pannable zoomable />
<Controls position="bottom-left" />
<Background
variant={BackgroundVariant.Dots}
gap={18}
size={1}
color="#d4d4d8"
/>
</ReactFlow>
</div>
<div className="workflow-canvas-footer">
<p>
{t("latestSavedVersions")}: {versions.length > 0 ? versions.map((item) => item.versionNumber).join(", ") : t("none")}
</p>
<p>{t("draftStatus")}: {dirty ? t("draftUnsaved") : t("draftSynced")}</p>
</div>
</section>
<aside className="panel">
<h2>{t("nodeConfiguration")}</h2>
{selectedNode ? (
<>
<p><strong>{selectedNode.name}</strong></p>
<p>{selectedNode.description}</p>
<p>{t("category")}: {selectedNode.category}</p>
<p>{t("definition")}: {selectedNodeDefinitionId}</p>
{selectedNodeContract ? (
<>
<p>{t("customNodeInputMode")}: {t(formatCustomNodeInputModeKey(selectedNodeContract.inputMode))}</p>
<p>{t("customNodeOutputMode")}: {t(formatCustomNodeOutputModeKey(selectedNodeContract.outputMode))}</p>
<p>{t("customNodeArtifactType")}: {selectedNodeContract.artifactType ?? t("none")}</p>
<p>{t("customNodeSourceKind")}: {t(formatCustomNodeSourceKindKey(selectedNodeSourceKind))}</p>
{selectedNodeEnvelopePreview ? (
<>
<label style={{ display: "grid", gap: 8 }}>
<span>{t("inputEnvelope")}</span>
<pre>{JSON.stringify(selectedNodeEnvelopePreview.input, null, 2)}</pre>
</label>
<label style={{ display: "grid", gap: 8 }}>
<span>{t("outputEnvelope")}</span>
<pre>{JSON.stringify(selectedNodeEnvelopePreview.output, null, 2)}</pre>
</label>
</>
) : null}
</>
) : null}
<div className="field-grid">
<label>
{t("executorType")}
<select
value={selectedNodeEffectiveRuntimeConfig.executorType ?? "python"}
onChange={(event) =>
updateSelectedNodeRuntimeConfig((current) => ({
...current,
executorType: event.target.value as "python" | "docker" | "http",
}))
}
>
<option value="python">python</option>
<option value="docker">docker</option>
<option value="http">http</option>
</select>
</label>
<label>
{t("runtimeTarget")}
<input
value={
selectedNodeEffectiveRuntimeConfig.executorType === "http"
? String(selectedNodeEffectiveRuntimeConfig.executorConfig?.url ?? "")
: selectedNodeEffectiveRuntimeConfig.executorType === "docker"
? String(
selectedNodeEffectiveRuntimeConfig.executorConfig?.image ??
selectedNodeEffectiveRuntimeConfig.executorConfig?.imageTag ??
"",
)
: ""
}
placeholder={
selectedNodeEffectiveRuntimeConfig.executorType === "http"
? "http://127.0.0.1:3010/mock-executor"
: selectedNodeEffectiveRuntimeConfig.executorType === "docker"
? "python:3.11-alpine"
: "python executor uses inline hook or default"
}
onChange={(event) =>
updateSelectedNodeRuntimeConfig((current) => ({
...current,
executorConfig:
current.executorType === "http"
? {
...(current.executorConfig ?? {}),
url: event.target.value,
method: "POST",
}
: current.executorType === "docker"
? {
...(current.executorConfig ?? {}),
image: event.target.value,
}
: current.executorConfig,
}))
}
/>
</label>
<label>
{t("artifactTitle")}
<input
value={selectedNodeRuntimeConfig?.artifactTitle ?? ""}
placeholder="Task Result: validate-structure"
onChange={(event) =>
updateSelectedNodeRuntimeConfig((current) => ({
...current,
artifactTitle: event.target.value,
}))
}
/>
</label>
</div>
{selectedNodeSupportsCodeHook ? (
<label style={{ display: "grid", gap: 8 }}>
<span>{t("pythonCodeHook")}</span>
<textarea
rows={8}
value={selectedNodeRuntimeConfig?.codeHookSpec?.source ?? ""}
placeholder={[
"def process(task, context):",
" return {'nodeId': task['nodeId'], 'ok': True}",
].join("\n")}
onChange={(event) =>
updateSelectedNodeRuntimeConfig((current) => ({
...current,
codeHookSpec: event.target.value.trim().length > 0
? {
language: "python",
entrypoint: "process",
source: event.target.value,
}
: undefined,
}))
}
/>
</label>
) : (
<p className="empty-state">{t("nodeNoHook")}</p>
)}
{selectedNodeId ? (
<button
className="button-secondary"
style={{ marginTop: 12 }}
onClick={() => {
const next = removeNodeFromDraft(draft, selectedNodeId);
setDraft(next);
setSelectedNodeId(next.logicGraph.nodes[0]?.id ?? "");
setDirty(true);
}}
>
{t("removeNode")}
</button>
) : null}
</>
) : (
<p className="empty-state">{t("selectNode")}</p>
)}
<div className="template-save-section">
<h3>{t("saveAsTemplate")}</h3>
<div className="field-grid">
<label>
{t("templateName")}
<input
value={templateName}
onChange={(event) => setTemplateName(event.target.value)}
placeholder={`${workflow?.name ?? "Workflow"} Template`}
/>
</label>
<label>
{t("templateDescription")}
<textarea
rows={4}
value={templateDescription}
onChange={(event) => setTemplateDescription(event.target.value)}
placeholder="Reusable project workflow for delivery normalization"
/>
</label>
</div>
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
disabled={!workflow || templateName.trim().length === 0}
onClick={async () => {
if (!workflow) {
return;
}
try {
setError(null);
const templatePayload = serializeWorkflowDraft(draft);
const template = await props.api.createWorkflowTemplate({
workspaceId: workflow.workspaceId,
projectId: workflow.projectId,
name: templateName.trim(),
description: templateDescription.trim() || undefined,
visualGraph: templatePayload.visualGraph,
logicGraph: templatePayload.logicGraph,
runtimeGraph: templatePayload.runtimeGraph,
pluginRefs: templatePayload.pluginRefs,
createdBy: workflow.createdBy ?? "local-user",
});
setSavedTemplateName(template.name);
} catch (createError) {
setError(
createError instanceof Error
? createError.message
: t("failedCreateTemplate"),
);
}
}}
>
{t("saveAsTemplate")}
</button>
</div>
{savedTemplateName ? (
<p className="empty-state">
{t("templateSaved")}: {savedTemplateName}
</p>
) : null}
</div>
</aside>
</section>
</div>
);
}
function RunsIndexPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
}) {
const { t } = useI18n();
const [runs, setRuns] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const load = async () => {
try {
const nextRuns = await props.api.listRuns({
projectId: props.bootstrap.project._id,
});
if (!cancelled) {
setRuns(nextRuns);
setError(null);
timer = setTimeout(() => {
void load();
}, 1500);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadRuns"));
}
}
};
void load();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [props.api, props.bootstrap.project._id]);
return (
<div className="page-stack">
<section className="panel">
<h1>{t("runsTitle")}</h1>
<p>{t("runsDescription")}</p>
{error ? <p>{error}</p> : null}
</section>
<section className="panel">
<div className="list-grid">
{runs.length === 0 ? (
<p className="empty-state">{t("noRunsYet")}</p>
) : (
runs.map((run) => (
<article key={run._id} className="asset-card">
<a href={`/runs/${run._id}`}>
<strong>{run.workflowName ?? run.workflowDefinitionId}</strong>
</a>
<p>{t("status")}: {translateStatus(run.status, t)}</p>
<p>{t("inputAssets")}: {(run.assetIds ?? []).join(", ") || t("none")}</p>
<p>{t("createdAt")}: {run.createdAt}</p>
</article>
))
)}
</div>
</section>
</div>
);
}
function RunDetailPage(props: {
api: ApiClient;
runId: string;
}) {
const { t } = useI18n();
const [run, setRun] = useState<any | null>(null);
const [tasks, setTasks] = useState<any[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [artifacts, setArtifacts] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const load = async () => {
try {
const [runData, taskData] = await Promise.all([
props.api.getRun(props.runId),
props.api.listRunTasks(props.runId),
]);
if (cancelled) {
return;
}
setRun(runData);
setTasks(taskData);
setError(null);
if (runData.status === "queued" || runData.status === "running") {
timer = setTimeout(() => {
void load();
}, 1000);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadRunDetail"));
}
}
};
void load();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [props.api, props.runId, refreshKey]);
useEffect(() => {
if (tasks.length === 0) {
setSelectedTaskId(null);
return;
}
if (selectedTaskId && tasks.some((task) => task._id === selectedTaskId)) {
return;
}
setSelectedTaskId(tasks[0]?._id ?? null);
}, [tasks, selectedTaskId]);
const selectedTask = useMemo(
() => tasks.find((task) => task._id === selectedTaskId) ?? tasks[0] ?? null,
[tasks, selectedTaskId],
);
useEffect(() => {
if (!selectedTask?._id) {
setArtifacts([]);
return;
}
void (async () => {
try {
setArtifacts(await props.api.listArtifactsByProducer("run_task", selectedTask._id));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadTaskArtifacts"));
}
})();
}, [props.api, selectedTask?._id, (selectedTask?.outputArtifactIds ?? []).join(","), t]);
if (error) {
return <section className="panel">{error}</section>;
}
if (!run) {
return <section className="panel">{t("loadingRun")}</section>;
}
return (
<div className="page-stack">
<section className="panel">
<h1>{t("runDetail")}</h1>
<p>Run ID: {run._id}</p>
<p>{t("workflow")}: {run.workflowName ?? run.workflowDefinitionId}</p>
<p>{t("status")}: {translateStatus(run.status, t)}</p>
<p>
{t("inputAssets")}:{" "}
{(run.assetIds ?? []).length > 0
? run.assetIds.map((assetId: string) => assetId).join(", ")
: t("none")}
</p>
<p>{t("startedAt")}: {run.startedAt ?? t("notAvailable")}</p>
<p>{t("finishedAt")}: {run.finishedAt ?? t("notAvailable")}</p>
<p>{t("runDuration")}: {typeof run.durationMs === "number" ? `${run.durationMs} ms` : t("notAvailable")}</p>
<p>{t("runSummary")}: {formatRunSummary(run, t)}</p>
<div className="button-row" style={{ marginTop: 12 }}>
{run.status === "queued" || run.status === "running" ? (
<button
className="button-secondary"
onClick={async () => {
try {
setError(null);
await props.api.cancelRun(run._id);
setRefreshKey((value) => value + 1);
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : t("failedCancelRun"));
}
}}
>
{t("cancelRun")}
</button>
) : null}
{run.status !== "queued" && run.status !== "running" ? (
<button
className="button-primary"
onClick={async () => {
try {
setError(null);
const retriedRun = await props.api.retryRun(run._id);
window.history.pushState({}, "", `/runs/${retriedRun._id}`);
window.dispatchEvent(new PopStateEvent("popstate"));
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : t("failedRetryRun"));
}
}}
>
{t("retryRun")}
</button>
) : null}
</div>
</section>
<section className="two-column">
<div className="panel">
<h2>{t("runGraph")}</h2>
<div className="list-grid">
{tasks.map((task) => (
<article
key={task._id}
className="task-card"
data-selected={String(selectedTask?._id === task._id)}
onClick={() => setSelectedTaskId(task._id)}
style={{ cursor: "pointer" }}
>
<div className="toolbar">
<strong>{task.nodeId}</strong>
<span className="status-pill" data-status={task.status}>
{translateStatus(task.status, t)}
</span>
</div>
<p>{t("type")}: {task.nodeType}</p>
<p>{t("boundAssets")}: {(task.assetIds ?? []).join(", ") || t("none")}</p>
</article>
))}
</div>
</div>
<aside className="panel">
<h2>{t("selectedTask")}</h2>
{selectedTask ? (
<>
<p>Node: {selectedTask.nodeId}</p>
<p>{t("status")}: {translateStatus(selectedTask.status, t)}</p>
<p>{t("definition")}: {selectedTask.nodeDefinitionId ?? t("notAvailable")}</p>
<p>{t("executor")}: {selectedTask.executorType}</p>
<p>{t("executorConfig")}: {formatExecutorConfigLabel(selectedTask.executorConfig)}</p>
<p>
{t("codeHook")}:{" "}
{selectedTask.codeHookSpec
? `${selectedTask.codeHookSpec.language}:${selectedTask.codeHookSpec.entrypoint ?? "process"}`
: t("none")}
</p>
<p>{t("inputAssets")}: {(selectedTask.assetIds ?? []).join(", ") || t("none")}</p>
<p>{t("startedAt")}: {selectedTask.startedAt ?? t("notAvailable")}</p>
<p>{t("finishedAt")}: {selectedTask.finishedAt ?? t("notAvailable")}</p>
<p>
{t("duration")}:{" "}
{typeof selectedTask.durationMs === "number"
? `${selectedTask.durationMs} ms`
: t("notAvailable")}
</p>
<p>{t("summary")}: {formatTaskSummary(selectedTask, t)}</p>
{selectedTask.errorMessage ? <p>{t("error")}: {selectedTask.errorMessage}</p> : null}
{selectedTask.status === "failed" || selectedTask.status === "cancelled" ? (
<div className="button-row" style={{ marginTop: 12 }}>
<button
className="button-primary"
onClick={async () => {
try {
setError(null);
await props.api.retryRunTask(run._id, selectedTask._id);
setRefreshKey((value) => value + 1);
} catch (actionError) {
setError(
actionError instanceof Error
? actionError.message
: t("failedRetryTask"),
);
}
}}
>
{t("retryTask")}
</button>
</div>
) : null}
<p>{t("artifacts")}: {artifacts.length}</p>
{artifacts.length > 0 ? (
<ul>
{artifacts.map((artifact) => (
<li key={artifact._id}>
<a href={`/explore/${artifact._id}`}>{artifact.title ?? artifact._id}</a>
</li>
))}
</ul>
) : (
<p className="empty-state">{t("noTaskArtifactsYet")}</p>
)}
<div className="page-stack">
<div>
<strong>{t("stdout")}</strong>
{(selectedTask.stdoutLines ?? []).length > 0 ? (
<ul>
{(selectedTask.stdoutLines ?? []).map((line: string, index: number) => (
<li key={`${selectedTask._id}-stdout-${index}`}>{line}</li>
))}
</ul>
) : (
<p className="empty-state">{t("noStdout")}</p>
)}
</div>
<div>
<strong>{t("stderr")}</strong>
{(selectedTask.stderrLines ?? []).length > 0 ? (
<ul>
{(selectedTask.stderrLines ?? []).map((line: string, index: number) => (
<li key={`${selectedTask._id}-stderr-${index}`}>{line}</li>
))}
</ul>
) : (
<p className="empty-state">{t("noStderr")}</p>
)}
</div>
<div>
<strong>{t("executionLog")}</strong>
{(selectedTask.logLines ?? []).length > 0 ? (
<ul>
{(selectedTask.logLines ?? []).map((line: string, index: number) => (
<li key={`${selectedTask._id}-log-${index}`}>{line}</li>
))}
</ul>
) : (
<p className="empty-state">{t("noTaskLogs")}</p>
)}
</div>
{selectedTask.lastResultPreview ? (
<div>
<strong>{t("resultPreview")}</strong>
<pre className="mono-block">
{JSON.stringify(selectedTask.lastResultPreview, null, 2)}
</pre>
</div>
) : null}
</div>
<pre className="mono-block">{JSON.stringify(selectedTask, null, 2)}</pre>
</>
) : (
<p className="empty-state">{t("noTasksCreated")}</p>
)}
</aside>
</section>
</div>
);
}
function ExplorePage(props: {
api: ApiClient;
artifactId: string | null;
}) {
const { t } = useI18n();
const [artifact, setArtifact] = useState<any | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!props.artifactId) {
return;
}
void (async () => {
try {
setArtifact(await props.api.getArtifact(props.artifactId!));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : t("failedLoadArtifact"));
}
})();
}, [props.artifactId, props.api, t]);
if (!props.artifactId) {
return (
<section className="panel">
<h1>{t("exploreTitle")}</h1>
<p className="empty-state">{t("exploreEmpty")}</p>
</section>
);
}
if (error) {
return <section className="panel">{error}</section>;
}
if (!artifact) {
return <section className="panel">{t("loadingArtifact")}</section>;
}
return (
<div className="page-stack">
<section className="panel">
<h1>{artifact.title}</h1>
<p>Artifact ID: {artifact._id}</p>
<p>{t("type")}: {artifact.type}</p>
<pre className="mono-block">{JSON.stringify(artifact.payload, null, 2)}</pre>
</section>
</div>
);
}
export function App(props: AppProps) {
const { t } = useI18n();
const api = useMemo(() => new ApiClient(props.apiBaseUrl), [props.apiBaseUrl]);
const pathname = usePathname();
const [bootstrap, setBootstrap] = useState<BootstrapContext | null>(null);
const [projects, setProjects] = useState<ProjectSummary[]>([]);
const [activeProjectId, setActiveProjectId] = useState("");
const [error, setError] = useState<string | null>(null);
const syncProjects = useCallback(
async (context: BootstrapContext, preferredProjectId?: string) => {
const nextProjects = (await api.listProjects(context.workspace._id)) as ProjectSummary[];
setProjects(nextProjects);
const storageKey = getActiveProjectStorageKey(context.workspace._id);
const storedProjectId =
typeof window === "undefined" ? null : window.localStorage.getItem(storageKey);
const resolvedProject =
nextProjects.find((project) => project._id === preferredProjectId) ??
nextProjects.find((project) => project._id === storedProjectId) ??
nextProjects.find((project) => project._id === context.project._id) ??
nextProjects[0];
const resolvedProjectId = resolvedProject?._id ?? context.project._id;
setActiveProjectId(resolvedProjectId);
if (typeof window !== "undefined") {
window.localStorage.setItem(storageKey, resolvedProjectId);
}
},
[api],
);
useEffect(() => {
void (async () => {
try {
const context = await api.bootstrapDev();
setBootstrap(context);
await syncProjects(context, context.project._id);
} catch (bootstrapError) {
setError(
bootstrapError instanceof Error ? bootstrapError.message : t("failedBootstrap"),
);
}
})();
}, [api, syncProjects, t]);
if (error) {
return <section className="panel">{error}</section>;
}
if (!bootstrap) {
return <section className="panel">{t("bootstrappingLocalWorkspace")}</section>;
}
const activeProject =
projects.find((project) => project._id === activeProjectId) ?? {
_id: bootstrap.project._id,
name: bootstrap.project.name,
};
const activeBootstrap: BootstrapContext = {
...bootstrap,
project: {
_id: activeProject._id,
name: activeProject.name,
},
};
const handleProjectSelected = (projectId: string, nextPath?: string) => {
setActiveProjectId(projectId);
if (typeof window !== "undefined") {
window.localStorage.setItem(
getActiveProjectStorageKey(activeBootstrap.workspace._id),
projectId,
);
}
if (nextPath) {
navigateTo(nextPath);
return;
}
navigateTo(normalizePathnameForProjectSwitch(pathname));
};
const handleProjectCreated = async (project: ProjectSummary) => {
if (!bootstrap) {
return;
}
await syncProjects(bootstrap, project._id);
navigateTo("/workflows");
};
const assetMatch = pathname.match(/^\/assets\/([^/]+)$/);
const workflowMatch = pathname.match(/^\/workflows\/([^/]+)$/);
const runMatch = pathname.match(/^\/runs\/([^/]+)$/);
const exploreMatch = pathname.match(/^\/explore\/([^/]+)$/);
let active: NavItem = "Projects";
let content: React.ReactNode = (
<ProjectsPage
api={api}
bootstrap={activeBootstrap}
projects={projects}
activeProjectId={activeProject._id}
onProjectCreated={handleProjectCreated}
onProjectSelected={handleProjectSelected}
/>
);
if (pathname === "/workflows") {
active = "Workflows";
content = <WorkflowsPage api={api} bootstrap={activeBootstrap} />;
} else if (pathname === "/nodes") {
active = "Nodes";
content = <NodesPage api={api} bootstrap={activeBootstrap} />;
} else if (workflowMatch) {
active = "Workflows";
content = <WorkflowEditorPage api={api} workflowId={workflowMatch[1]} />;
} else if (pathname === "/runs") {
active = "Runs";
content = <RunsIndexPage api={api} bootstrap={activeBootstrap} />;
} else if (runMatch) {
active = "Runs";
content = <RunDetailPage api={api} runId={runMatch[1]} />;
} else if (exploreMatch) {
active = "Explore";
content = <ExplorePage api={api} artifactId={exploreMatch[1]} />;
} else if (pathname === "/explore") {
active = "Explore";
content = <ExplorePage api={api} artifactId={null} />;
} else if (assetMatch) {
active = "Assets";
content = <AssetDetailPage api={api} assetId={assetMatch[1]} />;
} else if (pathname === "/assets") {
active = "Assets";
content = <AssetsPage api={api} bootstrap={activeBootstrap} />;
}
return (
<AppShell
workspaceName={activeBootstrap.workspace.name}
projectName={activeBootstrap.project.name}
projectControl={
<select
className="app-header__select"
value={activeProject._id}
onChange={(event) => handleProjectSelected(event.target.value)}
>
{(projects.length > 0 ? projects : [activeProject]).map((project) => (
<option key={project._id} value={project._id}>
{project.name}
</option>
))}
</select>
}
active={active}
>
{content}
</AppShell>
);
}