diff --git a/burr/tracking/server/run.py b/burr/tracking/server/run.py index 2e9759659..45938ebbf 100644 --- a/burr/tracking/server/run.py +++ b/burr/tracking/server/run.py @@ -32,6 +32,7 @@ IndexingBackendMixin, SnapshottingBackendMixin, ) +from burr.tracking.server import workspace setup_logging(logging.INFO) @@ -137,6 +138,7 @@ async def lifespan(app: FastAPI): global initialized initialized = True yield + await workspace.cleanup_processes() await backend.lifespan(app).__anext__() @@ -151,6 +153,7 @@ def _get_app_spec() -> BackendSpec: snapshotting=is_snapshotting_backend, supports_demos=supports_demos, supports_annotations=is_annotations_backend, + supports_workspace=True, ) @@ -342,6 +345,7 @@ async def version() -> dict: burr_version = "unknown" return {"version": burr_version} + ui_app.include_router(workspace.router, prefix="/api/v0/workspace") # Examples -- todo -- put them behind `if` statements ui_app.include_router(chatbot.router, prefix="/api/v0/chatbot") ui_app.include_router(email_assistant.router, prefix="/api/v0/email_assistant") diff --git a/burr/tracking/server/schema.py b/burr/tracking/server/schema.py index 749f1e3a2..78b277f8d 100644 --- a/burr/tracking/server/schema.py +++ b/burr/tracking/server/schema.py @@ -200,6 +200,7 @@ class BackendSpec(pydantic.BaseModel): snapshotting: bool supports_demos: bool supports_annotations: bool + supports_workspace: bool class AnnotationDataPointer(pydantic.BaseModel): diff --git a/burr/tracking/server/workspace.py b/burr/tracking/server/workspace.py new file mode 100644 index 000000000..9d3f5c945 --- /dev/null +++ b/burr/tracking/server/workspace.py @@ -0,0 +1,583 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import asyncio +import json as json_module +import logging +import os +import re +import signal +import time +from typing import Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +router = APIRouter() + +MAX_FILE_SIZE = 1_048_576 # 1MB + +LANGUAGE_MAP = { + ".py": "python", + ".js": "javascript", + ".ts": "typescript", + ".tsx": "tsx", + ".jsx": "jsx", + ".json": "json", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".md": "markdown", + ".html": "html", + ".css": "css", + ".sql": "sql", + ".sh": "bash", + ".txt": "text", +} + +BURR_APP_PATTERN = re.compile(r"ApplicationBuilder") + + +# --- Pydantic Models --- + + +class WorkspaceLinkRequest(BaseModel): + project_id: str + workspace_path: str + + +class WorkspaceLinkInfo(BaseModel): + project_id: str + workspace_path: Optional[str] + + +class BuilderProjectSave(BaseModel): + name: str + graph_json: str # JSON-serialized tree + + +class BuilderProjectSummary(BaseModel): + id: str + name: str + updated_at: float + + +class BuilderProjectFull(BaseModel): + id: str + name: str + graph_json: str + updated_at: float + + +class WorkspaceOpenRequest(BaseModel): + path: str + + +class WorkspaceInfo(BaseModel): + path: str + name: str + + +class FileEntry(BaseModel): + name: str + path: str + is_dir: bool + size: int + modified: float + is_python: bool + has_burr_app: bool + + +class FileContent(BaseModel): + path: str + content: str + language: str + size: int + + +class ProcessInfo(BaseModel): + pid: int + script_path: str + started_at: float + status: str # "running" | "stopped" | "exited" + exit_code: Optional[int] + + +class RunRequest(BaseModel): + workspace: str + script: str + + +# --- Security --- + + +def _validate_path(workspace: str, relative: str) -> str: + workspace_real = os.path.realpath(workspace) + if relative: + target = os.path.realpath(os.path.join(workspace_real, relative)) + else: + target = workspace_real + # Use os.sep suffix to prevent /foo matching /foobar + if target != workspace_real and not target.startswith(workspace_real + os.sep): + raise HTTPException(status_code=403, detail="Path traversal detected") + return target + + +def _validate_workspace(workspace: str) -> str: + """Validate workspace is a registered link or the open endpoint validated it.""" + workspace_real = os.path.realpath(workspace) + if not os.path.isdir(workspace_real): + raise HTTPException(status_code=400, detail="Workspace directory does not exist") + links = _read_links() + allowed = {os.path.realpath(p) for p in links.values()} + if workspace_real not in allowed: + raise HTTPException(status_code=403, detail="Workspace not registered") + return workspace_real + + +def _is_binary(file_path: str) -> bool: + try: + with open(file_path, "rb") as f: + chunk = f.read(8192) + return b"\x00" in chunk + except OSError: + return True + + +# --- Workspace Links --- + +_LINKS_PATH = os.path.join(os.path.expanduser("~/.burr"), "workspace_links.json") + + +def _read_links() -> dict: + if os.path.exists(_LINKS_PATH): + with open(_LINKS_PATH, "r") as f: + return json_module.load(f) + return {} + + +def _write_links(data: dict): + os.makedirs(os.path.dirname(_LINKS_PATH), exist_ok=True) + with open(_LINKS_PATH, "w") as f: + json_module.dump(data, f, indent=2) + + +# --- Process Management --- +# Uses asyncio.create_subprocess_exec which takes explicit argv (no shell injection). + +_processes: Dict[int, dict] = {} + + +async def cleanup_processes(): + for pid, info in list(_processes.items()): + proc = info.get("process") + if proc and proc.returncode is None: + try: + proc.terminate() + await asyncio.wait_for(proc.wait(), timeout=5) + except (ProcessLookupError, asyncio.TimeoutError): + try: + proc.kill() + except ProcessLookupError: + pass + _processes.clear() + + +# --- Endpoints --- + + +@router.post("/open", response_model=WorkspaceInfo) +async def open_workspace(request: WorkspaceOpenRequest): + path = os.path.realpath(request.path) + if not os.path.isdir(path): + raise HTTPException(status_code=400, detail="Directory does not exist") + return WorkspaceInfo(path=path, name=os.path.basename(path)) + + +@router.get("/link", response_model=WorkspaceLinkInfo) +async def get_workspace_link(project_id: str = Query(...)): + links = _read_links() + return WorkspaceLinkInfo( + project_id=project_id, + workspace_path=links.get(project_id), + ) + + +@router.post("/link", response_model=WorkspaceLinkInfo) +async def set_workspace_link(request: WorkspaceLinkRequest): + path = os.path.realpath(request.workspace_path) + if not os.path.isdir(path): + raise HTTPException(status_code=400, detail="Directory does not exist") + links = _read_links() + links[request.project_id] = path + _write_links(links) + return WorkspaceLinkInfo(project_id=request.project_id, workspace_path=path) + + +@router.delete("/link") +async def remove_workspace_link(project_id: str = Query(...)): + links = _read_links() + links.pop(project_id, None) + _write_links(links) + return {"ok": True} + + +@router.get("/tree", response_model=List[FileEntry]) +async def get_tree( + workspace: str = Query(...), + relative_path: str = Query(""), +): + _validate_workspace(workspace) + target = _validate_path(workspace, relative_path) + if not os.path.isdir(target): + raise HTTPException(status_code=400, detail="Not a directory") + + entries = [] + try: + for item in sorted(os.listdir(target)): + if item.startswith("."): + continue + full_path = os.path.join(target, item) + rel = os.path.relpath(full_path, os.path.realpath(workspace)) + try: + stat = os.stat(full_path) + except OSError: + continue + is_dir = os.path.isdir(full_path) + is_python = item.endswith(".py") + has_burr = False + if is_python and not is_dir: + try: + with open(full_path, "r", errors="ignore") as f: + content = f.read(65536) + has_burr = bool(BURR_APP_PATTERN.search(content)) + except OSError: + pass + entries.append( + FileEntry( + name=item, + path=rel, + is_dir=is_dir, + size=stat.st_size if not is_dir else 0, + modified=stat.st_mtime, + is_python=is_python, + has_burr_app=has_burr, + ) + ) + except PermissionError: + raise HTTPException(status_code=403, detail="Permission denied") + return entries + + +@router.get("/file", response_model=FileContent) +async def get_file( + workspace: str = Query(...), + relative_path: str = Query(...), +): + _validate_workspace(workspace) + target = _validate_path(workspace, relative_path) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="File not found") + size = os.path.getsize(target) + if size > MAX_FILE_SIZE: + raise HTTPException(status_code=413, detail="File too large (max 1MB)") + if _is_binary(target): + raise HTTPException(status_code=415, detail="Binary file not supported") + + ext = os.path.splitext(target)[1].lower() + language = LANGUAGE_MAP.get(ext, "text") + + with open(target, "r", errors="replace") as f: + content = f.read() + + return FileContent( + path=relative_path, + content=content, + language=language, + size=size, + ) + + +@router.post("/run", response_model=ProcessInfo) +async def run_script(request: RunRequest): + _validate_workspace(request.workspace) + target = _validate_path(request.workspace, request.script) + if not os.path.isfile(target): + raise HTTPException(status_code=404, detail="Script not found") + if not target.endswith(".py"): + raise HTTPException(status_code=400, detail="Only Python scripts supported") + + # asyncio.create_subprocess_exec takes an explicit argv list, + # so there is no shell interpretation and no injection risk. + proc = await asyncio.create_subprocess_exec( + "python", + target, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=os.path.realpath(request.workspace), + ) + + started_at = time.time() + info = { + "process": proc, + "script_path": request.script, + "started_at": started_at, + "workspace": request.workspace, + } + _processes[proc.pid] = info + + return ProcessInfo( + pid=proc.pid, + script_path=request.script, + started_at=started_at, + status="running", + exit_code=None, + ) + + +@router.get("/run/{pid}/output") +async def stream_output(pid: int): + if pid not in _processes: + raise HTTPException(status_code=404, detail="Process not found") + + proc = _processes[pid]["process"] + + async def event_generator(): + async def read_stream(stream, stream_type): + while True: + line = await stream.readline() + if not line: + break + text = line.decode("utf-8", errors="replace") + yield f'data: {{"type": "{stream_type}", "data": {_json_escape(text)}}}\n\n' + + stdout_gen = read_stream(proc.stdout, "stdout") + stderr_gen = read_stream(proc.stderr, "stderr") + + stdout_done = False + stderr_done = False + + while not stdout_done or not stderr_done: + tasks = [] + if not stdout_done: + tasks.append(("stdout", stdout_gen)) + if not stderr_done: + tasks.append(("stderr", stderr_gen)) + + for name, gen in tasks: + try: + line = await asyncio.wait_for(gen.__anext__(), timeout=0.1) + yield line + except StopAsyncIteration: + if name == "stdout": + stdout_done = True + else: + stderr_done = True + except asyncio.TimeoutError: + continue + + exit_code = await proc.wait() + yield f'data: {{"type": "exit", "data": "{exit_code}"}}\n\n' + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@router.post("/run/{pid}/stop", response_model=ProcessInfo) +async def stop_process(pid: int): + if pid not in _processes: + raise HTTPException(status_code=404, detail="Process not found") + + info = _processes[pid] + proc = info["process"] + + if proc.returncode is None: + try: + proc.send_signal(signal.SIGTERM) + await asyncio.wait_for(proc.wait(), timeout=5) + except (ProcessLookupError, asyncio.TimeoutError): + try: + proc.kill() + except ProcessLookupError: + pass + + return ProcessInfo( + pid=pid, + script_path=info["script_path"], + started_at=info["started_at"], + status="stopped" if proc.returncode is None else "exited", + exit_code=proc.returncode, + ) + + +@router.get("/processes", response_model=List[ProcessInfo]) +async def list_processes(workspace: str = Query(...)): + _validate_workspace(workspace) + result = [] + for pid, info in _processes.items(): + if info["workspace"] != workspace: + continue + proc = info["process"] + if proc.returncode is None: + status = "running" + else: + status = "exited" + result.append( + ProcessInfo( + pid=pid, + script_path=info["script_path"], + started_at=info["started_at"], + status=status, + exit_code=proc.returncode, + ) + ) + return result + + +@router.get("/scan", response_model=List[FileEntry]) +async def scan_burr_apps(workspace: str = Query(...)): + _validate_workspace(workspace) + workspace_real = os.path.realpath(workspace) + if not os.path.isdir(workspace_real): + raise HTTPException(status_code=400, detail="Workspace not found") + + results = [] + for root, dirs, filenames in os.walk(workspace_real): + dirs[:] = [ + d + for d in dirs + if not d.startswith(".") and d != "__pycache__" and d != "node_modules" + ] + for fname in filenames: + if not fname.endswith(".py"): + continue + full_path = os.path.join(root, fname) + rel = os.path.relpath(full_path, workspace_real) + try: + with open(full_path, "r", errors="ignore") as f: + content = f.read(65536) + if BURR_APP_PATTERN.search(content): + stat = os.stat(full_path) + results.append( + FileEntry( + name=fname, + path=rel, + is_dir=False, + size=stat.st_size, + modified=stat.st_mtime, + is_python=True, + has_burr_app=True, + ) + ) + except OSError: + continue + return results + + +def _json_escape(s: str) -> str: + import json + + return json.dumps(s) + + +# --- Builder Projects --- + +_BUILDER_DIR = os.path.join(os.path.expanduser("~/.burr"), "builder_projects") + + +def _ensure_builder_dir(): + os.makedirs(_BUILDER_DIR, exist_ok=True) + + +@router.get("/builder/projects", response_model=List[BuilderProjectSummary]) +async def list_builder_projects(): + _ensure_builder_dir() + projects = [] + for fname in sorted(os.listdir(_BUILDER_DIR)): + if not fname.endswith(".json"): + continue + fpath = os.path.join(_BUILDER_DIR, fname) + try: + with open(fpath, "r") as f: + data = json_module.load(f) + projects.append( + BuilderProjectSummary( + id=fname.replace(".json", ""), + name=data.get("name", fname), + updated_at=os.path.getmtime(fpath), + ) + ) + except (OSError, json_module.JSONDecodeError): + continue + return sorted(projects, key=lambda p: p.updated_at, reverse=True) + + +@router.post("/builder/projects", response_model=BuilderProjectFull) +async def save_builder_project(request: BuilderProjectSave): + _ensure_builder_dir() + # Generate ID from name + project_id = re.sub(r"[^a-zA-Z0-9_-]", "_", request.name).lower() + if not project_id: + project_id = "untitled" + fpath = os.path.join(_BUILDER_DIR, f"{project_id}.json") + data = { + "name": request.name, + "graph_json": request.graph_json, + } + with open(fpath, "w") as f: + json_module.dump(data, f, indent=2) + return BuilderProjectFull( + id=project_id, + name=request.name, + graph_json=request.graph_json, + updated_at=time.time(), + ) + + +@router.get("/builder/projects/{project_id}", response_model=BuilderProjectFull) +async def get_builder_project(project_id: str): + safe_id = re.sub(r"[^a-zA-Z0-9_-]", "", project_id) + fpath = os.path.join(_BUILDER_DIR, f"{safe_id}.json") + if not os.path.isfile(fpath): + raise HTTPException(status_code=404, detail="Project not found") + with open(fpath, "r") as f: + data = json_module.load(f) + return BuilderProjectFull( + id=safe_id, + name=data.get("name", safe_id), + graph_json=data.get("graph_json", "{}"), + updated_at=os.path.getmtime(fpath), + ) + + +@router.delete("/builder/projects/{project_id}") +async def delete_builder_project(project_id: str): + safe_id = re.sub(r"[^a-zA-Z0-9_-]", "", project_id) + fpath = os.path.join(_BUILDER_DIR, f"{safe_id}.json") + if os.path.isfile(fpath): + os.remove(fpath) + return {"ok": True} diff --git a/telemetry/ui/package-lock.json b/telemetry/ui/package-lock.json index 95f8310a7..05a6868bd 100644 --- a/telemetry/ui/package-lock.json +++ b/telemetry/ui/package-lock.json @@ -11,6 +11,8 @@ "@headlessui/react": "^2.1.9", "@heroicons/react": "^2.1.1", "@microsoft/fetch-event-source": "^2.0.1", + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.95.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -23,6 +25,7 @@ "@types/react-select": "^5.0.1", "@types/react-syntax-highlighter": "^15.5.11", "@uiw/react-json-view": "^2.0.0-alpha.12", + "@xyflow/react": "^12.10.1", "clsx": "^2.1.0", "dagre": "^0.8.5", "es-abstract": "^1.22.4", @@ -31,6 +34,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", + "react-joyride": "^3.0.0", "react-markdown": "^9.0.1", "react-query": "^3.39.3", "react-router-dom": "^6.22.1", @@ -3244,21 +3248,39 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz", + "integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.1" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", - "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.0.0", - "@floating-ui/utils": "^0.2.0" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { @@ -3276,11 +3298,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", - "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.0.0" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -3288,9 +3311,49 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", - "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@gilbarbara/deep-equal": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.4.1.tgz", + "integrity": "sha512-QF2BGeQjsa59T59XvFdR3is5jrl28Eg0J6giXAC5919bcqvR8XP4B+07tpbs6Y6/IQd4FBncaL2WVXIBgSxt4w==", + "license": "MIT" + }, + "node_modules/@gilbarbara/hooks": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@gilbarbara/hooks/-/hooks-0.11.0.tgz", + "integrity": "sha512-CIVazdxqFRplUfm9wZL3/0X1TURJekhPMWGFdWzEmyJrGPiotX2yxA1KiB8N7VnhawIaMtb2Apnda4Y6DRwi2Q==", + "license": "MIT", + "dependencies": { + "@gilbarbara/deep-equal": "^0.4.1" + }, + "peerDependencies": { + "react": "16.8 - 19" + } + }, + "node_modules/@gilbarbara/types": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@gilbarbara/types/-/types-0.2.2.tgz", + "integrity": "sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.1.0" + } + }, + "node_modules/@gilbarbara/types/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/@graphql-codegen/add": { "version": "5.0.3", @@ -5560,6 +5623,29 @@ "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@next/env": { "version": "14.2.14", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.14.tgz", @@ -6988,6 +7074,32 @@ "tslib": "^2.4.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.0.tgz", + "integrity": "sha512-H1/CWCe8tGL3YIVeo770Z6kPbt0B3M1d/iQXIIK1qlFiFt6G2neYdkHgLapOC8uMYNt9DmHjmGukEKgdMk1P+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.95.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.0.tgz", + "integrity": "sha512-EMP8B+BK9zvnAemT8M/y3z/WO0NjZ7fIUY3T3wnHYK6AA3qK/k33i7tPgCXCejhX0cd4I6bJIXN2GmjrHjDBzg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.95.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.10.8", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.10.8.tgz", @@ -8868,6 +8980,38 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -11863,6 +12007,16 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -16092,6 +16246,12 @@ "node": ">=8" } }, + "node_modules/is-lite": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-lite/-/is-lite-2.0.0.tgz", + "integrity": "sha512-70f2BMIQlbSUXVKaZUd9a9fJH3IH1PDckV0m4BIIO4LjnNYvOh4Ng7vXIXEwpA0KDZknRq+7fHwGTu0jIdx28g==", + "license": "MIT" + }, "node_modules/is-lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-lower-case/-/is-lower-case-2.0.2.tgz", @@ -19526,6 +19686,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/match-sorter": { "version": "6.3.4", "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz", @@ -20730,6 +20903,17 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -23549,11 +23733,52 @@ "react": "*" } }, + "node_modules/react-innertext": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz", + "integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">=0.0.0 <=99", + "react": ">=0.0.0 <=99" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-joyride": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-3.0.0.tgz", + "integrity": "sha512-ut5kZEu0fZauOVW0JnX0s6Vcia/mAFxSLUIV7nu8025U47PktqN1fnLtMqE+IzgGac8F3id9IPd/U9APmWYvkA==", + "license": "MIT", + "dependencies": { + "@fastify/deepmerge": "^3.2.1", + "@floating-ui/react-dom": "^2.1.8", + "@gilbarbara/deep-equal": "^0.4.1", + "@gilbarbara/hooks": "^0.11.0", + "@gilbarbara/types": "^0.2.2", + "is-lite": "^2.0.0", + "react-innertext": "^1.1.5", + "scroll": "^3.0.1", + "scrollparent": "^2.1.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "16.8 - 19", + "react-dom": "16.8 - 19" + } + }, + "node_modules/react-joyride/node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-json-tree": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-json-tree/-/react-json-tree-0.18.0.tgz", @@ -24550,6 +24775,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/scroll": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz", + "integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==", + "license": "MIT" + }, + "node_modules/scrollparent": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz", + "integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==", + "license": "ISC" + }, "node_modules/scuid": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/scuid/-/scuid-1.1.0.tgz", @@ -25138,6 +25375,12 @@ "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==" }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/static-eval": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/static-eval/-/static-eval-2.0.2.tgz", diff --git a/telemetry/ui/package.json b/telemetry/ui/package.json index 1a391fcc0..0d96dce48 100644 --- a/telemetry/ui/package.json +++ b/telemetry/ui/package.json @@ -6,6 +6,8 @@ "@headlessui/react": "^2.1.9", "@heroicons/react": "^2.1.1", "@microsoft/fetch-event-source": "^2.0.1", + "@monaco-editor/react": "^4.7.0", + "@tanstack/react-query": "^5.95.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -18,6 +20,7 @@ "@types/react-select": "^5.0.1", "@types/react-syntax-highlighter": "^15.5.11", "@uiw/react-json-view": "^2.0.0-alpha.12", + "@xyflow/react": "^12.10.1", "clsx": "^2.1.0", "dagre": "^0.8.5", "es-abstract": "^1.22.4", @@ -26,6 +29,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", + "react-joyride": "^3.0.0", "react-markdown": "^9.0.1", "react-query": "^3.39.3", "react-router-dom": "^6.22.1", diff --git a/telemetry/ui/src/App.tsx b/telemetry/ui/src/App.tsx index 121337ae3..f5a03b256 100644 --- a/telemetry/ui/src/App.tsx +++ b/telemetry/ui/src/App.tsx @@ -31,6 +31,8 @@ import { StreamingChatbotWithTelemetry } from './examples/StreamingChatbot'; import { AdminView } from './components/routes/AdminView'; import { AnnotationsViewContainer } from './components/routes/app/AnnotationsView'; import { DeepResearcherWithTelemetry } from './examples/DeepResearcher'; +import { BuilderView } from './components/routes/builder/BuilderView'; +import { WorkspaceSelector } from './components/routes/workspace/WorkspaceSelector'; /** * Basic application. We have an AppContainer -- this has a breadcrumb and a sidebar. @@ -65,6 +67,9 @@ const App = () => { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/telemetry/ui/src/api/models/BackendSpec.ts b/telemetry/ui/src/api/models/BackendSpec.ts index b9fd7ede6..61a3ce163 100644 --- a/telemetry/ui/src/api/models/BackendSpec.ts +++ b/telemetry/ui/src/api/models/BackendSpec.ts @@ -29,4 +29,5 @@ export type BackendSpec = { snapshotting: boolean; supports_demos: boolean; supports_annotations: boolean; + supports_workspace: boolean; }; diff --git a/telemetry/ui/src/api/workspaceApi.ts b/telemetry/ui/src/api/workspaceApi.ts new file mode 100644 index 000000000..36be4826d --- /dev/null +++ b/telemetry/ui/src/api/workspaceApi.ts @@ -0,0 +1,185 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { fetchEventSource } from '@microsoft/fetch-event-source'; + +const BASE = '/api/v0/workspace'; + +export interface WorkspaceInfo { + path: string; + name: string; +} + +export interface FileEntry { + name: string; + path: string; + is_dir: boolean; + size: number; + modified: number; + is_python: boolean; + has_burr_app: boolean; +} + +export interface FileContent { + path: string; + content: string; + language: string; + size: number; +} + +export interface ProcessInfo { + pid: number; + script_path: string; + started_at: number; + status: string; + exit_code: number | null; +} + +export interface ProcessOutputEvent { + type: 'stdout' | 'stderr' | 'exit'; + data: string; +} + +export interface WorkspaceLinkInfo { + project_id: string; + workspace_path: string | null; +} + +export interface BuilderProjectSummary { + id: string; + name: string; + updated_at: number; +} + +export interface BuilderProjectFull { + id: string; + name: string; + graph_json: string; + updated_at: number; +} + +async function apiPost(path: string, body: unknown): Promise { + const res = await fetch(`${BASE}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail.detail || res.statusText); + } + return res.json(); +} + +async function apiGet(path: string, params?: Record): Promise { + const url = new URL(`${BASE}${path}`, window.location.origin); + if (params) { + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + } + const res = await fetch(url.toString()); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail.detail || res.statusText); + } + return res.json(); +} + +export const workspaceApi = { + getWorkspaceLink(projectId: string): Promise { + return apiGet('/link', { project_id: projectId }); + }, + + setWorkspaceLink(projectId: string, workspacePath: string): Promise { + return apiPost('/link', { project_id: projectId, workspace_path: workspacePath }); + }, + + removeWorkspaceLink(projectId: string): Promise { + const url = new URL(`${BASE}/link`, window.location.origin); + url.searchParams.set('project_id', projectId); + return fetch(url.toString(), { method: 'DELETE' }).then(() => undefined); + }, + + openWorkspace(path: string): Promise { + return apiPost('/open', { path }); + }, + + getFileTree(workspace: string, relativePath = ''): Promise { + return apiGet('/tree', { workspace, relative_path: relativePath }); + }, + + getFileContent(workspace: string, relativePath: string): Promise { + return apiGet('/file', { workspace, relative_path: relativePath }); + }, + + runScript(workspace: string, script: string): Promise { + return apiPost('/run', { workspace, script }); + }, + + stopProcess(pid: number): Promise { + return apiPost(`/run/${pid}/stop`, {}); + }, + + getProcesses(workspace: string): Promise { + return apiGet('/processes', { workspace }); + }, + + scanBurrApps(workspace: string): Promise { + return apiGet('/scan', { workspace }); + }, + + listBuilderProjects(): Promise { + return apiGet('/builder/projects'); + }, + + saveBuilderProject(name: string, graphJson: string): Promise { + return apiPost('/builder/projects', { name, graph_json: graphJson }); + }, + + getBuilderProject(id: string): Promise { + return apiGet(`/builder/projects/${id}`); + }, + + deleteBuilderProject(id: string): Promise { + return fetch(`${BASE}/builder/projects/${id}`, { method: 'DELETE' }).then(() => undefined); + }, + + streamProcessOutput( + pid: number, + onEvent: (event: ProcessOutputEvent) => void, + onError: (err: Error) => void + ): AbortController { + const ctrl = new AbortController(); + fetchEventSource(`${BASE}/run/${pid}/output`, { + signal: ctrl.signal, + onmessage(ev) { + try { + const parsed: ProcessOutputEvent = JSON.parse(ev.data); + onEvent(parsed); + } catch { + // ignore parse errors + } + }, + onerror(err) { + onError(err instanceof Error ? err : new Error(String(err))); + }, + openWhenHidden: true + }); + return ctrl; + } +}; diff --git a/telemetry/ui/src/components/routes/builder/BuilderEdge.tsx b/telemetry/ui/src/components/routes/builder/BuilderEdge.tsx new file mode 100644 index 000000000..6637a1022 --- /dev/null +++ b/telemetry/ui/src/components/routes/builder/BuilderEdge.tsx @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; + +type BuilderEdgeData = { + condition: string; + onConditionChange: (condition: string) => void; +}; + +export const BuilderEdgeComponent = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + data +}: EdgeProps) => { + const edgeData = data as BuilderEdgeData; + const [edgePath] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition + }); + + const label = edgeData?.condition === 'default' ? '' : edgeData?.condition || ''; + + return ( + + ); +}; diff --git a/telemetry/ui/src/components/routes/builder/BuilderGraph.tsx b/telemetry/ui/src/components/routes/builder/BuilderGraph.tsx new file mode 100644 index 000000000..250b5d5ba --- /dev/null +++ b/telemetry/ui/src/components/routes/builder/BuilderGraph.tsx @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useMemo } from 'react'; +import { + ReactFlow, + Controls, + Background, + ReactFlowProvider, + useReactFlow +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { BurrNodeType, BurrEdgeType, BurrGraph, InsertContext } from '../../../utils/builderTypes'; +import { NodeType } from '../../../utils/codeGenerator'; + +import { StepNodeComponent } from './nodes/StepNode'; +import { BigAddButtonComponent } from './nodes/BigAddButton'; +import { GraphEndWidgetComponent } from './nodes/GraphEndWidget'; +import { LoopReturnNodeComponent } from './nodes/LoopReturnNode'; + +import { StraightLineEdgeComponent } from './edges/StraightLineEdge'; +import { LoopStartEdgeComponent } from './edges/LoopStartEdge'; +import { LoopReturnEdgeComponent } from './edges/LoopReturnEdge'; +import { RouterStartEdgeComponent } from './edges/RouterStartEdge'; +import { RouterEndEdgeComponent } from './edges/RouterEndEdge'; + +const nodeTypes = { + [BurrNodeType.STEP]: StepNodeComponent, + [BurrNodeType.BIG_ADD_BUTTON]: BigAddButtonComponent, + [BurrNodeType.GRAPH_END]: GraphEndWidgetComponent, + [BurrNodeType.LOOP_RETURN]: LoopReturnNodeComponent +}; + +const edgeTypes = { + [BurrEdgeType.STRAIGHT_LINE]: StraightLineEdgeComponent, + [BurrEdgeType.LOOP_START]: LoopStartEdgeComponent, + [BurrEdgeType.LOOP_RETURN]: LoopReturnEdgeComponent, + [BurrEdgeType.ROUTER_START]: RouterStartEdgeComponent, + [BurrEdgeType.ROUTER_END]: RouterEndEdgeComponent +}; + +type BuilderGraphProps = { + layoutGraph: BurrGraph; + selectedNodeId: string | null; + onSelectNode: (id: string | null) => void; + onInsert: (nodeType: NodeType, ctx: InsertContext) => void; + unresolvedReads: Map; +}; + +const BuilderGraphInner = (props: BuilderGraphProps) => { + const { fitView } = useReactFlow(); + + useEffect(() => { + const timer = setTimeout(() => fitView({ padding: 0.3 }), 50); + return () => clearTimeout(timer); + }, [props.layoutGraph, fitView]); + + const flowNodes = useMemo(() => { + return props.layoutGraph.nodes.map((node) => { + if (node.type === BurrNodeType.STEP) { + const stepData = node.data as any; + return { + ...node, + data: { + ...stepData, + isSelected: stepData.stepId === props.selectedNodeId, + unresolvedReads: props.unresolvedReads.get(stepData.name) || [], + onSelect: () => props.onSelectNode(stepData.stepId) + } + }; + } + if (node.type === BurrNodeType.BIG_ADD_BUTTON) { + return { + ...node, + data: { ...node.data, onInsert: props.onInsert } + }; + } + return node; + }); + }, [props.layoutGraph.nodes, props.selectedNodeId, props.unresolvedReads, props.onSelectNode]); + + const flowEdges = useMemo(() => { + return props.layoutGraph.edges.map((edge) => ({ + ...edge, + data: { ...edge.data, onInsert: props.onInsert } + })); + }, [props.layoutGraph.edges, props.onInsert]); + + return ( + props.onSelectNode(null)} + fitView + maxZoom={2} + minZoom={0.1} + > + + + + ); +}; + +export const BuilderGraph = (props: BuilderGraphProps) => { + return ( + + + + ); +}; diff --git a/telemetry/ui/src/components/routes/builder/BuilderNode.tsx b/telemetry/ui/src/components/routes/builder/BuilderNode.tsx new file mode 100644 index 000000000..bad27c95d --- /dev/null +++ b/telemetry/ui/src/components/routes/builder/BuilderNode.tsx @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Handle, Position } from '@xyflow/react'; +import { Chip } from '../../common/chip'; +import { + ExclamationTriangleIcon, + BoltIcon, + ArrowDownTrayIcon, + FlagIcon, + SparklesIcon, + GlobeAltIcon, + CodeBracketIcon, + SignalIcon, + ArrowPathIcon, + ArrowsRightLeftIcon +} from '@heroicons/react/24/outline'; +import { NodeType, NODE_TYPE_META } from '../../../utils/codeGenerator'; + +const NODE_ICONS: Record> = { + action: BoltIcon, + input: ArrowDownTrayIcon, + result: FlagIcon, + llm_call: SparklesIcon, + api_call: GlobeAltIcon, + code: CodeBracketIcon, + streaming: SignalIcon, + loop: ArrowPathIcon, + router: ArrowsRightLeftIcon +}; + +type BuilderNodeData = { + label: string; + nodeType: NodeType; + reads: string[]; + writes: string[]; + inputs: string[]; + isSelected: boolean; + unresolvedReads: string[]; + onSelect: () => void; +}; + +export const BuilderNodeComponent = (props: { data: BuilderNodeData }) => { + const { label, nodeType, reads, writes, inputs, isSelected, unresolvedReads, onSelect } = + props.data; + const meta = NODE_TYPE_META[nodeType]; + const Icon = NODE_ICONS[nodeType]; + + return ( + <> + +
+
+ + {label} + {meta.label} + {unresolvedReads.length > 0 && ( + + )} +
+ + {reads.length > 0 && ( +
+ {reads.map((r) => ( + + ))} +
+ )} + {writes.length > 0 && ( +
+ {writes.map((w) => ( + + ))} +
+ )} + {inputs.length > 0 && ( +
+ {inputs.map((i) => ( + + ))} +
+ )} +
+ + + ); +}; diff --git a/telemetry/ui/src/components/routes/builder/BuilderToolbar.tsx b/telemetry/ui/src/components/routes/builder/BuilderToolbar.tsx new file mode 100644 index 000000000..896abb3b9 --- /dev/null +++ b/telemetry/ui/src/components/routes/builder/BuilderToolbar.tsx @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useRef, useState } from 'react'; +import { + PlusIcon, + ShieldCheckIcon, + ArrowDownTrayIcon, + FolderOpenIcon, + DocumentArrowDownIcon, + TrashIcon +} from '@heroicons/react/24/outline'; +import { NodeType } from '../../../utils/codeGenerator'; +import { NodeTypePicker } from './NodeTypePicker'; +import { + workspaceApi, + BuilderProjectSummary +} from '../../../api/workspaceApi'; + +export const BuilderToolbar = (props: { + onAddNode: (nodeType: NodeType) => void; + onValidate: () => void; + validationCount: number; + projectName: string; + onProjectNameChange: (name: string) => void; + onSave: () => void; + onLoad: (graphJson: string) => void; + onNew: () => void; +}) => { + const [pickerOpen, setPickerOpen] = useState(false); + const [fileMenuOpen, setFileMenuOpen] = useState(false); + const [savedProjects, setSavedProjects] = useState([]); + const [showOpenDialog, setShowOpenDialog] = useState(false); + const addBtnRef = useRef(null); + const fileMenuRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (fileMenuRef.current && !fileMenuRef.current.contains(e.target as Node)) { + setFileMenuOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const loadProjects = async () => { + const projects = await workspaceApi.listBuilderProjects(); + setSavedProjects(projects); + setShowOpenDialog(true); + setFileMenuOpen(false); + }; + + const handleOpen = async (id: string) => { + const project = await workspaceApi.getBuilderProject(id); + props.onProjectNameChange(project.name); + props.onLoad(project.graph_json); + setShowOpenDialog(false); + }; + + const handleDelete = async (id: string) => { + await workspaceApi.deleteBuilderProject(id); + setSavedProjects((prev) => prev.filter((p) => p.id !== id)); + }; + + return ( + <> +
+ {/* File menu */} +
+ + {fileMenuOpen && ( +
+ + + +
+ +
+ )} +
+ +
+ + {/* Project name */} + props.onProjectNameChange(e.target.value)} + className="px-2 py-1 text-sm font-medium text-gray-700 bg-transparent border-b border-transparent hover:border-gray-300 focus:border-blue-500 outline-none w-40" + placeholder="Untitled Project" + /> + +
+ + {/* Add Node */} +
+ + {pickerOpen && ( + } + onSelect={(type) => props.onAddNode(type)} + onClose={() => setPickerOpen(false)} + /> + )} +
+ + {/* Validation */} + +
+ + {/* Open Project Dialog */} + {showOpenDialog && ( +
+
+
+

Open Project

+ +
+
+ {savedProjects.length === 0 && ( +
No saved projects
+ )} + {savedProjects.map((p) => ( +
+ + +
+ ))} +
+
+
+ )} + + ); +}; diff --git a/telemetry/ui/src/components/routes/builder/BuilderView.tsx b/telemetry/ui/src/components/routes/builder/BuilderView.tsx new file mode 100644 index 000000000..397e7ac64 --- /dev/null +++ b/telemetry/ui/src/components/routes/builder/BuilderView.tsx @@ -0,0 +1,512 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import Editor, { OnMount } from '@monaco-editor/react'; +import { + DocumentTextIcon, + XMarkIcon, + ChevronRightIcon, + FolderIcon, + FolderOpenIcon +} from '@heroicons/react/24/outline'; +import { DefaultService } from '../../../api'; +import { useBuilderState } from '../../../hooks/useBuilderState'; +import { BuilderGraph } from './BuilderGraph'; +import { BuilderToolbar } from './BuilderToolbar'; +import { NodeEditor } from './NodeEditor'; +import { NodeType, ProjectFile } from '../../../utils/codeGenerator'; +import { parsePythonCode, buildTreeFromParsed } from '../../../utils/codeParser'; +import { FileEntry, workspaceApi } from '../../../api/workspaceApi'; + +type OpenTab = { + name: string; + language: string; + content: string; + source: 'generated' | 'workspace'; + path?: string; // workspace relative path + modified?: boolean; +}; + +// Mini file explorer for the editor sidebar +const EditorFileExplorer = (props: { + generatedFiles: ProjectFile[]; + workspacePath: string | null; + onOpenFile: (tab: OpenTab) => void; +}) => { + const [expanded, setExpanded] = useState(true); + const [wsExpanded, setWsExpanded] = useState(false); + const [wsFiles, setWsFiles] = useState([]); + const [wsSubFiles, setWsSubFiles] = useState>({}); + + useEffect(() => { + if (props.workspacePath && wsExpanded) { + workspaceApi.getFileTree(props.workspacePath).then(setWsFiles); + } + }, [props.workspacePath, wsExpanded]); + + const loadSubDir = async (path: string) => { + if (!props.workspacePath || wsSubFiles[path]) return; + const entries = await workspaceApi.getFileTree(props.workspacePath, path); + setWsSubFiles((prev) => ({ ...prev, [path]: entries })); + }; + + const openWsFile = async (entry: FileEntry) => { + if (!props.workspacePath || entry.is_dir) return; + const data = await workspaceApi.getFileContent(props.workspacePath, entry.path); + props.onOpenFile({ + name: entry.name, + language: data.language, + content: data.content, + source: 'workspace', + path: entry.path + }); + }; + + const langMap: Record = { + py: 'python', js: 'javascript', ts: 'typescript', json: 'json', + yaml: 'yaml', yml: 'yaml', toml: 'toml', md: 'markdown', txt: 'plaintext' + }; + + return ( +
+ {/* Generated files */} + + {expanded && + props.generatedFiles.map((f) => ( + + ))} + + {/* Workspace files */} + {props.workspacePath && ( + <> + + {wsExpanded && + wsFiles.map((entry) => + entry.is_dir ? ( +
+ + {wsSubFiles[entry.path]?.map((sub) => + !sub.is_dir ? ( + + ) : null + )} +
+ ) : ( + + ) + )} + + )} +
+ ); +}; + +export const BuilderView = () => { + const { projectId, appId } = useParams<{ projectId?: string; appId?: string }>(); + const [projectName, setProjectName] = useState('Untitled Project'); + + const builder = useBuilderState(); + + const editSourceRef = useRef<'graph' | 'editor'>('graph'); + const [editorOverrides, setEditorOverrides] = useState>({}); + const debounceRef = useRef | null>(null); + const [parseError, setParseError] = useState(null); + + // Cleanup debounce on unmount + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + // Open tabs + const [openTabs, setOpenTabs] = useState([]); + const [activeTabName, setActiveTabName] = useState(null); + + // Workspace link + const { data: linkInfo } = useQuery({ + queryKey: ['workspace-link', projectId], + queryFn: () => workspaceApi.getWorkspaceLink(projectId!), + enabled: !!projectId + }); + const workspacePath = linkInfo?.workspace_path || null; + + const { data: appData } = useQuery({ + queryKey: ['steps', appId], + queryFn: () => + DefaultService.getApplicationLogsApiV0ProjectIdAppIdPartitionKeyAppsGet(projectId!, appId!, '__none__'), + enabled: !!projectId && !!appId + }); + + const [imported, setImported] = useState(false); + useEffect(() => { + if (appData && !imported) { + builder.importFromApplication(appData.application); + setImported(true); + } + }, [appData, imported, builder]); + + // Auto-open actions.py on first load + useEffect(() => { + if (builder.projectFiles.length > 0 && openTabs.length === 0) { + const actionsFile = builder.projectFiles[0]; + const tab: OpenTab = { + name: actionsFile.name, + language: actionsFile.language, + content: actionsFile.content, + source: 'generated' + }; + setOpenTabs([tab]); + setActiveTabName(tab.name); + } + }, [builder.projectFiles]); + + const handleAddNode = useCallback( + (nodeType: NodeType) => { + editSourceRef.current = 'graph'; + setEditorOverrides({}); + builder.addNode({ x: 0, y: 0 }, nodeType); + }, + [builder] + ); + + const handleSave = useCallback(async () => { + const graphJson = JSON.stringify(builder.rootNode); + await workspaceApi.saveBuilderProject(projectName, graphJson); + }, [builder.rootNode, projectName]); + + const handleLoad = useCallback( + (graphJson: string) => { + try { + const parsed = JSON.parse(graphJson); + builder.setRootNode(parsed); + editSourceRef.current = 'graph'; + setEditorOverrides({}); + setOpenTabs([]); + setActiveTabName(null); + } catch { + // invalid JSON + } + }, + [builder] + ); + + const handleNew = useCallback(() => { + builder.setRootNode(null); + setProjectName('Untitled Project'); + editSourceRef.current = 'graph'; + setEditorOverrides({}); + setOpenTabs([]); + setActiveTabName(null); + }, [builder]); + + // Ctrl+S to save + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + handleSave(); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [handleSave]); + + const getTabKey = (t: OpenTab) => t.path || t.name; + + const openFile = useCallback((tab: OpenTab) => { + const key = getTabKey(tab); + setOpenTabs((prev) => { + const exists = prev.find((t) => getTabKey(t) === key); + if (exists) return prev; + return [...prev, tab]; + }); + setActiveTabName(key); + }, []); + + const closeTab = useCallback( + (key: string) => { + setOpenTabs((prev) => { + const filtered = prev.filter((t) => getTabKey(t) !== key); + if (activeTabName === key) { + setActiveTabName(filtered.length > 0 ? getTabKey(filtered[filtered.length - 1]) : null); + } + return filtered; + }); + }, + [activeTabName] + ); + + const activeTab = openTabs.find((t) => getTabKey(t) === activeTabName); + + // Get content for active tab (generated files update from graph) + const getTabContent = (tab: OpenTab): string => { + if (tab.source === 'generated') { + if (editSourceRef.current === 'editor' && editorOverrides[tab.name] !== undefined) { + return editorOverrides[tab.name]; + } + const gen = builder.projectFiles.find((f) => f.name === tab.name); + return gen?.content || tab.content; + } + return editorOverrides[getTabKey(tab)] ?? tab.content; + }; + + const getTabLanguage = (tab: OpenTab): string => { + if (tab.source === 'generated') { + return builder.projectFiles.find((f) => f.name === tab.name)?.language || tab.language; + } + return tab.language; + }; + + const handleEditorChange = useCallback( + (value: string | undefined) => { + if (!value || !activeTabName) return; + editSourceRef.current = 'editor'; + setEditorOverrides((prev) => ({ ...prev, [activeTabName]: value })); + + // Parse for graph sync only on generated Python files + const tab = openTabs.find((t) => t.name === activeTabName); + if (!tab || tab.source !== 'generated') return; + if (activeTabName !== 'actions.py' && activeTabName !== 'app.py') return; + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + const actionsCode = + activeTabName === 'actions.py' + ? value + : editorOverrides['actions.py'] || + builder.projectFiles.find((f) => f.name === 'actions.py')?.content || + ''; + const appCode = + activeTabName === 'app.py' + ? value + : editorOverrides['app.py'] || + builder.projectFiles.find((f) => f.name === 'app.py')?.content || + ''; + + const parsed = parsePythonCode(actionsCode + '\n\n' + appCode); + if (parsed.error || parsed.actions.length === 0) { + setParseError(parsed.error || null); + return; + } + setParseError(null); + const tree = buildTreeFromParsed(parsed); + if (tree) builder.setRootFromCode(tree); + }, 1000); + }, + [activeTabName, builder, editorOverrides, openTabs] + ); + + const handleEditorMount: OnMount = (editor) => { + editor.onDidFocusEditorText(() => { + editSourceRef.current = 'editor'; + }); + editor.onDidBlurEditorText(() => { + editSourceRef.current = 'graph'; + setEditorOverrides({}); + }); + }; + + return ( +
+ {}} + validationCount={builder.validationErrors.size} + projectName={projectName} + onProjectNameChange={setProjectName} + onSave={handleSave} + onLoad={handleLoad} + onNew={handleNew} + /> + +
+ {/* Left: Visual Builder */} +
+
+ { + editSourceRef.current = 'graph'; + setEditorOverrides({}); + builder.setSelectedNodeId(id); + }} + onInsert={(nodeType, ctx) => { + editSourceRef.current = 'graph'; + setEditorOverrides({}); + builder.handleInsert(nodeType, ctx); + }} + unresolvedReads={builder.validationErrors} + /> +
+ + {builder.selectedNode && ( + { + editSourceRef.current = 'graph'; + setEditorOverrides({}); + builder.updateNode(builder.selectedNode!.id, updates); + }} + onDelete={() => { + editSourceRef.current = 'graph'; + setEditorOverrides({}); + builder.removeNode(builder.selectedNode!.id); + }} + unresolvedReads={builder.validationErrors.get(builder.selectedNode.name) || []} + /> + )} +
+ + {/* Right: VS Code-style editor */} +
+ {/* Mini file explorer */} + + + {/* Editor area */} +
+ {/* Tabs */} +
+ {openTabs.map((tab) => { + const key = getTabKey(tab); + return ( +
setActiveTabName(key)} + className={`flex items-center gap-1 px-3 py-1.5 text-xs cursor-pointer border-r border-gray-200 shrink-0 select-none ${ + key === activeTabName + ? 'bg-white text-gray-900 font-medium border-b-2 border-b-blue-500' + : 'bg-gray-50 text-gray-500 hover:bg-gray-100' + }`} + > + + {tab.name} + +
+ ); + })} +
+ {parseError && Parse error} +
+ + {/* Monaco Editor */} +
+ {activeTab ? ( + + ) : ( +
+ Open a file from the explorer +
+ )} +
+
+
+
+
+ ); +}; diff --git a/telemetry/ui/src/components/routes/builder/CodePreview.tsx b/telemetry/ui/src/components/routes/builder/CodePreview.tsx new file mode 100644 index 000000000..ae759b418 --- /dev/null +++ b/telemetry/ui/src/components/routes/builder/CodePreview.tsx @@ -0,0 +1,52 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { base16AteliersulphurpoolLight } from 'react-syntax-highlighter/dist/esm/styles/prism'; + +export const CodePreview = (props: { code: string }) => { + const copyToClipboard = () => { + navigator.clipboard.writeText(props.code); + }; + + return ( +
+
+ Generated Python + +
+
+ + {props.code} + +
+
+ ); +}; diff --git a/telemetry/ui/src/components/routes/builder/NodeEditor.tsx b/telemetry/ui/src/components/routes/builder/NodeEditor.tsx new file mode 100644 index 000000000..c572f2761 --- /dev/null +++ b/telemetry/ui/src/components/routes/builder/NodeEditor.tsx @@ -0,0 +1,359 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from 'react'; +import { BuilderNode, NodeType, NODE_TYPE_META } from '../../../utils/codeGenerator'; +import { TrashIcon } from '@heroicons/react/24/outline'; + +const TagInput = (props: { + label: string; + values: string[]; + onChange: (values: string[]) => void; + colorClass: string; +}) => { + const [input, setInput] = useState(''); + + const addTag = () => { + const trimmed = input.trim(); + if (trimmed && !props.values.includes(trimmed)) { + props.onChange([...props.values, trimmed]); + setInput(''); + } + }; + + return ( +
+ +
+ {props.values.map((v) => ( + + {v} + + + ))} +
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addTag(); + } + }} + placeholder={`Add ${props.label.toLowerCase()}...`} + className="border border-gray-200 rounded px-2 py-1 text-sm" + /> +
+ ); +}; + +const TextInput = (props: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + mono?: boolean; +}) => ( +
+ + props.onChange(e.target.value)} + placeholder={props.placeholder} + className={`border border-gray-200 rounded px-2 py-1 text-sm ${props.mono ? 'font-mono' : ''}`} + /> +
+); + +const SelectInput = (props: { + label: string; + value: string; + onChange: (v: string) => void; + options: { value: string; label: string }[]; +}) => ( +
+ + +
+); + +const CodeInput = (props: { label: string; value: string; onChange: (v: string) => void }) => ( +
+ +