2571 lines
93 KiB
TypeScript
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>
|
|
);
|
|
}
|