diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32169baf..569e86a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,3 +76,59 @@ jobs: - name: Build frontend run: npm run build + + synthetic-browser-smoke: + name: Synthetic browser smoke + needs: + - backend + - frontend + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.7.3" + enable-cache: true + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: client/package-lock.json + + - name: Fetch pinned PyTorch Connectomics runtime + run: bash scripts/setup_pytorch_connectomics.sh + + - name: Install Python dependencies + run: uv sync --frozen --python 3.11 --group dev + + - name: Install frontend dependencies + working-directory: client + run: npm ci --fetch-retries=5 --fetch-retry-maxtimeout=120000 + + - name: Install Chromium + run: uv run playwright install --with-deps chromium + + - name: Run deterministic synthetic browser smoke + run: bash scripts/run_browser_synthetic_core_smoke_ci.sh + + - name: Upload browser smoke diagnostics + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: synthetic-browser-smoke-${{ github.run_attempt }} + path: .ci/synthetic-browser-smoke/ + if-no-files-found: warn + retention-days: 14 diff --git a/.gitignore b/.gitignore index 0d858bfe..b6cb3514 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ uploads pytorch_connectomics server_api/chatbot/faiss_index/ .logs/ +.pytc/ # Local deployment evidence (may contain transient viewer URLs) demo-proofread-3d.png diff --git a/client/package-lock.json b/client/package-lock.json index 868d7b99..b171fa82 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,6 +8,8 @@ "name": "pytc-client", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", @@ -3717,6 +3719,55 @@ "node": ">=10" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.7.tgz", + "integrity": "sha512-11uSrj77IDijNBqizD4lY4y1laMyRrqMLSxjnWy5CvWkCjyRDW+gGmxYq0lwQKVas/sq7zyzYWXbL/BvBzR32g==", + "dependencies": { + "@tanstack/virtual-core": "3.17.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "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/@tanstack/virtual-core": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.5.tgz", + "integrity": "sha512-AXfBC3sq6PuYSwyxYORqqgHCNjPGAvKJvZuBBJ1klhztWBB5cgqgwsq8+fNfaQJG7/K4xYBja9S90QFn2zmQAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/client/package.json b/client/package.json index 3af2d657..fd6a2c0a 100644 --- a/client/package.json +++ b/client/package.json @@ -9,6 +9,8 @@ "@testing-library/jest-dom": "^6.4.6", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", + "@tanstack/react-query": "^5.83.0", + "@tanstack/react-virtual": "^3.13.12", "antd": "^5.18.1", "axios": "^1.7.2", "buffer": "^6.0.3", diff --git a/client/src/App.css b/client/src/App.css index 336d8ef9..dfa67459 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -2,6 +2,19 @@ text-align: center; } +.app-error-boundary { + align-items: center; + background: var(--seg-bg-canvas, #f7f4ed); + display: flex; + justify-content: center; + min-height: 100vh; + padding: 24px; +} + +.app-error-boundary .ant-result { + max-width: 680px; +} + :root { --seg-bg-canvas: #f7f4ed; --seg-bg-panel: #fffdfa; @@ -192,6 +205,34 @@ padding-right: 2px; } +.pytc-top-nav, +.pytc-top-menu { + min-width: 0; +} + +.pytc-top-nav { + max-width: 100vw; + overflow: hidden; +} + +.pytc-top-nav-action { + flex: 0 0 auto; +} + +@media (max-width: 720px) { + .pytc-top-menu .ant-menu-item { + padding-inline: 10px 14px !important; + } + + .pytc-top-nav-action { + padding-inline: 8px; + } + + .pytc-top-nav-action__label { + display: none; + } +} + .app-logo { height: 40vmin; pointer-events: none; diff --git a/client/src/api.js b/client/src/api.js index f3010b37..026841c2 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -1,6 +1,7 @@ import axios from "axios"; import yaml from "js-yaml"; import { logClientEvent } from "./logging/appEventLog"; +import { attachApiError, normalizeApiError } from "./errors/apiError"; import { detectConfigDiagnostics, summarizeConfigText, @@ -117,6 +118,9 @@ const apiDebugLog = (...args) => { export const apiClient = axios.create({ baseURL: BASE_URL, withCredentials: true, + headers: { + Accept: "application/json, application/problem+json", + }, }); const summarizePayload = (payload) => { @@ -190,6 +194,7 @@ const attachApiLogging = (instance, source) => { return response; }, (error) => { + attachApiError(error); const config = error.config || {}; const startedAt = config.metadata?.startedAt; const endedAt = @@ -209,6 +214,8 @@ const attachApiLogging = (instance, source) => { ? Number((endedAt - startedAt).toFixed(2)) : null, detail: error.response?.data?.detail || null, + errorCode: error.apiError?.code || null, + requestId: error.apiError?.requestId || null, }, }); return Promise.reject(error); @@ -232,33 +239,6 @@ const buildFilePath = (file) => { const hasBrowserFile = (file) => file && file.originFileObj instanceof File; -const getErrorDetailMessage = (detail) => { - if (!detail) return ""; - if (typeof detail === "string") return detail; - if (Array.isArray(detail)) { - return detail.map(getErrorDetailMessage).filter(Boolean).join("; "); - } - if (typeof detail === "object") { - if (detail.user_message) { - return getErrorDetailMessage(detail.user_message); - } - const nestedUpstream = - detail.upstream_body !== undefined - ? getErrorDetailMessage(detail.upstream_body) - : ""; - return [ - detail.message, - detail.detail, - detail.reason, - nestedUpstream, - detail.error, - ] - .filter(Boolean) - .join(" | "); - } - return String(detail); -}; - export async function getNeuroglancerViewer( image, label, @@ -392,13 +372,11 @@ export async function checkFile(file) { function handleError(error) { if (error.response) { - const detail = error.response.data?.detail; - const detailMessage = getErrorDetailMessage(detail); - throw new Error( - `${error.response.status}: ${detailMessage || error.response.statusText}`, - ); + const apiError = normalizeApiError(error); + error.apiError = apiError; + error.message = `${error.response.status}: ${apiError.message}`; } - throw error; + throw attachApiError(error); } export async function makeApiRequest(url, method, data = null) { @@ -973,6 +951,36 @@ export async function listWorkflowEvents(workflowId) { } } +export async function listWorkflowOperations(workflowId, { limit = 12 } = {}) { + try { + const res = await apiClient.get( + canonicalizeApiPath(`/api/workflows/${workflowId}/operations`), + { params: { limit } }, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function cancelWorkflowOperation( + workflowId, + operationId, + reason = "", +) { + try { + const res = await apiClient.post( + canonicalizeApiPath( + `/api/workflows/${workflowId}/operations/${operationId}/cancel`, + ), + reason ? { reason } : undefined, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + export async function getWorkflowHotspots(workflowId) { try { const res = await apiClient.get( diff --git a/client/src/api.test.js b/client/src/api.test.js index 25788c74..fb4f1c90 100644 --- a/client/src/api.test.js +++ b/client/src/api.test.js @@ -73,6 +73,18 @@ describe("api canonicalization", () => { expect(url).toBe("https://demo.example/api/files?parent=root"); }); + it("requests structured Problem Details responses", () => { + const { axiosMock } = loadApiModule(BASE_WITH_API_PREFIX); + + expect(axiosMock.create).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + Accept: "application/json, application/problem+json", + }, + }), + ); + }); + it("canonicalizes training approval/action paths for base URLs with /api/workflows", () => { const { api, apiClientMock } = loadApiModule( "https://demo.example/api/workflows", @@ -92,4 +104,23 @@ describe("api canonicalization", () => { "/99/commands/321/run", ); }); + + it("uses canonical durable operation list and cancellation paths", async () => { + const { api, apiClientMock } = loadApiModule(BASE_WITH_API_PREFIX); + apiClientMock.get.mockResolvedValue({ data: [] }); + apiClientMock.post.mockResolvedValue({ + data: { id: 8, status: "cancelled" }, + }); + + await api.listWorkflowOperations(42, { limit: 6 }); + expect(apiClientMock.get).toHaveBeenCalledWith("/workflows/42/operations", { + params: { limit: 6 }, + }); + + await api.cancelWorkflowOperation(42, 8, "No longer needed"); + expect(apiClientMock.post).toHaveBeenCalledWith( + "/workflows/42/operations/8/cancel", + { reason: "No longer needed" }, + ); + }); }); diff --git a/client/src/components/AppErrorBoundary.js b/client/src/components/AppErrorBoundary.js new file mode 100644 index 00000000..daa0a742 --- /dev/null +++ b/client/src/components/AppErrorBoundary.js @@ -0,0 +1,81 @@ +import React from "react"; +import { ArrowLeftOutlined, ReloadOutlined } from "@ant-design/icons"; +import { Button, Result, Space, Typography } from "antd"; +import { logClientEvent } from "../logging/appEventLog"; + +const { Text } = Typography; + +class AppErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { error: null, errorId: null }; + } + + static getDerivedStateFromError(error) { + return { + error, + errorId: `ui-${Date.now().toString(36)}`, + }; + } + + componentDidCatch(error, info) { + logClientEvent("ui_render_failed", { + level: "ERROR", + message: error?.message || "The application failed to render", + source: "AppErrorBoundary", + data: { + errorId: this.state.errorId, + errorName: error?.name, + componentStack: info?.componentStack, + }, + }); + } + + retry = () => { + this.setState({ error: null, errorId: null }); + }; + + reload = () => { + window.location.reload(); + }; + + goBack = () => { + window.history.back(); + }; + + render() { + const { error, errorId } = this.state; + if (!error) return this.props.children; + + return ( +
+ + + + + + } + > + Error reference: {errorId} + +
+ ); + } +} + +export default AppErrorBoundary; diff --git a/client/src/components/AppErrorBoundary.test.js b/client/src/components/AppErrorBoundary.test.js new file mode 100644 index 00000000..38f193e1 --- /dev/null +++ b/client/src/components/AppErrorBoundary.test.js @@ -0,0 +1,51 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen } from "@testing-library/react"; +import AppErrorBoundary from "./AppErrorBoundary"; +import { logClientEvent } from "../logging/appEventLog"; + +jest.mock("../logging/appEventLog", () => ({ logClientEvent: jest.fn() })); + +const Broken = ({ broken }) => { + if (broken) throw new Error("render failed"); + return
Recovered content
; +}; + +describe("AppErrorBoundary", () => { + let consoleError; + + beforeEach(() => { + consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + jest.clearAllMocks(); + }); + + it("shows recovery actions and records render failures", () => { + const { rerender } = render( + + + , + ); + + expect(screen.getByRole("alert")).toHaveTextContent( + "This screen could not be displayed", + ); + expect(screen.getByText(/Error reference: ui-/)).toBeInTheDocument(); + expect(logClientEvent).toHaveBeenCalledWith( + "ui_render_failed", + expect.objectContaining({ source: "AppErrorBoundary" }), + ); + + rerender( + + + , + ); + fireEvent.click(screen.getByRole("button", { name: /try again/i })); + + expect(screen.getByText("Recovered content")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/FilePickerModal.js b/client/src/components/FilePickerModal.js index 0ffa724d..ced2bd04 100644 --- a/client/src/components/FilePickerModal.js +++ b/client/src/components/FilePickerModal.js @@ -1,12 +1,28 @@ -import React, { useState, useEffect, useMemo } from "react"; -import { Modal, List, Breadcrumb, Button, Spin, message, Progress } from "antd"; +import React, { useState, useEffect, useMemo, useRef } from "react"; +import { + Modal, + List, + Breadcrumb, + Button, + Spin, + message, + Progress, + Result, + Empty, +} from "antd"; import { FolderFilled, FileOutlined, ArrowLeftOutlined, UploadOutlined, + ReloadOutlined, } from "@ant-design/icons"; import { apiClient } from "../api"; +import { normalizeApiError } from "../errors/apiError"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +const FILE_PAGE_SIZE = 100; const HIDDEN_SYSTEM_FILES = new Set([ "workflow_preference.json", @@ -44,116 +60,134 @@ const FilePickerModal = ({ selectionType = "file", }) => { const [currentPath, setCurrentPath] = useState("root"); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(false); + const [breadcrumbs, setBreadcrumbs] = useState([ + { id: "root", name: "Projects", item: null }, + ]); const [previewStatus, setPreviewStatus] = useState({}); const [onlyImages, setOnlyImages] = useState(false); const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); + const scrollRef = useRef(null); + const queryClient = useQueryClient(); const previewBaseUrl = apiClient.defaults.baseURL || "http://localhost:4242"; - // Refactored fetch to get all files once - const [allData, setAllData] = useState([]); - const folderById = useMemo(() => { - const map = new Map(); - allData.forEach((item) => { - if (item?.is_folder) { - map.set(String(item.id), item); + const filesQuery = useInfiniteQuery({ + queryKey: ["files", "picker", currentPath, { onlyImages }], + enabled: visible, + initialPageParam: 0, + queryFn: async ({ pageParam, signal }) => { + const response = await apiClient.get("/files", { + params: { + parent: currentPath, + offset: pageParam, + limit: FILE_PAGE_SIZE, + volume_only: onlyImages, + }, + signal, + }); + if (Array.isArray(response.data)) { + return { + items: response.data, + total: response.data.length, + offset: 0, + limit: response.data.length || FILE_PAGE_SIZE, + has_more: false, + }; } + return response.data; + }, + getNextPageParam: (page) => + page?.has_more + ? Number(page.offset || 0) + Number(page.limit || 0) + : null, + }); + + const allItems = useMemo( + () => filesQuery.data?.pages.flatMap((page) => page.items || []) || [], + [filesQuery.data], + ); + const items = useMemo(() => { + const visibleItems = allItems.filter((item) => { + const nameLower = String(item.name || "").toLowerCase(); + return item.is_folder || !HIDDEN_SYSTEM_FILES.has(nameLower); + }); + visibleItems.sort((a, b) => { + if (a.is_folder === b.is_folder) return a.name.localeCompare(b.name); + return a.is_folder ? -1 : 1; }); - return map; - }, [allData]); + return visibleItems; + }, [allItems]); + const loadError = useMemo( + () => (filesQuery.isError ? normalizeApiError(filesQuery.error) : null), + [filesQuery.error, filesQuery.isError], + ); + + const rowVirtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 64, + overscan: 8, + initialRect: { width: 568, height: 400 }, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); useEffect(() => { if (visible) { setCurrentPath("root"); + setBreadcrumbs([{ id: "root", name: "Projects", item: null }]); setOnlyImages(false); setPreviewStatus({}); setUploadProgress(null); - loadAllData(); } }, [visible]); - const loadAllData = async () => { - setLoading(true); - try { - const res = await apiClient.get("/files"); - setAllData(res.data); - } catch (error) { - message.error("Failed to load files"); - } finally { - setLoading(false); + useEffect(() => { + if (!visible) { + queryClient.cancelQueries({ queryKey: ["files", "picker"] }); } - }; + }, [queryClient, visible]); - // Derive items for current view useEffect(() => { - const filtered = allData.filter((f) => { - const nameLower = String(f.name || "").toLowerCase(); - if (!f.is_folder && HIDDEN_SYSTEM_FILES.has(nameLower)) return false; - if (currentPath === "root") return f.path === "root" || !f.path; - return String(f.path) === currentPath; - }); - - const filteredByType = onlyImages - ? filtered.filter((f) => f.is_folder || isImageFile(f)) - : filtered; - - filteredByType.sort((a, b) => { - if (a.is_folder === b.is_folder) return a.name.localeCompare(b.name); - return a.is_folder ? -1 : 1; - }); + const lastRow = virtualRows[virtualRows.length - 1]; + if ( + lastRow && + lastRow.index >= items.length - 8 && + filesQuery.hasNextPage && + !filesQuery.isFetchingNextPage + ) { + filesQuery.fetchNextPage(); + } + }, [filesQuery, items.length, virtualRows]); - setItems(filteredByType); - }, [currentPath, allData, onlyImages]); + useEffect(() => { + if (loadError) message.error(loadError.message); + }, [loadError]); const getParentPath = () => { if (currentPath === "root") return null; - const currentFolderObj = folderById.get(String(currentPath)); - return currentFolderObj ? currentFolderObj.path || "root" : "root"; + return breadcrumbs[breadcrumbs.length - 2]?.id || "root"; }; const goUp = () => { const parent = getParentPath(); - if (parent) setCurrentPath(parent); + if (parent) { + setCurrentPath(parent); + setBreadcrumbs((current) => current.slice(0, -1)); + } }; - const getBreadcrumbs = () => { - const parts = []; - let curr = currentPath; - while (curr && curr !== "root") { - const folder = folderById.get(String(curr)); - if (folder) { - parts.unshift({ id: String(folder.id), name: folder.name }); - curr = folder.path; - } else { - break; - } - } - parts.unshift({ id: "root", name: "Projects" }); - return parts; + const openFolder = (item) => { + const id = String(item.id); + setCurrentPath(id); + setBreadcrumbs((current) => [...current, { id, name: item.name, item }]); + scrollRef.current?.scrollTo({ top: 0 }); }; const constructFullPath = (item) => { if (!item) return ""; - if (item.path === "root" || !item.path) return item.name; - - const parts = [item.name]; - let currParentId = item.path; - - // Safety break to prevent infinite loops - let attempts = 0; - while (currParentId && currParentId !== "root" && attempts < 100) { - const parent = folderById.get(String(currParentId)); - if (parent) { - parts.unshift(parent.name); - currParentId = parent.path; - } else { - break; - } - attempts++; - } - return parts.join("/"); + return [...breadcrumbs.slice(1).map((part) => part.name), item.name].join( + "/", + ); }; const handleSelectCurrentDirectory = () => { @@ -162,10 +196,13 @@ const FilePickerModal = ({ onSelect({ name: "", path: "root", is_folder: true, logical_path: "" }); // Root return; } - const currentFolder = allData.find((f) => String(f.id) === currentPath); - if (currentFolder) { - const fullPath = constructFullPath(currentFolder); - onSelect({ ...currentFolder, logical_path: fullPath }); + const currentFolder = breadcrumbs[breadcrumbs.length - 1]; + if (currentFolder?.item) { + const fullPath = breadcrumbs + .slice(1) + .map((part) => part.name) + .join("/"); + onSelect({ ...currentFolder.item, logical_path: fullPath }); } }; @@ -237,7 +274,7 @@ const FilePickerModal = ({ message.success( `Uploaded ${uploaded} file${uploaded > 1 ? "s" : ""} to this folder`, ); - await loadAllData(); + await filesQuery.refetch(); } } finally { setUploading(false); @@ -293,7 +330,7 @@ const FilePickerModal = ({ ) : null } width={600} - bodyStyle={{ padding: 0 }} + styles={{ body: { padding: 0 } }} >
- - {getBreadcrumbs().map((b) => ( - + ({ + key: breadcrumb.id, + title: ( - - ))} - + ), + }))} + />
)} -
- {loading ? ( +
+ {filesQuery.isPending ? (
+ ) : loadError ? ( + } + onClick={() => filesQuery.refetch()} + > + Try again + + ) : null + } + /> + ) : items.length === 0 ? ( + ) : ( - ( - { - if (item.is_folder) { - setCurrentPath(String(item.id)); - } else { - if (selectionType === "file") { - const fullPath = constructFullPath(item); - onSelect({ ...item, logical_path: fullPath }); - } - } - }} - actions={[ - item.is_folder && ( - - ), - (selectionType === "file" || - selectionType === "fileOrDirectory") && - !item.is_folder && ( - - ), - (selectionType === "directory" || - selectionType === "fileOrDirectory") && - item.is_folder && ( - - ), - ]} - > - - ) : isImageFile(item) ? ( -
- {previewStatus[item.id] !== "loaded" && ( - - )} - {previewStatus[item.id] !== "error" && ( - {item.name} markPreviewLoaded(item.id)} - onError={() => markPreviewError(item.id)} + } + } + }} + actions={[ + item.is_folder && ( + + ), + (selectionType === "file" || + selectionType === "fileOrDirectory") && + !item.is_folder && ( + + ), + (selectionType === "directory" || + selectionType === "fileOrDirectory") && + item.is_folder && ( + + ), + ]} + > + - )} -
- ) : ( - - ) - } - title={ - - {item.name} - {item.is_folder && - (item.path === "root" || !item.path) && - item.physical_path && ( - - Mounted - - )} - - } - description={item.size ? item.size : null} - /> -
+ {previewStatus[item.id] !== "loaded" && ( + + )} + {previewStatus[item.id] !== "error" && ( + {item.name} markPreviewLoaded(item.id)} + onError={() => markPreviewError(item.id)} + style={{ + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + objectFit: "cover", + opacity: + previewStatus[item.id] === "loaded" ? 1 : 0, + transition: "opacity 0.2s ease", + }} + /> + )} +
+ ) : ( + + ) + } + title={ + + {item.name} + {item.is_folder && + (item.path === "root" || !item.path) && + item.physical_path && ( + + Mounted + + )} + + } + description={item.size ? item.size : null} + /> + +
+ ); + })} + {filesQuery.isFetchingNextPage && ( +
+ +
)} - /> + )} diff --git a/client/src/components/FilePickerModal.test.js b/client/src/components/FilePickerModal.test.js new file mode 100644 index 00000000..50fa08fe --- /dev/null +++ b/client/src/components/FilePickerModal.test.js @@ -0,0 +1,110 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import FilePickerModal from "./FilePickerModal"; +import { apiClient } from "../api"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createAppQueryClient } from "../queryClient"; + +jest.mock("../api", () => ({ + apiClient: { + get: jest.fn(), + post: jest.fn(), + defaults: { baseURL: "http://localhost:4242" }, + }, +})); + +describe("FilePickerModal", () => { + const renderPicker = (props = {}) => { + const queryClient = createAppQueryClient(); + return render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + window.matchMedia = jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); + }); + + it("keeps a retryable error state when files cannot be loaded", async () => { + apiClient.get + .mockRejectedValueOnce(new Error("Network Error")) + .mockResolvedValueOnce({ data: [] }); + + renderPicker(); + + expect(await screen.findByText("Files unavailable")).toBeTruthy(); + expect( + screen.getByText("Check the server connection and try again."), + ).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /try again/i })); + + await waitFor(() => expect(apiClient.get).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(screen.queryByText("Files unavailable")).toBeNull(), + ); + }); + + it("requests bounded pages for the visible folder", async () => { + apiClient.get + .mockResolvedValueOnce({ + data: { + items: Array.from({ length: 100 }, (_, index) => ({ + id: index + 1, + name: `volume-${index + 1}.tif`, + path: "root", + is_folder: false, + })), + total: 101, + offset: 0, + limit: 100, + has_more: true, + }, + }) + .mockResolvedValueOnce({ + data: { + items: [ + { + id: 101, + name: "volume-101.tif", + path: "root", + is_folder: false, + }, + ], + total: 101, + offset: 100, + limit: 100, + has_more: false, + }, + }); + + renderPicker(); + + await waitFor(() => expect(apiClient.get).toHaveBeenCalled()); + expect(apiClient.get.mock.calls[0][1].params).toEqual({ + parent: "root", + offset: 0, + limit: 100, + volume_only: false, + }); + expect(apiClient.get.mock.calls[0][1].signal).toBeDefined(); + expect(apiClient.get.mock.calls.length).toBeLessThanOrEqual(2); + }); +}); diff --git a/client/src/components/workflow/WorkflowOperationsPanel.css b/client/src/components/workflow/WorkflowOperationsPanel.css new file mode 100644 index 00000000..44877c53 --- /dev/null +++ b/client/src/components/workflow/WorkflowOperationsPanel.css @@ -0,0 +1,68 @@ +.workflow-operations-panel__loading { + align-items: center; + display: flex; + gap: 8px; + justify-content: center; + min-height: 96px; +} + +.workflow-operations-panel__list { + border-block: 1px solid var(--seg-border-subtle, #e4ded2); +} + +.workflow-operation-row { + align-items: center; + display: grid; + gap: 12px; + grid-template-columns: minmax(0, 1fr) auto; + min-height: 76px; + padding: 10px 2px; +} + +.workflow-operation-row + .workflow-operation-row { + border-top: 1px solid var(--seg-border-subtle, #e4ded2); +} + +.workflow-operation-row__main { + display: grid; + gap: 6px; + min-width: 0; +} + +.workflow-operation-row__heading, +.workflow-operation-row__metadata { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + min-width: 0; +} + +.workflow-operation-row__reference, +.workflow-operation-row__error { + overflow-wrap: anywhere; +} + +.workflow-operation-row__metadata .ant-typography { + font-size: 12px; +} + +.workflow-operation-row__actions { + justify-self: end; +} + +.workflow-operations-panel__syncing { + align-self: flex-end; + font-size: 12px; +} + +@media (max-width: 640px) { + .workflow-operation-row { + align-items: stretch; + grid-template-columns: minmax(0, 1fr); + } + + .workflow-operation-row__actions { + justify-self: start; + } +} diff --git a/client/src/components/workflow/WorkflowOperationsPanel.js b/client/src/components/workflow/WorkflowOperationsPanel.js new file mode 100644 index 00000000..c7377c9d --- /dev/null +++ b/client/src/components/workflow/WorkflowOperationsPanel.js @@ -0,0 +1,379 @@ +import React, { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Button, + Card, + Empty, + Popconfirm, + Progress, + Space, + Spin, + Tag, + Tooltip, + Typography, +} from "antd"; +import { RedoOutlined, ReloadOutlined, StopOutlined } from "@ant-design/icons"; +import { + cancelWorkflowOperation, + listWorkflowOperations, + runWorkflowCommand, +} from "../../api"; +import { getApiErrorMessage } from "../../errors/apiError"; +import { useWorkflow } from "../../contexts/WorkflowContext"; +import "./WorkflowOperationsPanel.css"; + +const { Text } = Typography; +const ACTIVE_OPERATION_STATUSES = new Set(["queued", "running"]); +const CANCELLABLE_OPERATION_STATUSES = new Set(["queued", "running"]); +const OPERATION_POLL_INTERVAL_MS = 2500; + +const STATUS_CONFIG = { + queued: { label: "Queued", color: "default" }, + running: { label: "Running", color: "processing" }, + succeeded: { label: "Succeeded", color: "success" }, + failed: { label: "Failed", color: "error" }, + cancelled: { label: "Cancelled", color: "warning" }, +}; + +export const hasActiveOperations = (operations = []) => + operations.some((operation) => + ACTIVE_OPERATION_STATUSES.has(operation?.status), + ); + +export const getOperationsRefetchInterval = (query) => + hasActiveOperations(query?.state?.data) ? OPERATION_POLL_INTERVAL_MS : false; + +export const getWorkflowOperationsQueryOptions = ( + workflowId, + { compact = false } = {}, +) => ({ + queryKey: ["workflow", workflowId, "operations"], + queryFn: () => + listWorkflowOperations(workflowId, { limit: compact ? 6 : 12 }), + enabled: Boolean(workflowId), + refetchInterval: getOperationsRefetchInterval, + refetchOnReconnect: "always", +}); + +const hasReplayableInput = (input) => + Boolean( + input && + typeof input === "object" && + !Array.isArray(input) && + Object.keys(input).length, + ); + +export const canRetryOperation = (operation) => { + if (operation?.status !== "failed" || !operation?.command_id) return false; + + const retry = operation.metadata?.retry; + if ( + retry?.allowed === true && + retry?.kind === "workflow_command" && + hasReplayableInput(operation.input) + ) { + return true; + } + + return Boolean( + operation.metadata?.command_type === "start_training" && + operation.metadata?.execution_scope === "worker_submission" && + [503, 504].includes(Number(operation.error?.status_code)) && + hasReplayableInput(operation.input), + ); +}; + +const operationLabel = (value) => { + const normalized = String(value || "operation") + .replace(/^agent_action:/, "") + .replace(/[_:]+/g, " ") + .trim(); + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +}; + +const operationErrorMessage = (operation) => { + const error = operation?.error; + if (!error) return ""; + if (typeof error === "string") return error; + if (typeof error.detail === "string") return error.detail; + if (typeof error.message === "string") return error.message; + if (typeof error.error === "string") return error.error; + return "The operation did not complete."; +}; + +const formatUpdatedAt = (value) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString([], { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +}; + +function OperationRow({ operation, cancelling, retrying, onCancel, onRetry }) { + const status = STATUS_CONFIG[operation.status] || { + label: operationLabel(operation.status), + color: "default", + }; + const cancellationRequested = Boolean(operation.cancellation_requested_at); + const cancellationPending = + cancellationRequested && operation.status === "running"; + const cancellationAcknowledged = + cancellationRequested && operation.status === "cancelled"; + const canCancel = + CANCELLABLE_OPERATION_STATUSES.has(operation.status) && + !cancellationRequested; + const canRetry = canRetryOperation(operation); + const progress = Number(operation.progress); + const hasProgress = + operation.progress !== null && + operation.progress !== undefined && + Number.isFinite(progress); + const errorMessage = operationErrorMessage(operation); + + return ( +
+
+
+ {operationLabel(operation.operation_type)} + + {status.label} + {cancellationPending && ( + Cancellation requested + )} + {cancellationAcknowledged && ( + Cancellation acknowledged + )} + +
+ +
+ + Ref {operation.correlation_id || `operation-${operation.id}`} + + {formatUpdatedAt(operation.updated_at) && ( + + Updated {formatUpdatedAt(operation.updated_at)} + + )} + {operation.attempt_count > 0 && ( + Attempt {operation.attempt_count} + )} +
+ + {hasProgress && operation.status === "running" && ( + + )} + + {errorMessage && operation.status === "failed" && ( + + {errorMessage} + + )} +
+ + {(canCancel || canRetry) && ( + + {canRetry && ( + + )} + {canCancel && ( + onCancel(operation)} + > + + + )} + + )} +
+ ); +} + +function WorkflowOperationsPanel({ compact = false }) { + const workflow = useWorkflow()?.workflow; + const workflowId = workflow?.id; + const queryClient = useQueryClient(); + const queryKey = ["workflow", workflowId, "operations"]; + const [actionError, setActionError] = useState(""); + + const operationsQuery = useQuery( + getWorkflowOperationsQueryOptions(workflowId, { compact }), + ); + + const refreshOperations = () => { + setActionError(""); + operationsQuery.refetch(); + }; + + const updateOperation = (updatedOperation) => { + queryClient.setQueryData(queryKey, (current = []) => + current.map((operation) => + operation.id === updatedOperation.id ? updatedOperation : operation, + ), + ); + }; + + const cancelMutation = useMutation({ + mutationFn: (operation) => + cancelWorkflowOperation( + workflowId, + operation.id, + "Cancelled from the workflow operations panel.", + ), + onMutate: () => setActionError(""), + onSuccess: (operation) => { + updateOperation(operation); + queryClient.invalidateQueries({ queryKey }); + }, + onError: (error) => setActionError(getApiErrorMessage(error)), + }); + + const retryMutation = useMutation({ + mutationFn: (operation) => + runWorkflowCommand(workflowId, operation.command_id), + onMutate: () => setActionError(""), + onSuccess: () => queryClient.invalidateQueries({ queryKey }), + onError: (error) => setActionError(getApiErrorMessage(error)), + }); + + if (!workflowId) return null; + + const operations = operationsQuery.data || []; + const activeCount = operations.filter((operation) => + ACTIVE_OPERATION_STATUSES.has(operation.status), + ).length; + + return ( + + Operations + {activeCount > 0 && ( + {activeCount} active + )} + + } + extra={ + + + + + )} {selectionBox && (
{ await waitFor(() => { expect(apiClient.get).toHaveBeenCalledWith("/files", { - params: { parent: "root" }, + params: { parent: "root", offset: 0, limit: 200 }, }); }); expect(apiClient.get).not.toHaveBeenCalledWith("/files"); }); + it("loads additional bounded pages only when requested", async () => { + let filePage = 0; + apiClient.get.mockImplementation((url) => { + if (url === "/files/project-suggestions") { + return Promise.resolve({ data: [] }); + } + if (url !== "/files") { + return Promise.resolve({ data: { exists: false, profile: null } }); + } + filePage += 1; + if (filePage === 1) { + return Promise.resolve({ + data: { + items: [ + { + id: 1, + name: "first-volume.tif", + path: "root", + is_folder: false, + size: "1KB", + type: "image/tiff", + }, + ], + total: 201, + offset: 0, + limit: 200, + has_more: true, + }, + }); + } + return Promise.resolve({ + data: { + items: [ + { + id: 2, + name: "last-volume.tif", + path: "root", + is_folder: false, + size: "1KB", + type: "image/tiff", + }, + ], + total: 201, + offset: 200, + limit: 200, + has_more: false, + }, + }); + }); + + renderFilesManager(); + fireEvent.click(await screen.findByText("Load more files")); + + await waitFor(() => + expect(apiClient.get).toHaveBeenCalledWith("/files", { + params: { parent: "root", offset: 200, limit: 200 }, + }), + ); + expect(await screen.findByText("last-volume.tif")).toBeTruthy(); + expect(screen.queryByText("Load more files")).toBeNull(); + }); + it("opens a confirmation modal before registering a suggested smoke project", async () => { mockProjectSuggestionResponses([smokeSuggestion]); apiClient.post.mockResolvedValue({ @@ -506,7 +568,7 @@ describe("FilesManager", () => { ); }); expect(apiClient.get).toHaveBeenCalledWith("/files", { - params: { parent: "7" }, + params: { parent: "7", offset: 0, limit: 200 }, }); await continueWithProjectContext(); await waitFor(() => { @@ -1049,7 +1111,7 @@ describe("FilesManager", () => { await screen.findByText("Confirm project basics"); await waitFor(() => { expect(apiClient.get).toHaveBeenCalledWith("/files", { - params: { parent: "7" }, + params: { parent: "7", offset: 0, limit: 200 }, }); expect(mockWorkflowContext.consumeRuntimeAction).toHaveBeenCalledWith( "choose-data-action", diff --git a/client/src/views/ProjectProgress.js b/client/src/views/ProjectProgress.js index 1a4eca99..c5c99066 100644 --- a/client/src/views/ProjectProgress.js +++ b/client/src/views/ProjectProgress.js @@ -19,6 +19,7 @@ import { } from "@ant-design/icons"; import { useWorkflow } from "../contexts/WorkflowContext"; import { logClientEvent } from "../logging/appEventLog"; +import WorkflowOperationsPanel from "../components/workflow/WorkflowOperationsPanel"; const { Text, Title } = Typography; @@ -362,6 +363,10 @@ function ProjectProgress() { +
+ +
+ {(blockers.length > 0 || actions.length > 0) && (
({ logClientEvent: jest.fn(), })); +jest.mock("../components/workflow/WorkflowOperationsPanel", () => () => ( +
Operations
+)); + describe("ProjectProgress", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/client/src/views/Views.js b/client/src/views/Views.js index 8cd60b5c..1f0c6006 100644 --- a/client/src/views/Views.js +++ b/client/src/views/Views.js @@ -7,7 +7,9 @@ import { ThunderboltOutlined, BugOutlined, MessageOutlined, + PlusOutlined, ProjectOutlined, + QuestionCircleOutlined, } from "@ant-design/icons"; import FilesManager from "./FilesManager"; import Visualization from "./Visualization"; @@ -59,7 +61,7 @@ const workflowPhaseTone = { evaluate: "#7c3aed", }; -function WorkflowOverviewStrip({ overview, workflow, onNavigate }) { +function WorkflowOverviewStrip({ overview, workflow, onNavigate, onAction }) { const summary = overview?.volume_summary || {}; const phase = overview?.phase || workflow?.stage || "setup"; const phaseLabel = @@ -156,7 +158,9 @@ function WorkflowOverviewStrip({ overview, workflow, onNavigate }) { {action && ( -