2026-03-26 21:38:55 +08:00

737 lines
23 KiB
TypeScript

import React, { useEffect, useMemo, useState } from "react";
import { ApiClient } from "./api-client.ts";
import {
addNodeToDraft,
createDefaultWorkflowDraft,
removeNodeFromDraft,
resolveDefinitionIdForNode,
serializeWorkflowDraft,
workflowDraftFromVersion,
type WorkflowDraft,
} from "./workflow-editor-state.ts";
type NavItem = "Assets" | "Workflows" | "Runs" | "Explore" | "Labels" | "Admin";
type BootstrapContext = {
userId: string;
workspace: { _id: string; name: string };
project: { _id: string; name: string };
};
type AppProps = {
apiBaseUrl: string;
};
function usePathname() {
const [pathname, setPathname] = useState(
typeof window === "undefined" ? "/assets" : window.location.pathname || "/assets",
);
useEffect(() => {
const handle = () => setPathname(window.location.pathname || "/assets");
window.addEventListener("popstate", handle);
return () => window.removeEventListener("popstate", handle);
}, []);
return pathname === "/" ? "/assets" : pathname;
}
function AppShell(props: {
workspaceName: string;
projectName: string;
active: NavItem;
children: React.ReactNode;
}) {
const navItems: Array<{ label: NavItem; href: string }> = [
{ label: "Assets", href: "/assets" },
{ label: "Workflows", href: "/workflows" },
{ label: "Runs", href: "/runs" },
{ label: "Explore", href: "/explore" },
{ label: "Labels", href: "/labels" },
{ label: "Admin", href: "/admin" },
];
return (
<div className="app-shell">
<header className="app-header">
<div className="app-header__group">
<div className="app-header__pill">
<span className="app-header__label">Workspace</span>
<strong>{props.workspaceName}</strong>
</div>
<div className="app-header__pill">
<span className="app-header__label">Project</span>
<strong>{props.projectName}</strong>
</div>
</div>
<div className="app-header__group">
<span className="app-header__label">Runs</span>
<span className="app-header__label">Local Dev</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)}>
{item.label}
</a>
</li>
))}
</ul>
</aside>
<main className="app-main">{props.children}</main>
</div>
);
}
function AssetsPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
}) {
const [sourcePath, setSourcePath] = useState("");
const [assets, setAssets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const loadAssets = async () => {
try {
setAssets(await props.api.listAssets(props.bootstrap.project._id));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load assets");
}
};
useEffect(() => {
void loadAssets();
}, [props.bootstrap.project._id]);
return (
<div className="page-stack">
<section className="panel">
<h1>Assets</h1>
<p>Register local folders, archives, or dataset files, then probe them into managed asset metadata.</p>
<div className="field-grid">
<label>
Local Path
<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 loadAssets();
} catch (registerError) {
setError(
registerError instanceof Error
? registerError.message
: "Failed to register local asset",
);
}
}}
>
Register Local Path
</button>
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="panel">
<div className="list-grid">
{assets.length === 0 ? (
<p className="empty-state">No assets have been registered yet.</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}>
{asset.status}
</span>
</div>
<p>Type: {asset.type}</p>
<p>Source: {asset.sourceType}</p>
<p>Detected: {(asset.detectedFormats ?? []).join(", ") || "pending"}</p>
<p>Top-level entries: {(asset.topLevelPaths ?? []).slice(0, 6).join(", ") || "n/a"}</p>
</article>
))
)}
</div>
</section>
</div>
);
}
function AssetDetailPage(props: {
api: ApiClient;
assetId: string;
}) {
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">Loading asset detail...</section>;
}
return (
<div className="page-stack">
<section className="two-column">
<div className="panel">
<h1>{asset.displayName}</h1>
<p>Asset ID: {asset._id}</p>
<p>Type: {asset.type}</p>
<p>Status: {asset.status}</p>
<p>Source path: {asset.sourcePath ?? "n/a"}</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);
}}
>
Probe Again
</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);
}}
>
Create Explore Artifact
</button>
{artifactId ? <a href={`/explore/${artifactId}`}>Open Explore View</a> : null}
</div>
</div>
<aside className="panel">
<h2>Probe Summary</h2>
{probeReport ? (
<>
<p>Detected: {(probeReport.detectedFormatCandidates ?? []).join(", ")}</p>
<p>Warnings: {(probeReport.warnings ?? []).join(", ") || "none"}</p>
<p>
Recommended nodes: {(probeReport.recommendedNextNodes ?? []).join(", ") || "inspect_asset"}
</p>
<pre className="mono-block">
{JSON.stringify(probeReport.structureSummary ?? {}, null, 2)}
</pre>
</>
) : (
<p className="empty-state">No probe report yet.</p>
)}
</aside>
</section>
</div>
);
}
function WorkflowsPage(props: {
api: ApiClient;
bootstrap: BootstrapContext;
}) {
const [workflows, setWorkflows] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
setWorkflows(await props.api.listWorkflows(props.bootstrap.project._id));
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load workflows");
}
};
useEffect(() => {
void load();
}, [props.bootstrap.project._id]);
return (
<div className="page-stack">
<section className="panel">
<div className="toolbar">
<h1 style={{ margin: 0 }}>Workflows</h1>
<button
className="button-primary"
onClick={async () => {
await props.api.createWorkflow({
workspaceId: props.bootstrap.workspace._id,
projectId: props.bootstrap.project._id,
name: `Delivery Normalize ${workflows.length + 1}`,
});
await load();
}}
>
Create Workflow
</button>
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="panel">
<div className="list-grid">
{workflows.length === 0 ? (
<p className="empty-state">No workflows yet.</p>
) : (
workflows.map((workflow) => (
<article key={workflow._id} className="asset-card">
<a href={`/workflows/${workflow._id}`}>
<strong>{workflow.name}</strong>
</a>
<p>Status: {workflow.status}</p>
<p>Latest version: {workflow.latestVersionNumber}</p>
</article>
))
)}
</div>
</section>
</div>
);
}
function WorkflowEditorPage(props: {
api: ApiClient;
workflowId: string;
}) {
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);
useEffect(() => {
void (async () => {
try {
const workflowDefinition = await props.api.getWorkflowDefinition(props.workflowId);
const [nodeDefs, workflowVersions, workflowAssets] = await Promise.all([
props.api.listNodeDefinitions(),
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");
setDirty(false);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load workflow");
}
})();
}, [props.workflowId]);
const selectedNode = useMemo(
() =>
nodes.find((node) => node.id === resolveDefinitionIdForNode(draft, selectedNodeId)) ?? null,
[draft, nodes, selectedNodeId],
);
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);
return version;
}
return (
<div className="page-stack">
<section className="panel">
<div className="toolbar">
<h1 style={{ margin: 0 }}>{workflow?.name ?? "Workflow Editor"}</h1>
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span>Run Asset</span>
<select
value={selectedAssetId ?? ""}
onChange={(event) => setSelectedAssetId(event.target.value || null)}
>
{assets.length === 0 ? <option value="">No assets available</option> : null}
{assets.map((asset) => (
<option key={asset._id} value={asset._id}>
{asset.displayName}
</option>
))}
</select>
</label>
<button
className="button-primary"
onClick={async () => {
await saveCurrentDraft();
}}
>
Save Workflow Version
</button>
<button
className="button-secondary"
onClick={async () => {
if (!selectedAssetId) {
setError("Select an asset before triggering a workflow run.");
return;
}
const latestVersion = dirty || versions.length === 0
? await saveCurrentDraft()
: versions[0];
const run = await props.api.createRun({
workflowDefinitionId: props.workflowId,
workflowVersionId: latestVersion._id,
assetIds: [selectedAssetId],
});
setLastRunId(run._id);
}}
disabled={!selectedAssetId}
>
Trigger Workflow Run
</button>
<button
className="button-secondary"
onClick={() => {
const latestDraft = workflowDraftFromVersion(versions[0] ?? null);
setDraft(latestDraft);
setSelectedNodeId(latestDraft.logicGraph.nodes[0]?.id ?? "rename-folder");
setDirty(false);
}}
>
Reload Latest Saved
</button>
{lastRunId ? <a href={`/runs/${lastRunId}`}>Open Latest Run</a> : null}
</div>
{error ? <p>{error}</p> : null}
</section>
<section className="editor-layout">
<aside className="panel">
<h2>Node Library</h2>
<div className="list-grid">
{nodes.map((node) => (
<button
key={node.id}
className="button-secondary"
onClick={() => {
const result = addNodeToDraft(draft, node);
setDraft(result.draft);
setSelectedNodeId(result.nodeId);
setDirty(true);
}}
>
{node.name}
</button>
))}
</div>
</aside>
<section className="panel">
<h2>Canvas</h2>
<div className="list-grid">
{draft.logicGraph.nodes.map((node) => (
<div
key={node.id}
className="node-card"
data-selected={String(selectedNodeId === node.id)}
onClick={() => setSelectedNodeId(node.id)}
style={{ cursor: "pointer" }}
>
<strong>{node.id}</strong>
<p>Type: {node.type}</p>
<p>Definition: {resolveDefinitionIdForNode(draft, node.id)}</p>
<button
className="button-secondary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
const next = removeNodeFromDraft(draft, node.id);
setDraft(next);
setSelectedNodeId(next.logicGraph.nodes[0]?.id ?? "");
setDirty(true);
}}
>
Remove Node
</button>
</div>
))}
</div>
<p style={{ marginTop: 12 }}>
Latest saved versions: {versions.length > 0 ? versions.map((item) => item.versionNumber).join(", ") : "none"}
</p>
<p>Draft status: {dirty ? "unsaved changes" : "synced"}</p>
</section>
<aside className="panel">
<h2>Node Configuration</h2>
{selectedNode ? (
<>
<p><strong>{selectedNode.name}</strong></p>
<p>{selectedNode.description}</p>
<p>Category: {selectedNode.category}</p>
</>
) : (
<p className="empty-state">Select a node.</p>
)}
</aside>
</section>
</div>
);
}
function RunsIndexPage() {
return (
<section className="panel">
<h1>Runs</h1>
<p className="empty-state">Open a specific run from the workflow editor.</p>
</section>
);
}
function RunDetailPage(props: {
api: ApiClient;
runId: string;
}) {
const [run, setRun] = useState<any | null>(null);
const [tasks, setTasks] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const [runData, taskData] = await Promise.all([
props.api.getRun(props.runId),
props.api.listRunTasks(props.runId),
]);
setRun(runData);
setTasks(taskData);
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : "Failed to load run detail");
}
})();
}, [props.runId]);
if (error) {
return <section className="panel">{error}</section>;
}
if (!run) {
return <section className="panel">Loading run...</section>;
}
return (
<div className="page-stack">
<section className="panel">
<h1>Run Detail</h1>
<p>Run ID: {run._id}</p>
<p>Status: {run.status}</p>
<p>
Input assets:{" "}
{(run.assetIds ?? []).length > 0
? run.assetIds.map((assetId: string) => assetId).join(", ")
: "none"}
</p>
</section>
<section className="two-column">
<div className="panel">
<h2>Run Graph</h2>
<div className="list-grid">
{tasks.map((task) => (
<article key={task._id} className="task-card">
<div className="toolbar">
<strong>{task.nodeId}</strong>
<span className="status-pill" data-status={task.status}>
{task.status}
</span>
</div>
<p>Node type: {task.nodeType}</p>
<p>Bound assets: {(task.assetIds ?? []).join(", ") || "none"}</p>
</article>
))}
</div>
</div>
<aside className="panel">
<h2>Selected Task</h2>
{tasks[0] ? (
<>
<p>Node: {tasks[0].nodeId}</p>
<p>Status: {tasks[0].status}</p>
<pre className="mono-block">{JSON.stringify(tasks[0], null, 2)}</pre>
</>
) : (
<p className="empty-state">No tasks created.</p>
)}
</aside>
</section>
</div>
);
}
function ExplorePage(props: {
api: ApiClient;
artifactId: string | null;
}) {
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 : "Failed to load artifact");
}
})();
}, [props.artifactId]);
if (!props.artifactId) {
return (
<section className="panel">
<h1>Explore</h1>
<p className="empty-state">Create an artifact from asset detail to inspect it here.</p>
</section>
);
}
if (error) {
return <section className="panel">{error}</section>;
}
if (!artifact) {
return <section className="panel">Loading artifact...</section>;
}
return (
<div className="page-stack">
<section className="panel">
<h1>{artifact.title}</h1>
<p>Artifact ID: {artifact._id}</p>
<p>Type: {artifact.type}</p>
<pre className="mono-block">{JSON.stringify(artifact.payload, null, 2)}</pre>
</section>
</div>
);
}
export function App(props: AppProps) {
const api = useMemo(() => new ApiClient(props.apiBaseUrl), [props.apiBaseUrl]);
const pathname = usePathname();
const [bootstrap, setBootstrap] = useState<BootstrapContext | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
setBootstrap(await api.bootstrapDev());
} catch (bootstrapError) {
setError(
bootstrapError instanceof Error ? bootstrapError.message : "Failed to bootstrap local context",
);
}
})();
}, [api]);
if (error) {
return <section className="panel">{error}</section>;
}
if (!bootstrap) {
return <section className="panel">Bootstrapping local workspace...</section>;
}
const assetMatch = pathname.match(/^\/assets\/([^/]+)$/);
const workflowMatch = pathname.match(/^\/workflows\/([^/]+)$/);
const runMatch = pathname.match(/^\/runs\/([^/]+)$/);
const exploreMatch = pathname.match(/^\/explore\/([^/]+)$/);
let active: NavItem = "Assets";
let content: React.ReactNode = <AssetsPage api={api} bootstrap={bootstrap} />;
if (pathname === "/workflows") {
active = "Workflows";
content = <WorkflowsPage api={api} bootstrap={bootstrap} />;
} else if (workflowMatch) {
active = "Workflows";
content = <WorkflowEditorPage api={api} workflowId={workflowMatch[1]} />;
} else if (pathname === "/runs") {
active = "Runs";
content = <RunsIndexPage />;
} 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]} />;
}
return (
<AppShell
workspaceName={bootstrap.workspace.name}
projectName={bootstrap.project.name}
active={active}
>
{content}
</AppShell>
);
}