diff --git a/.agents/rules/pytc-client.md b/.agents/rules/pytc-client.md new file mode 100644 index 00000000..846f1ae1 --- /dev/null +++ b/.agents/rules/pytc-client.md @@ -0,0 +1,5 @@ +--- +trigger: always_on +glob: +description: +--- diff --git a/.logs/start/api-server.log b/.logs/start/api-server.log new file mode 100644 index 00000000..356d2b55 --- /dev/null +++ b/.logs/start/api-server.log @@ -0,0 +1,17 @@ +INFO: Started server process [1802573] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:4242 (Press CTRL+C to quit) +================================================================================ +EHTOOL ROUTER MODULE LOADED - VERSION: DEBUG v2 +================================================================================ +INFO: 127.0.0.1:52216 - "GET /health HTTP/1.1" 200 OK +INFO: 127.0.0.1:39000 - "GET /api/pm/data HTTP/1.1" 200 OK +INFO: 127.0.0.1:38992 - "GET /files?parent=root HTTP/1.1" 200 OK +INFO: 127.0.0.1:39000 - "GET /api/pm/data HTTP/1.1" 200 OK +INFO: 127.0.0.1:55914 - "OPTIONS /api/pm/login HTTP/1.1" 200 OK +INFO: 127.0.0.1:55930 - "POST /api/pm/login HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [1802573] diff --git a/.logs/start/data-server.log b/.logs/start/data-server.log new file mode 100644 index 00000000..b7f71d64 --- /dev/null +++ b/.logs/start/data-server.log @@ -0,0 +1 @@ +127.0.0.1 - - [26/Mar/2026 14:48:47] "GET / HTTP/1.1" 200 - diff --git a/.logs/start/pytc-server.log b/.logs/start/pytc-server.log new file mode 100644 index 00000000..cfd53951 --- /dev/null +++ b/.logs/start/pytc-server.log @@ -0,0 +1,21 @@ +INFO: Started server process [1802650] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:4243 (Press CTRL+C to quit) + +================================================================================ +SERVER_PYTC STARTING UP +Python executable: /home/sam/Workshop/pytc-client/.venv/bin/python3 +Working directory: /home/sam/Workshop/pytc-client +================================================================================ + + +================================================================================ +SERVER_PYTC: Starting Uvicorn server on port 4243... +================================================================================ + +INFO: 127.0.0.1:50152 - "GET /hello HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [1802650] diff --git a/.logs/start/react-build.log b/.logs/start/react-build.log new file mode 100644 index 00000000..b675bfdf --- /dev/null +++ b/.logs/start/react-build.log @@ -0,0 +1,82 @@ + +> PyTC Client@0.1.0 build +> cross-env CI=false react-scripts build + +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +Creating an optimized production build... +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +Compiled with warnings. + +[eslint] +src/components/FilePickerModal.js + Line 102:35: Function declared in a loop contains unsafe references to variable(s) 'curr' no-loop-func + Line 124:35: Function declared in a loop contains unsafe references to variable(s) 'currParentId' no-loop-func + Line 243:15: The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md jsx-a11y/anchor-is-valid + +src/components/YamlFileUploader.js + Line 347:6: React Hook useEffect has missing dependencies: 'getCurrentConfig' and 'syncYamlContext'. Either include them or remove the dependency array react-hooks/exhaustive-deps + Line 361:6: React Hook useEffect has missing dependencies: 'getCurrentConfig', 'setCurrentConfig', and 'updateInputSelectorInformation'. Either include them or remove the dependency array react-hooks/exhaustive-deps + Line 374:6: React Hook useMemo has a missing dependency: 'getCurrentConfig'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +src/views/FilesManager.js + Line 81:31: Function declared in a loop contains unsafe references to variable(s) 'current' no-loop-func + Line 420:27: Function declared in a loop contains unsafe references to variable(s) 'curr' no-loop-func + Line 772:32: Function declared in a loop contains unsafe references to variable(s) 'descendantSizeKb', 'descendantSizeKb', 'descendantSizeKb', 'descendantSizeKb' no-loop-func + Line 1069:6: React Hook useEffect has missing dependencies: 'finishCreateFolder', 'finishRename', 'handleCopy', 'handleCut', 'handleDelete', and 'handlePaste'. Either include them or remove the dependency array react-hooks/exhaustive-deps + +src/views/Views.js + Line 11:3: 'BarChartOutlined' is defined but never used no-unused-vars + +src/views/mask-proofreading/MaskProofreading.js + Line 5:10: 'ehToolSession' is assigned a value but never used no-unused-vars + Line 5:25: 'setEhToolSession' is assigned a value but never used no-unused-vars + Line 6:10: 'refreshTrigger' is assigned a value but never used no-unused-vars + Line 6:26: 'setRefreshTrigger' is assigned a value but never used no-unused-vars + +src/views/project-manager/AnnotationDashboard.js + Line 340:5: 'isAdmin' is assigned a value but never used no-unused-vars + +src/views/project-manager/ModelQualityDashboard.js + Line 40:7: 'CONFUSION_MATRIX' is assigned a value but never used no-unused-vars + +src/views/project-manager/ProjectManager.js + Line 19:3: 'UserOutlined' is defined but never used no-unused-vars + Line 20:3: 'CrownOutlined' is defined but never used no-unused-vars + Line 38:61: 'loading' is assigned a value but never used no-unused-vars + +src/views/project-manager/ProjectManagerLogin.js + Line 14:11: 'success' is assigned a value but never used no-unused-vars + +src/views/project-manager/ProofreaderProgress.js + Line 163:6: React Hook useMemo has a missing dependency: 'getStats'. Either include it or remove the dependency array react-hooks/exhaustive-deps + +src/views/project-manager/QuotaManagement.js + Line 29:3: 'SaveOutlined' is defined but never used no-unused-vars + Line 98:5: 'activeWorker' is assigned a value but never used no-unused-vars + +src/views/project-manager/VolumeTracker.js + Line 16:3: 'Popconfirm' is defined but never used no-unused-vars + Line 127:6: React Hook useMemo has an unnecessary dependency: 'isAdmin'. Either exclude it or remove the dependency array react-hooks/exhaustive-deps + Line 184:15: 'cfg' is assigned a value but never used no-unused-vars + +Search for the keywords to learn more about each warning. +To ignore, add // eslint-disable-next-line to the line before. + +File sizes after gzip: + + 553.16 kB build/static/js/main.be80df26.js + 465 B build/static/css/main.40821645.css + +The bundle size is significantly larger than recommended. +Consider reducing it with code splitting: https://goo.gl/9VhYWB +You can also analyze the project dependencies: https://goo.gl/LeUzfb + +The project was built assuming it is hosted at ./. +You can control this with the homepage field in your package.json. + +The build folder is ready to be deployed. + +Find out more about deployment here: + + https://cra.link/deployment + diff --git a/.logs/start/react-dev.log b/.logs/start/react-dev.log new file mode 100644 index 00000000..aa502f28 --- /dev/null +++ b/.logs/start/react-dev.log @@ -0,0 +1,21 @@ + +> PyTC Client@0.1.0 start +> react-scripts start + +[baseline-browser-mapping] The data in this module is over two months old. To ensure accurate Baseline data, please update: `npm i baseline-browser-mapping@latest -D` +(node:1802959) [DEP_WEBPACK_DEV_SERVER_ON_AFTER_SETUP_MIDDLEWARE] DeprecationWarning: 'onAfterSetupMiddleware' option is deprecated. Please use the 'setupMiddlewares' option. +(Use `node --trace-deprecation ...` to show where the warning was created) +(node:1802959) [DEP_WEBPACK_DEV_SERVER_ON_BEFORE_SETUP_MIDDLEWARE] DeprecationWarning: 'onBeforeSetupMiddleware' option is deprecated. Please use the 'setupMiddlewares' option. +Starting the development server... + +Compiled successfully! + +You can now view PyTC Client in the browser. + + Local: http://localhost:3000 + On Your Network: http://10.101.188.107:3000 + +Note that the development build is not optimized. +To create a production build, use npm run build. + +webpack compiled successfully diff --git a/client/package.json b/client/package.json index 7a080180..0107480c 100644 --- a/client/package.json +++ b/client/package.json @@ -28,6 +28,7 @@ }, "scripts": { "start": "react-scripts start", + "test": "react-scripts test", "build": "cross-env CI=false react-scripts build", "electron": "electron ." }, diff --git a/client/src/App.js b/client/src/App.js index 6d14652d..9c26a2c7 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -3,6 +3,7 @@ import "./App.css"; import Views from "./views/Views"; import { AppContext, ContextWrapper } from "./contexts/GlobalContext"; import { YamlContextWrapper } from "./contexts/YamlContext"; +import { WorkflowProvider } from "./contexts/WorkflowContext"; function CacheBootstrapper({ children }) { const { resetFileState } = useContext(AppContext); @@ -38,11 +39,13 @@ function App() { return ( - -
- -
-
+ + +
+ +
+
+
); diff --git a/client/src/__tests__/agentProposalCards.test.js b/client/src/__tests__/agentProposalCards.test.js new file mode 100644 index 00000000..afd415ea --- /dev/null +++ b/client/src/__tests__/agentProposalCards.test.js @@ -0,0 +1,78 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import AgentProposalCard from "../components/chat/AgentProposalCard"; + +jest.mock("antd", () => ({ + Button: ({ children, ...props }) => ( + + ), + Space: ({ children }) =>
{children}
, + Tag: ({ children }) => {children}, + Typography: { + Text: ({ children }) => {children}, + }, +})); + +describe("AgentProposalCard", () => { + it("renders hotspot proposal fields and approve/reject actions", () => { + const onApprove = jest.fn(); + const onReject = jest.fn(); + const proposal = { + type: "prioritize_failure_hotspots", + rationale: "Focus annotation where the model fails most often.", + target_dataset: "set-a", + hotspots: ["z:11", "z:12"], + priority_metric: "error_rate", + min_failure_rate: 0.2, + }; + + render( + , + ); + + expect(screen.getByText("Prioritize Failure Hotspots")).toBeTruthy(); + expect(screen.getByText("set-a")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Approve" })); + fireEvent.click(screen.getByRole("button", { name: "Reject" })); + + expect(onApprove).toHaveBeenCalledWith(proposal); + expect(onReject).toHaveBeenCalledWith(proposal); + }); + + it("supports correction-impact cards with compact rationale", () => { + const proposal = { + proposal_type: "preview_correction_impact", + rationale: "A".repeat(200), + target_metric: "f1", + expected_delta: "+0.05", + sample_size: 128, + confidence: "high", + }; + + render(); + + expect(screen.getByText("Preview Correction Impact")).toBeTruthy(); + expect(screen.getByText(/A{157}…/)).toBeTruthy(); + expect(screen.getByText("+0.05")).toBeTruthy(); + }); + + it("renders fallback proposal content", () => { + render( + , + ); + + expect(screen.getByText("Agent Proposal")).toBeTruthy(); + expect(screen.getByText("Keep behavior stable")).toBeTruthy(); + expect(screen.getByText("bar")).toBeTruthy(); + }); +}); diff --git a/client/src/__tests__/workflowTimelineFilters.test.js b/client/src/__tests__/workflowTimelineFilters.test.js new file mode 100644 index 00000000..3b60804b --- /dev/null +++ b/client/src/__tests__/workflowTimelineFilters.test.js @@ -0,0 +1,34 @@ +import { + DEFAULT_TIMELINE_FILTERS, + filterTimelineEvents, +} from "../contexts/workflow/timelineFilters"; + +const EVENTS = [ + { id: "1", actor: "user", event_type: "dataset.loaded" }, + { id: "2", actor: "agent", event_type: "agent.proposal_created" }, + { id: "3", actor: "system", event_type: "inference.completed" }, + { id: "4", actor: "agent", event_type: "agent.proposal_approved" }, +]; + +describe("workflow timeline filters", () => { + it("preserves the full timeline by default", () => { + const visible = filterTimelineEvents(EVENTS, DEFAULT_TIMELINE_FILTERS); + expect(visible).toHaveLength(EVENTS.length); + expect(visible).toBe(EVENTS); + }); + + it("filters by actor and event type combinations", () => { + expect(filterTimelineEvents(EVENTS, { actor: "agent", eventType: "" })).toEqual([ + EVENTS[1], + EVENTS[3], + ]); + + expect( + filterTimelineEvents(EVENTS, { actor: "agent", eventType: "approved" }), + ).toEqual([EVENTS[3]]); + + expect( + filterTimelineEvents(EVENTS, { actor: "all", eventType: "proposal" }), + ).toEqual([EVENTS[1], EVENTS[3]]); + }); +}); diff --git a/client/src/api.js b/client/src/api.js index fffe5f26..e3dcecb0 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -35,6 +35,7 @@ const getErrorDetailMessage = (detail) => { return detail.map(getErrorDetailMessage).filter(Boolean).join("; "); } if (typeof detail === "object") { + if (detail.user_message) return String(detail.user_message); const nestedUpstream = detail.upstream_body !== undefined ? getErrorDetailMessage(detail.upstream_body) @@ -52,7 +53,7 @@ const getErrorDetailMessage = (detail) => { return String(detail); }; -export async function getNeuroglancerViewer(image, label, scales) { +export async function getNeuroglancerViewer(image, label, scales, workflowId = null) { try { const url = `${BASE_URL}/neuroglancer`; if (hasBrowserFile(image)) { @@ -70,6 +71,9 @@ export async function getNeuroglancerViewer(image, label, scales) { ); } formData.append("scales", JSON.stringify(scales)); + if (workflowId) { + formData.append("workflow_id", String(workflowId)); + } const res = await axios.post(url, formData); return res.data; } @@ -78,6 +82,7 @@ export async function getNeuroglancerViewer(image, label, scales) { image: buildFilePath(image), label: buildFilePath(label), scales, + workflow_id: workflowId, }); const res = await axios.post(url, data); return res.data; @@ -107,6 +112,9 @@ function handleError(error) { if (error.response) { const detail = error.response.data?.detail; const detailMessage = getErrorDetailMessage(detail); + if (detail?.user_message) { + throw new Error(detailMessage); + } throw new Error( `${error.response.status}: ${detailMessage || error.response.statusText}`, ); @@ -141,6 +149,7 @@ export async function startModelTraining( logPath, outputPath, configOriginPath = "", + workflowId = null, ) { try { console.log("[API] ===== Starting Training Configuration ====="); @@ -178,6 +187,7 @@ export async function startModelTraining( outputPath, // TensorBoard will use this instead trainingConfig: configToSend, configOriginPath, + workflow_id: workflowId, }); console.log("[API] Request payload size:", data.length, "bytes"); @@ -232,6 +242,7 @@ export async function startModelInference( outputPath, checkpointPath, configOriginPath = "", + workflowId = null, ) { console.log("\n========== API.JS: START_MODEL_INFERENCE CALLED =========="); console.log("[API] Function arguments:"); @@ -293,6 +304,7 @@ export async function startModelInference( outputPath, inferenceConfig: configToSend, configOriginPath, + workflow_id: workflowId, }; console.log("[API] Payload structure:"); @@ -369,6 +381,11 @@ export async function queryChatBot(query, conversationId) { }); return res.data; } catch (error) { + if (error.message === "Network Error") { + throw new Error( + "The AI assistant could not reach the PyTC API or configured language model service. Please contact your system administrator with this error: Network Error", + ); + } handleError(error); } } @@ -438,6 +455,11 @@ export async function queryHelperChat(taskKey, query, fieldContext) { }); return res.data?.response; } catch (error) { + if (error.message === "Network Error") { + throw new Error( + "The AI helper could not reach the PyTC API or configured language model service. Please contact your system administrator with this error: Network Error", + ); + } handleError(error); } } @@ -462,3 +484,151 @@ export async function getConfigPresetContent(path) { export async function getModelArchitectures() { return makeApiRequest("pytc/architectures", "get"); } + +// ── Project Manager persistence ─────────────────────────────────────────────── + +export async function getPMData() { + try { + const res = await apiClient.get("/api/pm/data"); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function savePMData(state) { + try { + const res = await apiClient.post("/api/pm/data", state); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function resetPMData() { + try { + const res = await apiClient.post("/api/pm/data/reset"); + return res.data; + } catch (error) { + handleError(error); + } +} + +// ── Workflow spine ─────────────────────────────────────────────────────────── + +export async function getCurrentWorkflow() { + try { + const res = await apiClient.get("/api/workflows/current"); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function updateWorkflow(workflowId, patch) { + try { + const res = await apiClient.patch(`/api/workflows/${workflowId}`, patch); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function listWorkflowEvents(workflowId) { + try { + const res = await apiClient.get(`/api/workflows/${workflowId}/events`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowHotspots(workflowId) { + try { + const res = await apiClient.get(`/api/workflows/${workflowId}/hotspots`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowImpactPreview(workflowId) { + try { + const res = await apiClient.get(`/api/workflows/${workflowId}/impact-preview`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function getWorkflowMetrics(workflowId) { + try { + const res = await apiClient.get(`/api/workflows/${workflowId}/metrics`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function exportWorkflowBundle(workflowId) { + try { + const res = await apiClient.post(`/api/workflows/${workflowId}/export-bundle`); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function appendWorkflowEvent(workflowId, event) { + try { + const res = await apiClient.post(`/api/workflows/${workflowId}/events`, event); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function createAgentAction(workflowId, action) { + try { + const res = await apiClient.post( + `/api/workflows/${workflowId}/agent-actions`, + action, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function approveAgentAction(workflowId, eventId) { + try { + const res = await apiClient.post( + `/api/workflows/${workflowId}/agent-actions/${eventId}/approve`, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function rejectAgentAction(workflowId, eventId) { + try { + const res = await apiClient.post( + `/api/workflows/${workflowId}/agent-actions/${eventId}/reject`, + ); + return res.data; + } catch (error) { + handleError(error); + } +} + +export async function queryWorkflowAgent(workflowId, query) { + try { + const res = await apiClient.post(`/api/workflows/${workflowId}/agent/query`, { + query, + }); + return res.data; + } catch (error) { + handleError(error); + } +} diff --git a/client/src/components/Chatbot.js b/client/src/components/Chatbot.js index 17875031..38de04e0 100644 --- a/client/src/components/Chatbot.js +++ b/client/src/components/Chatbot.js @@ -27,6 +27,8 @@ import { } from "../api"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import WorkflowTimeline from "./WorkflowTimeline"; +import { useWorkflow } from "../contexts/WorkflowContext"; const { TextArea } = Input; const { Text } = Typography; @@ -41,6 +43,20 @@ const GREETING = { const truncate = (str, n = 50) => str.length > n ? str.slice(0, n).trimEnd() + "…" : str; +const WORKFLOW_QUERY_TERMS = [ + "workflow", + "next", + "stage", + "retrain", + "training", + "corrected", + "proofread", + "mask", + "inference", + "visualize", + "evaluate", +]; + /* ═══════════════════════════════════════════════════════════════════════════ */ function Chatbot({ onClose }) { @@ -54,6 +70,15 @@ function Chatbot({ onClose }) { const [isLoadingConvo, setIsLoadingConvo] = useState(false); const lastMessageRef = useRef(null); + const workflowContext = useWorkflow(); + + const shouldUseWorkflowAgent = (query) => { + if (!workflowContext?.workflow?.id || !workflowContext?.queryAgent) { + return false; + } + const lower = query.toLowerCase(); + return WORKFLOW_QUERY_TERMS.some((term) => lower.includes(term)); + }; /* ── scroll ────────────────────────────────────────────────────────────── */ const scrollToBottom = useCallback(() => { @@ -119,6 +144,18 @@ function Chatbot({ onClose }) { setMessages((prev) => [...prev, { role: "user", content: query }]); setIsSending(true); try { + if (shouldUseWorkflowAgent(query)) { + const data = await workflowContext.queryAgent(query); + const response = + data?.response || + "I could not inspect the workflow state for that request."; + setMessages((prev) => [ + ...prev, + { role: "assistant", content: response }, + ]); + return; + } + const data = await queryChatBot(query, activeConvoId); const response = data?.response || "Sorry, I could not generate a response."; @@ -418,6 +455,8 @@ function Chatbot({ onClose }) { + + {/* messages */}
{isLoadingConvo ? ( diff --git a/client/src/components/RuntimeLogPanel.js b/client/src/components/RuntimeLogPanel.js index 9a938a7e..1ce77453 100644 --- a/client/src/components/RuntimeLogPanel.js +++ b/client/src/components/RuntimeLogPanel.js @@ -117,7 +117,7 @@ function RuntimeLogPanel({ title, runtime, onRefresh }) { background: "#fff", border: "1px solid #f0f0f0", fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace', + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", fontSize: 12, color: "#262626", wordBreak: "break-word", @@ -142,7 +142,7 @@ function RuntimeLogPanel({ title, runtime, onRefresh }) { whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace', + "ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace", }} > {text || "No runtime logs captured yet."} diff --git a/client/src/components/WorkflowTimeline.js b/client/src/components/WorkflowTimeline.js new file mode 100644 index 00000000..f2569f9b --- /dev/null +++ b/client/src/components/WorkflowTimeline.js @@ -0,0 +1,241 @@ +import React, { useMemo, useState } from "react"; +import { Button, Empty, Input, List, Select, Space, Tag, Typography } from "antd"; +import { useWorkflow } from "../contexts/WorkflowContext"; +import AgentProposalCard from "./chat/AgentProposalCard"; +import { + DEFAULT_TIMELINE_FILTERS, + filterTimelineEvents, + normalizeTimelineFilters, + TIMELINE_ACTOR_OPTIONS, +} from "../contexts/workflow/timelineFilters"; + +const { Text } = Typography; + +const STAGE_LABELS = { + setup: "Setup", + visualization: "Visualization", + inference: "Inference", + proofreading: "Proofreading", + retraining_staged: "Retraining staged", + evaluation: "Evaluation", +}; + +const SEVERITY_COLORS = { + low: "default", + medium: "orange", + high: "red", +}; + +function formatEventTime(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} + +function WorkflowTimeline({ limit = 8 }) { + const workflowContext = useWorkflow(); + const workflow = workflowContext?.workflow; + const events = workflowContext?.events; + const hotspots = workflowContext?.hotspots || []; + const impactPreview = workflowContext?.impactPreview; + const refreshInsights = workflowContext?.refreshInsights; + const approveAgentAction = workflowContext?.approveAgentAction; + const rejectAgentAction = workflowContext?.rejectAgentAction; + const [filters, setFilters] = useState(DEFAULT_TIMELINE_FILTERS); + + const reversedEvents = useMemo(() => [...(events || [])].reverse(), [events]); + const visibleEvents = useMemo(() => { + return filterTimelineEvents(reversedEvents, filters).slice(0, limit); + }, [reversedEvents, filters, limit]); + const stageLabel = STAGE_LABELS[workflow?.stage] || workflow?.stage || "Loading"; + const topHotspot = hotspots[0] || null; + + if (!workflowContext) return null; + + return ( +
+ + + + Workflow + + + {stageLabel} + + + + + {workflow?.title || "Segmentation Workflow"} + + + + + + {(topHotspot || impactPreview) && ( +
+ {topHotspot && ( + + + Hotspot + + + {topHotspot.severity} + + {topHotspot.summary} + + )} + {impactPreview && ( +
+ + Impact + + + {` ${impactPreview.summary} (confidence: ${impactPreview.confidence})`} + +
+ )} +
+ )} + + + + setFilters((prev) => + normalizeTimelineFilters({ ...prev, eventType: event.target.value }), + ) + } + /> + + + + {visibleEvents.length === 0 ? ( + + ) : ( + { + const isPendingProposal = + event.event_type === "agent.proposal_created" && + event.approval_status === "pending"; + return ( + + Needs review + , + ] + : [] + } + > + + {event.summary} + {event.approval_status !== "not_required" && ( + + {event.approval_status} + + )} + + } + description={ +
+ + + {event.actor} + + + {event.event_type} + + + {formatEventTime(event.created_at)} + + + {isPendingProposal && ( + approveAgentAction?.(event.id)} + onReject={() => rejectAgentAction?.(event.id)} + /> + )} +
+ } + /> +
+ ); + }} + /> + )} +
+ ); +} + +export default WorkflowTimeline; diff --git a/client/src/components/WorkflowTimeline.test.js b/client/src/components/WorkflowTimeline.test.js new file mode 100644 index 00000000..4792681f --- /dev/null +++ b/client/src/components/WorkflowTimeline.test.js @@ -0,0 +1,228 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import WorkflowTimeline from "./WorkflowTimeline"; +import { WorkflowContext } from "../contexts/WorkflowContext"; + +jest.mock("antd", () => { + const React = require("react"); + const List = ({ dataSource = [], renderItem }) => ( +
+ {dataSource.map((item, index) => ( + + {renderItem(item, index)} + + ))} +
+ ); + List.Item = ({ children, actions = [] }) => ( +
+ {children} + {actions.map((action, index) => ( + {action} + ))} +
+ ); + List.Item.Meta = ({ title, description }) => ( +
+
{title}
+
{description}
+
+ ); + + const Empty = ({ description }) =>
{description}
; + Empty.PRESENTED_IMAGE_SIMPLE = "simple"; + + return { + Button: ({ children, icon, ...props }) => ( + + ), + Input: ({ ...props }) => , + Empty, + List, + Select: ({ options = [], value, onChange, ...props }) => ( + + ), + Space: ({ children }) => {children}, + Tag: ({ children }) => {children}, + Typography: { + Text: ({ children }) => {children}, + }, + }; +}); + +jest.mock("@ant-design/icons", () => { + const Icon = () => ; + return { + CheckOutlined: Icon, + CloseOutlined: Icon, + }; +}); + +jest.mock("../contexts/WorkflowContext", () => { + const React = require("react"); + const WorkflowContext = React.createContext(null); + return { + WorkflowContext, + useWorkflow: () => React.useContext(WorkflowContext), + }; +}); + +const workflow = { + id: 1, + title: "Test Workflow", + stage: "proofreading", +}; + +function renderTimeline(overrides = {}) { + const value = { + workflow, + events: [], + hotspots: [], + impactPreview: null, + refreshInsights: jest.fn(), + approveAgentAction: jest.fn(), + rejectAgentAction: jest.fn(), + ...overrides, + }; + render( + + + , + ); + return value; +} + +describe("WorkflowTimeline", () => { + it("renders workflow stage and chronological evidence", () => { + renderTimeline({ + events: [ + { + id: 1, + actor: "user", + event_type: "dataset.loaded", + stage: "proofreading", + summary: "Loaded dataset.", + approval_status: "not_required", + created_at: "2026-04-12T12:00:00Z", + }, + ], + }); + + expect(screen.getByText("Proofreading")).toBeTruthy(); + expect(screen.getByText("Loaded dataset.")).toBeTruthy(); + expect(screen.getByText("dataset.loaded")).toBeTruthy(); + }); + + it("exposes approve and reject controls for pending agent proposals", () => { + const approveAgentAction = jest.fn(); + const rejectAgentAction = jest.fn(); + renderTimeline({ + approveAgentAction, + rejectAgentAction, + events: [ + { + id: 7, + actor: "agent", + event_type: "agent.proposal_created", + stage: "proofreading", + summary: "Stage corrected masks.", + approval_status: "pending", + created_at: "2026-04-12T12:00:00Z", + }, + ], + }); + + fireEvent.click(screen.getByText("Approve")); + fireEvent.click(screen.getByText("Reject")); + + expect(approveAgentAction).toHaveBeenCalledWith(7); + expect(rejectAgentAction).toHaveBeenCalledWith(7); + }); + + it("renders hotspot + impact summary and refresh action", () => { + const refreshInsights = jest.fn(); + renderTimeline({ + refreshInsights, + hotspots: [ + { + rank: 1, + region_key: "z:12", + score: 9.5, + severity: "high", + summary: "z:12 has repeated failures.", + recommended_action: "Open proofreading.", + evidence: {}, + }, + ], + impactPreview: { + confidence: "high", + summary: "Corrections are ready to stage.", + }, + }); + + expect(screen.getByText("z:12 has repeated failures.")).toBeTruthy(); + expect( + screen.getByText( + /Corrections are ready to stage\. \(confidence: high\)/, + ), + ).toBeTruthy(); + + fireEvent.click(screen.getByText("Refresh Insights")); + expect(refreshInsights).toHaveBeenCalledTimes(1); + }); + + it("filters timeline entries by actor and event type", () => { + renderTimeline({ + events: [ + { + id: 1, + actor: "user", + event_type: "dataset.loaded", + stage: "proofreading", + summary: "Loaded dataset.", + approval_status: "not_required", + created_at: "2026-04-12T12:00:00Z", + }, + { + id: 2, + actor: "agent", + event_type: "agent.proposal_created", + stage: "proofreading", + summary: "Stage corrected masks.", + approval_status: "pending", + payload: { + action: "stage_retraining_from_corrections", + params: { corrected_mask_path: "/tmp/corrected.tif" }, + }, + created_at: "2026-04-12T12:02:00Z", + }, + ], + }); + + fireEvent.change(screen.getByLabelText("Actor filter"), { + target: { value: "agent" }, + }); + expect(screen.queryByText("Loaded dataset.")).toBeNull(); + expect(screen.getAllByText("Stage corrected masks.").length).toBeGreaterThan(0); + + fireEvent.change(screen.getByLabelText("Event type filter"), { + target: { value: "proposal" }, + }); + expect(screen.getAllByText("Stage corrected masks.").length).toBeGreaterThan(0); + expect(screen.getByText("Stage Retraining From Corrections")).toBeTruthy(); + }); +}); diff --git a/client/src/components/YamlFileUploader.js b/client/src/components/YamlFileUploader.js index e7a33c5a..8fec2dbd 100644 --- a/client/src/components/YamlFileUploader.js +++ b/client/src/components/YamlFileUploader.js @@ -490,7 +490,11 @@ const YamlFileUploader = (props) => { {yamlContent ? ( {sliderData.map((param, index) => { - const sliderValue = getSliderValue(currentYamlData, type, param.key); + const sliderValue = getSliderValue( + currentYamlData, + type, + param.key, + ); const sliderSupported = isSliderSupported( currentYamlData, type, @@ -507,7 +511,11 @@ const YamlFileUploader = (props) => { min={param.min} max={param.max} marks={param.marks} - value={typeof sliderValue === "number" ? sliderValue : param.value} + value={ + typeof sliderValue === "number" + ? sliderValue + : param.value + } disabled={!sliderSupported} onChange={(newValue) => handleSliderChange(param.key, newValue) diff --git a/client/src/components/chat/AgentProposalCard.js b/client/src/components/chat/AgentProposalCard.js new file mode 100644 index 00000000..d6f06ab2 --- /dev/null +++ b/client/src/components/chat/AgentProposalCard.js @@ -0,0 +1,60 @@ +import React from "react"; +import { Button, Space, Tag, Typography } from "antd"; + +import { getProposalCardContent } from "../../contexts/workflow/proposalCardConfig"; + +const { Text } = Typography; + +function AgentProposalCard({ proposal, onApprove, onReject, disabled = false }) { + const content = getProposalCardContent(proposal || {}); + + return ( +
+ + + + {content.title} + + {content.type} + + {content.rationale} + {content.fields?.length > 0 && ( +
+ {content.fields.map((field) => ( + + + {field.label} + + {field.value} + + ))} +
+ )} + + + + +
+
+ ); +} + +export default AgentProposalCard; diff --git a/client/src/configSchema.js b/client/src/configSchema.js index 23e6ca86..f1473e0d 100644 --- a/client/src/configSchema.js +++ b/client/src/configSchema.js @@ -135,14 +135,26 @@ export function setTrainingOutputPath(configObj, outputPath) { } const checkpointsPath = joinPath(outputPath, "checkpoints"); if (hasPath(configObj, ["train", "monitor", "checkpoint", "dirpath"])) { - setPathValue(configObj, ["train", "monitor", "checkpoint", "dirpath"], checkpointsPath); + setPathValue( + configObj, + ["train", "monitor", "checkpoint", "dirpath"], + checkpointsPath, + ); return; } if (hasPath(configObj, ["monitor", "checkpoint", "dirpath"])) { - setPathValue(configObj, ["monitor", "checkpoint", "dirpath"], checkpointsPath); + setPathValue( + configObj, + ["monitor", "checkpoint", "dirpath"], + checkpointsPath, + ); return; } - setPathValue(configObj, ["monitor", "checkpoint", "dirpath"], checkpointsPath); + setPathValue( + configObj, + ["monitor", "checkpoint", "dirpath"], + checkpointsPath, + ); } export function setInferenceOutputPath(configObj, outputPath) { @@ -152,7 +164,11 @@ export function setInferenceOutputPath(configObj, outputPath) { setPathValue(configObj, ["INFERENCE", "OUTPUT_PATH"], outputPath); return; } - setPathValue(configObj, ["inference", "save_prediction", "output_path"], outputPath); + setPathValue( + configObj, + ["inference", "save_prediction", "output_path"], + outputPath, + ); } export function setInferenceExecutionDefaults(configObj) { @@ -212,16 +228,14 @@ export function applyInputPaths( } if (mode === "training") { - const imagePath = - pickFirstExistingPath(configObj, [ - ["train", "data", "train", "image"], - ["data", "train", "image"], - ]) || ["train", "data", "train", "image"]; - const labelPath = - pickFirstExistingPath(configObj, [ - ["train", "data", "train", "label"], - ["data", "train", "label"], - ]) || ["train", "data", "train", "label"]; + const imagePath = pickFirstExistingPath(configObj, [ + ["train", "data", "train", "image"], + ["data", "train", "image"], + ]) || ["train", "data", "train", "image"]; + const labelPath = pickFirstExistingPath(configObj, [ + ["train", "data", "train", "label"], + ["data", "train", "label"], + ]) || ["train", "data", "train", "label"]; setPathValue(configObj, imagePath, inputImagePath); setPathValue(configObj, labelPath, inputLabelPath); if (outputPath) { @@ -230,16 +244,14 @@ export function applyInputPaths( return; } - const imagePath = - pickFirstExistingPath(configObj, [ - ["test", "data", "test", "image"], - ["data", "test", "image"], - ]) || ["test", "data", "test", "image"]; - const labelPath = - pickFirstExistingPath(configObj, [ - ["test", "data", "test", "label"], - ["data", "test", "label"], - ]) || ["test", "data", "test", "label"]; + const imagePath = pickFirstExistingPath(configObj, [ + ["test", "data", "test", "image"], + ["data", "test", "image"], + ]) || ["test", "data", "test", "image"]; + const labelPath = pickFirstExistingPath(configObj, [ + ["test", "data", "test", "label"], + ["data", "test", "label"], + ]) || ["test", "data", "test", "label"]; setPathValue(configObj, imagePath, inputImagePath); if (hasLabelPath) { setPathValue(configObj, labelPath, inputLabelPath); diff --git a/client/src/contexts/ProjectManagerContext.js b/client/src/contexts/ProjectManagerContext.js new file mode 100644 index 00000000..291ec723 --- /dev/null +++ b/client/src/contexts/ProjectManagerContext.js @@ -0,0 +1,287 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, +} from "react"; +import { message, Spin } from "antd"; +import { getPMData, savePMData, resetPMData } from "../api"; +import { apiClient } from "../api"; + +// ── Context ─────────────────────────────────────────────────────────────────── + +const ProjectManagerContext = createContext(null); + +export function useProjectManager() { + const ctx = useContext(ProjectManagerContext); + if (!ctx) + throw new Error( + "useProjectManager must be used inside ", + ); + return ctx; +} + +// ── Provider ────────────────────────────────────────────────────────────────── + +const DEBOUNCE_MS = 800; + +/** + * ProjectManagerProvider + * + * Props: + * role "admin" | "worker" — current RBAC role + * activeWorker worker key string — e.g. "alex" (relevant when role=worker) + */ +export function ProjectManagerProvider({ children }) { + const [pmState, setPmState] = useState(null); // null = not yet loaded + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [user, setUser] = useState(null); // Authenticated user object + const debounceRef = useRef(null); + + // ── Session Restoration ────────────────────────────────────────────────── + useEffect(() => { + const saved = localStorage.getItem("pm_user"); + if (saved) { + try { + setUser(JSON.parse(saved)); + } catch (e) { + localStorage.removeItem("pm_user"); + } + } + }, []); + + // ── Fetch on mount (always load PM config/seeds) ────────────────────────── + useEffect(() => { + let cancelled = false; + setLoading(true); + getPMData() + .then((data) => { + if (!cancelled) setPmState(data); + }) + .catch((err) => { + if (!cancelled) { + console.error("[PM] Failed to load data:", err); + message.error("Failed to load Project Manager data from server."); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + // ── Auth Actions ────────────────────────────────────────────────────────── + const login = useCallback(async (username, password) => { + try { + const res = await apiClient.post("/api/pm/login", { username, password }); + if (res.data.ok) { + const userData = res.data.user; + setUser(userData); + localStorage.setItem("pm_user", JSON.stringify(userData)); + message.success(`Logged in as ${userData.name}`); + return true; + } + } catch (err) { + message.error(err.response?.data?.detail || "Login failed"); + return false; + } + }, []); + + const logout = useCallback(() => { + setUser(null); + localStorage.removeItem("pm_user"); + message.info("Logged out."); + }, []); + + // ── Debounced server save ───────────────────────────────────────────────── + const _scheduleSave = useCallback((nextState) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(async () => { + setSaving(true); + try { + await savePMData(nextState); + message.success({ + content: "Changes saved.", + key: "pm-save", + duration: 1.5, + }); + } catch (err) { + console.error("[PM] Save failed:", err); + message.error({ + content: "Failed to save changes to server.", + key: "pm-save", + }); + } finally { + setSaving(false); + } + }, DEBOUNCE_MS); + }, []); + + // ── Public state-mutators ───────────────────────────────────────────────── + const updateState = useCallback( + (patch) => { + setPmState((prev) => { + const next = { ...prev, ...patch }; + _scheduleSave(next); + return next; + }); + }, + [_scheduleSave], + ); + + const resetData = useCallback(async () => { + setLoading(true); + try { + const seed = await resetPMData(); + setPmState(seed); + message.success("Reset to default data."); + } catch (err) { + console.error("[PM] Reset failed:", err); + message.error("Failed to reset data on server."); + } finally { + setLoading(false); + } + }, []); + + // ── Volume helpers ─────────────────────────────────────────────────────── + const getVolumes = useCallback(async (params = {}) => { + try { + const res = await apiClient.get("/api/pm/volumes", { params }); + return res.data; + } catch (err) { + message.error("Failed to load volumes from server."); + throw err; + } + }, []); + + const updateVolumeStatus = useCallback(async (volumeId, status) => { + try { + const res = await apiClient.patch(`/api/pm/volumes/${volumeId}`, { + status, + }); + if (res.data?.global_progress) { + setPmState((prev) => + prev ? { ...prev, global_progress: res.data.global_progress } : prev, + ); + } + return res.data; + } catch (err) { + message.error("Failed to update volume status."); + throw err; + } + }, []); + + // ── Convenience setters ─────────────────────────────────────────────────── + const setQuotaData = useCallback( + (v) => updateState({ quota_data: v }), + [updateState], + ); + const setProofreaderData = useCallback( + (v) => updateState({ proofreader_data: v }), + [updateState], + ); + const setThroughputData = useCallback( + (v) => updateState({ throughput_data: v }), + [updateState], + ); + const setMsgPreview = useCallback( + (v) => updateState({ msg_preview: v }), + [updateState], + ); + + // ── Computed RBAC Values ────────────────────────────────────────────────── + const isAuthenticated = !!user; + const role = user?.role || "guest"; + const isAdmin = role === "admin"; + const isWorker = role === "worker"; + const activeWorker = isWorker ? user?.key : null; + + // ── Role-filtered convenience getters ───────────────────────────────────── + const allQuotaData = pmState?.quota_data ?? []; + const allProofreaderData = pmState?.proofreader_data ?? []; + + const quotaData = isWorker + ? allQuotaData.filter((r) => r.worker_key === activeWorker) + : allQuotaData; + const proofreaderData = isWorker + ? allProofreaderData.filter((r) => r.worker_key === activeWorker) + : allProofreaderData; + + // ── Loading gate ────────────────────────────────────────────────────────── + if (loading && pmState === null) { + return ( +
+ +
+ ); + } + + return ( + + {children} + + ); +} diff --git a/client/src/contexts/WorkflowContext.js b/client/src/contexts/WorkflowContext.js new file mode 100644 index 00000000..fc9f23d1 --- /dev/null +++ b/client/src/contexts/WorkflowContext.js @@ -0,0 +1,210 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { message } from "antd"; +import { + appendWorkflowEvent, + approveAgentAction as approveAgentActionApi, + createAgentAction, + getCurrentWorkflow, + getWorkflowHotspots, + getWorkflowImpactPreview, + listWorkflowEvents, + queryWorkflowAgent, + rejectAgentAction as rejectAgentActionApi, + updateWorkflow as updateWorkflowApi, +} from "../api"; +import { AppContext } from "./GlobalContext"; + +export const WorkflowContext = createContext(null); + +export function useWorkflow() { + return useContext(WorkflowContext); +} + +export function WorkflowProvider({ children }) { + const appContext = useContext(AppContext); + const [workflow, setWorkflow] = useState(null); + const [events, setEvents] = useState([]); + const [hotspots, setHotspots] = useState([]); + const [impactPreview, setImpactPreview] = useState(null); + const [loading, setLoading] = useState(true); + const [lastClientEffects, setLastClientEffects] = useState(null); + + const refreshWorkflow = useCallback(async () => { + setLoading(true); + try { + const data = await getCurrentWorkflow(); + setWorkflow(data?.workflow || null); + setEvents(data?.events || []); + return data; + } catch (error) { + message.error("Failed to load workflow state."); + return null; + } finally { + setLoading(false); + } + }, []); + + const refreshEvents = useCallback(async () => { + if (!workflow?.id) return []; + const nextEvents = await listWorkflowEvents(workflow.id); + setEvents(nextEvents || []); + return nextEvents || []; + }, [workflow?.id]); + + const refreshInsights = useCallback(async () => { + if (!workflow?.id) { + setHotspots([]); + setImpactPreview(null); + return null; + } + try { + const [hotspotData, impactData] = await Promise.all([ + getWorkflowHotspots(workflow.id), + getWorkflowImpactPreview(workflow.id), + ]); + setHotspots(hotspotData?.hotspots || []); + setImpactPreview(impactData || null); + return { + hotspots: hotspotData?.hotspots || [], + impactPreview: impactData || null, + }; + } catch (_error) { + return null; + } + }, [workflow?.id]); + + useEffect(() => { + refreshWorkflow(); + }, [refreshWorkflow]); + + useEffect(() => { + if (!workflow?.id) { + setHotspots([]); + setImpactPreview(null); + return; + } + refreshInsights(); + }, [ + workflow?.id, + workflow?.stage, + workflow?.corrected_mask_path, + events.length, + refreshInsights, + ]); + + const updateWorkflow = useCallback( + async (patch) => { + if (!workflow?.id) return null; + const nextWorkflow = await updateWorkflowApi(workflow.id, patch); + setWorkflow(nextWorkflow); + return nextWorkflow; + }, + [workflow?.id], + ); + + const appendEvent = useCallback( + async (event) => { + if (!workflow?.id) return null; + const nextEvent = await appendWorkflowEvent(workflow.id, event); + setEvents((prev) => [...prev, nextEvent]); + return nextEvent; + }, + [workflow?.id], + ); + + const proposeAgentAction = useCallback( + async (action) => { + if (!workflow?.id) return null; + const proposal = await createAgentAction(workflow.id, action); + await refreshEvents(); + return proposal; + }, + [workflow?.id, refreshEvents], + ); + + const applyClientEffects = useCallback( + (effects) => { + if (!effects) return; + if (effects.set_training_label_path && appContext?.trainingState) { + appContext.trainingState.setInputLabel(effects.set_training_label_path); + } + setLastClientEffects(effects); + }, + [appContext], + ); + + const approveAgentAction = useCallback( + async (eventId) => { + if (!workflow?.id) return null; + const result = await approveAgentActionApi(workflow.id, eventId); + if (result?.workflow) { + setWorkflow(result.workflow); + } + applyClientEffects(result?.client_effects); + await refreshEvents(); + message.success("Agent proposal approved."); + return result; + }, + [workflow?.id, refreshEvents, applyClientEffects], + ); + + const rejectAgentAction = useCallback( + async (eventId) => { + if (!workflow?.id) return null; + const result = await rejectAgentActionApi(workflow.id, eventId); + await refreshEvents(); + message.info("Agent proposal rejected."); + return result; + }, + [workflow?.id, refreshEvents], + ); + + const queryAgent = useCallback( + async (query) => { + if (!workflow?.id) return null; + const result = await queryWorkflowAgent(workflow.id, query); + if (result?.proposals?.length) { + await refreshEvents(); + } + return result; + }, + [workflow?.id, refreshEvents], + ); + + const consumeClientEffects = useCallback(() => { + const effects = lastClientEffects; + setLastClientEffects(null); + return effects; + }, [lastClientEffects]); + + return ( + + {children} + + ); +} diff --git a/client/src/contexts/WorkflowContext.test.js b/client/src/contexts/WorkflowContext.test.js new file mode 100644 index 00000000..57549833 --- /dev/null +++ b/client/src/contexts/WorkflowContext.test.js @@ -0,0 +1,118 @@ +import React from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; + +import { AppContext } from "./GlobalContext"; +import { WorkflowProvider, useWorkflow } from "./WorkflowContext"; +import { + approveAgentAction, + getCurrentWorkflow, + getWorkflowHotspots, + getWorkflowImpactPreview, + listWorkflowEvents, +} from "../api"; + +jest.mock("../api", () => ({ + approveAgentAction: jest.fn(), + appendWorkflowEvent: jest.fn(), + createAgentAction: jest.fn(), + getCurrentWorkflow: jest.fn(), + getWorkflowHotspots: jest.fn(), + getWorkflowImpactPreview: jest.fn(), + listWorkflowEvents: jest.fn(), + queryWorkflowAgent: jest.fn(), + rejectAgentAction: jest.fn(), + updateWorkflow: jest.fn(), +})); + +const baseWorkflow = { + id: 1, + title: "Segmentation Workflow", + stage: "setup", +}; + +function Probe() { + const workflowContext = useWorkflow(); + return ( +
+
{workflowContext.workflow?.stage || "loading"}
+
{workflowContext.events.map((event) => event.event_type).join(",")}
+
{workflowContext.hotspots?.[0]?.summary || "no-hotspot"}
+
{workflowContext.impactPreview?.confidence || "no-impact"}
+ +
+ ); +} + +function renderProvider(appContextValue) { + render( + + + + + , + ); +} + +describe("WorkflowProvider", () => { + beforeEach(() => { + jest.clearAllMocks(); + getCurrentWorkflow.mockResolvedValue({ + workflow: baseWorkflow, + events: [{ id: 1, event_type: "workflow.created" }], + }); + listWorkflowEvents.mockResolvedValue([]); + getWorkflowHotspots.mockResolvedValue({ + workflow_id: 1, + hotspots: [{ region_key: "z:9", summary: "Top hotspot", severity: "high" }], + }); + getWorkflowImpactPreview.mockResolvedValue({ + workflow_id: 1, + confidence: "medium", + summary: "Impact summary", + }); + }); + + it("loads the current workflow and events on startup", async () => { + renderProvider({ trainingState: { setInputLabel: jest.fn() } }); + + expect(await screen.findByText("setup")).toBeTruthy(); + expect(screen.getByText("workflow.created")).toBeTruthy(); + await waitFor(() => { + expect(getWorkflowHotspots).toHaveBeenCalledWith(1); + expect(getWorkflowImpactPreview).toHaveBeenCalledWith(1); + }); + await waitFor(() => { + expect(screen.getByText("Top hotspot")).toBeTruthy(); + expect(screen.getByText("medium")).toBeTruthy(); + }); + expect(getCurrentWorkflow).toHaveBeenCalledTimes(1); + }); + + it("applies client effects when an agent proposal is approved", async () => { + const setInputLabel = jest.fn(); + approveAgentAction.mockResolvedValue({ + workflow: { ...baseWorkflow, stage: "retraining_staged" }, + client_effects: { + navigate_to: "training", + set_training_label_path: "/tmp/corrected.tif", + }, + }); + + renderProvider({ trainingState: { setInputLabel } }); + await screen.findByText("setup"); + + fireEvent.click(screen.getByText("Approve proposal")); + + await waitFor(() => { + expect(setInputLabel).toHaveBeenCalledWith("/tmp/corrected.tif"); + }); + await waitFor(() => { + expect(screen.getByText("retraining_staged")).toBeTruthy(); + }); + }); +}); diff --git a/client/src/contexts/workflow/proposalCardConfig.js b/client/src/contexts/workflow/proposalCardConfig.js new file mode 100644 index 00000000..febf263a --- /dev/null +++ b/client/src/contexts/workflow/proposalCardConfig.js @@ -0,0 +1,92 @@ +const toDisplayValue = (value) => { + if (value === null || value === undefined || value === "") { + return "—"; + } + if (Array.isArray(value)) { + return value.length ? value.join(", ") : "—"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +}; + +const compactRationale = (rationale) => { + if (!rationale) return "No rationale provided."; + const trimmed = String(rationale).trim(); + return trimmed.length > 160 ? `${trimmed.slice(0, 157)}…` : trimmed; +}; + +const pickEntries = (proposal, keys) => + keys + .filter((key) => key in proposal) + .map((key) => ({ + key, + label: key.replace(/_/g, " "), + value: toDisplayValue(proposal[key]), + })); + +export const getProposalCardContent = (proposal = {}) => { + const type = proposal.type || proposal.proposal_type || proposal.action || "proposal"; + + if (type === "prioritize_failure_hotspots") { + return { + type, + title: "Prioritize Failure Hotspots", + rationale: compactRationale(proposal.rationale || proposal.why), + fields: pickEntries(proposal, [ + "target_dataset", + "hotspots", + "priority_metric", + "min_failure_rate", + ]), + }; + } + + if (type === "preview_correction_impact") { + return { + type, + title: "Preview Correction Impact", + rationale: compactRationale(proposal.rationale || proposal.why), + fields: pickEntries(proposal, [ + "target_metric", + "expected_delta", + "sample_size", + "confidence", + ]), + }; + } + + if (type === "stage_retraining_from_corrections") { + return { + type, + title: "Stage Retraining From Corrections", + rationale: compactRationale( + proposal.rationale || + proposal.why || + "Stage corrected masks for the next model iteration.", + ), + fields: pickEntries(proposal, [ + "corrected_mask_path", + "written_path", + "training_output_path", + ]), + }; + } + + const fallbackFields = Object.entries(proposal) + .filter(([key]) => !["type", "proposal_type", "rationale", "why"].includes(key)) + .slice(0, 4) + .map(([key, value]) => ({ + key, + label: key.replace(/_/g, " "), + value: toDisplayValue(value), + })); + + return { + type, + title: "Agent Proposal", + rationale: compactRationale(proposal.rationale || proposal.why), + fields: fallbackFields, + }; +}; diff --git a/client/src/contexts/workflow/timelineFilters.js b/client/src/contexts/workflow/timelineFilters.js new file mode 100644 index 00000000..5bfeee1a --- /dev/null +++ b/client/src/contexts/workflow/timelineFilters.js @@ -0,0 +1,42 @@ +export const TIMELINE_ACTOR_OPTIONS = ["all", "user", "agent", "system"]; + +export const DEFAULT_TIMELINE_FILTERS = { + actor: "all", + eventType: "", +}; + +export function normalizeTimelineFilters(filters = {}) { + const actor = TIMELINE_ACTOR_OPTIONS.includes(filters.actor) + ? filters.actor + : DEFAULT_TIMELINE_FILTERS.actor; + const eventType = (filters.eventType || "").trim(); + return { actor, eventType }; +} + +export function eventMatchesTimelineFilters( + event, + filters = DEFAULT_TIMELINE_FILTERS, +) { + const normalized = normalizeTimelineFilters(filters); + const actor = (event?.actor || "").toLowerCase(); + const eventType = (event?.event_type || event?.eventType || "").toLowerCase(); + + if (normalized.actor !== "all" && actor !== normalized.actor) { + return false; + } + if (!normalized.eventType) { + return true; + } + return eventType.includes(normalized.eventType.toLowerCase()); +} + +export function filterTimelineEvents(events = [], filters = DEFAULT_TIMELINE_FILTERS) { + if (!Array.isArray(events) || events.length === 0) { + return []; + } + const normalized = normalizeTimelineFilters(filters); + if (normalized.actor === "all" && !normalized.eventType) { + return events; + } + return events.filter((event) => eventMatchesTimelineFilters(event, normalized)); +} diff --git a/client/src/views/EHTool.js b/client/src/views/EHTool.js index 5d546fd7..3cb38e32 100644 --- a/client/src/views/EHTool.js +++ b/client/src/views/EHTool.js @@ -13,6 +13,7 @@ function EHTool({ onSessionChange, refreshTrigger, savedSessionId, + workflowId, }) { // Initialize with saved session if available const [sessionId, setSessionId] = useState(savedSessionId || null); @@ -37,6 +38,7 @@ function EHTool({ diff --git a/client/src/views/ModelInference.js b/client/src/views/ModelInference.js index 8ea3bf44..8de45350 100644 --- a/client/src/views/ModelInference.js +++ b/client/src/views/ModelInference.js @@ -11,13 +11,18 @@ import Configurator from "../components/Configurator"; import { applyInputPaths } from "../configSchema"; import RuntimeLogPanel from "../components/RuntimeLogPanel"; import { AppContext } from "../contexts/GlobalContext"; +import { useWorkflow } from "../contexts/WorkflowContext"; function ModelInference({ isInferring, setIsInferring }) { const context = useContext(AppContext); + const workflowContext = useWorkflow(); + const appendWorkflowEvent = workflowContext?.appendEvent; + const workflowId = workflowContext?.workflow?.id; const inference = context.inferenceState; const [inferenceStatus, setInferenceStatus] = useState(""); const [inferenceRuntime, setInferenceRuntime] = useState(null); const pollingIntervalRef = useRef(null); + const terminalLoggedRef = useRef(false); const getPath = (val) => { if (!val) return ""; @@ -60,7 +65,10 @@ function ModelInference({ isInferring, setIsInferring }) { }); return yaml.dump(yamlData, { indent: 2 }).replace(/^\s*\n/gm, ""); } catch (error) { - console.warn("Failed to prepare inference config from current inputs:", error); + console.warn( + "Failed to prepare inference config from current inputs:", + error, + ); return inferenceConfig; } }; @@ -81,9 +89,32 @@ function ModelInference({ isInferring, setIsInferring }) { if (!status.isRunning) { setIsInferring(false); + if (!terminalLoggedRef.current && appendWorkflowEvent) { + terminalLoggedRef.current = true; + const succeeded = status.exitCode === 0; + await appendWorkflowEvent({ + actor: "system", + event_type: succeeded + ? "inference.completed" + : "inference.failed", + stage: "inference", + summary: succeeded + ? "Inference completed successfully." + : "Inference finished without a successful exit.", + payload: { + exitCode: status.exitCode, + phase: status.phase, + lastError: status.lastError, + outputPath: getPath(inference.outputPath), + }, + }); + } if (status.exitCode === 0) { setInferenceStatus("Inference completed successfully! ✓"); - } else if (status.exitCode !== null && status.exitCode !== undefined) { + } else if ( + status.exitCode !== null && + status.exitCode !== undefined + ) { setInferenceStatus( `Inference finished with exit code: ${status.exitCode}`, ); @@ -96,6 +127,19 @@ function ModelInference({ isInferring, setIsInferring }) { } catch (error) { console.error("Error polling inference status:", error); setIsInferring(false); + if (!terminalLoggedRef.current && appendWorkflowEvent) { + terminalLoggedRef.current = true; + await appendWorkflowEvent({ + actor: "system", + event_type: "inference.failed", + stage: "inference", + summary: "Inference status polling failed.", + payload: { + error: error.message || "unknown error", + outputPath: getPath(inference.outputPath), + }, + }); + } setInferenceStatus( `Inference status polling failed: ${error.message || "unknown error"}`, ); @@ -109,9 +153,10 @@ function ModelInference({ isInferring, setIsInferring }) { pollingIntervalRef.current = null; } }; - }, [isInferring, setIsInferring]); + }, [isInferring, setIsInferring, appendWorkflowEvent, inference.outputPath]); const handleStartButton = async () => { + let checkpointPath = ""; try { const inferenceConfig = context.inferenceConfig; if (!inferenceConfig) { @@ -121,7 +166,7 @@ function ModelInference({ isInferring, setIsInferring }) { return; } - const checkpointPath = getPath(inference.checkpointPath); + checkpointPath = getPath(inference.checkpointPath); if (!checkpointPath) { setInferenceStatus("Error: Please set checkpoint path first."); return; @@ -129,14 +174,17 @@ function ModelInference({ isInferring, setIsInferring }) { setIsInferring(true); setInferenceStatus("Starting inference..."); + terminalLoggedRef.current = false; - const preparedInferenceConfig = getPreparedInferenceConfig(inferenceConfig); + const preparedInferenceConfig = + getPreparedInferenceConfig(inferenceConfig); const res = await startModelInference( preparedInferenceConfig, getPath(inference.outputPath), checkpointPath, getConfigOriginPath(), + workflowId, ); console.log(res); await refreshInferenceLogs(); @@ -144,6 +192,20 @@ function ModelInference({ isInferring, setIsInferring }) { } catch (e) { console.log(e); setIsInferring(false); + if (!terminalLoggedRef.current && appendWorkflowEvent) { + terminalLoggedRef.current = true; + await appendWorkflowEvent({ + actor: "system", + event_type: "inference.failed", + stage: "inference", + summary: "Inference failed to start.", + payload: { + error: e.message || "unknown error", + outputPath: getPath(inference.outputPath), + checkpointPath, + }, + }); + } await refreshInferenceLogs(); setInferenceStatus( `Inference error: ${e.message || "Please check console for details."}`, diff --git a/client/src/views/ModelTraining.js b/client/src/views/ModelTraining.js index 312724dc..cd848579 100644 --- a/client/src/views/ModelTraining.js +++ b/client/src/views/ModelTraining.js @@ -12,9 +12,11 @@ import Configurator from "../components/Configurator"; import { applyInputPaths } from "../configSchema"; import RuntimeLogPanel from "../components/RuntimeLogPanel"; import { AppContext } from "../contexts/GlobalContext"; +import { useWorkflow } from "../contexts/WorkflowContext"; function ModelTraining() { const context = useContext(AppContext); + const workflowContext = useWorkflow(); const training = context.trainingState; const [isTraining, setIsTraining] = useState(false); const [trainingStatus, setTrainingStatus] = useState(""); @@ -61,7 +63,9 @@ function ModelTraining() { if (status.exitCode === 0) { setTrainingStatus("Training completed successfully! ✓"); } else if (status.exitCode !== null) { - setTrainingStatus(`Training finished with exit code: ${status.exitCode}`); + setTrainingStatus( + `Training finished with exit code: ${status.exitCode}`, + ); } else if (status.phase === "failed" && status.lastError) { setTrainingStatus(`Training failed: ${status.lastError}`); } else { @@ -91,7 +95,10 @@ function ModelTraining() { }); return yaml.dump(yamlData, { indent: 2 }).replace(/^\s*\n/gm, ""); } catch (error) { - console.warn("Failed to prepare training config from current inputs:", error); + console.warn( + "Failed to prepare training config from current inputs:", + error, + ); return trainingConfig; } }; @@ -161,6 +168,7 @@ function ModelTraining() { getPath(training.logPath) || getPath(training.outputPath), getPath(training.outputPath), getConfigOriginPath(), + workflowContext?.workflow?.id, ); console.log(res); await refreshTrainingLogs(); diff --git a/client/src/views/Views.js b/client/src/views/Views.js index e442adf5..fab8c0a7 100644 --- a/client/src/views/Views.js +++ b/client/src/views/Views.js @@ -8,14 +8,17 @@ import { DashboardOutlined, BugOutlined, MessageOutlined, + ProjectOutlined, } from "@ant-design/icons"; import FilesManager from "./FilesManager"; import Visualization from "./Visualization"; import ModelTraining from "./ModelTraining"; import ModelInference from "./ModelInference"; import Monitoring from "./Monitoring"; -import MaskProofreading from "./MaskProofreading"; +import MaskProofreading from "./mask-proofreading/MaskProofreading"; +import ProjectManager from "./project-manager/ProjectManager"; import Chatbot from "../components/Chatbot"; +import { useWorkflow } from "../contexts/WorkflowContext"; const { Content } = Layout; @@ -34,11 +37,21 @@ const MODULE_ITEMS = [ key: "mask-proofreading", icon: , }, + { + label: "Project Manager", + key: "project-manager", + icon: , + }, ]; function Views() { - const [current, setCurrent] = useState("files"); - const [visitedTabs, setVisitedTabs] = useState(new Set(["files"])); + const workflowContext = useWorkflow(); + const lastClientEffects = workflowContext?.lastClientEffects; + const consumeClientEffects = workflowContext?.consumeClientEffects; + const [current, setCurrent] = useState("project-manager"); + const [visitedTabs, setVisitedTabs] = useState( + new Set(["project-manager", "files"]), + ); const [isChatOpen, setIsChatOpen] = useState(false); const [chatWidth, setChatWidth] = useState(560); const isResizing = useRef(false); @@ -46,6 +59,33 @@ function Views() { const [viewers, setViewers] = useState([]); const [isInferring, setIsInferring] = useState(false); +/* + const allItems = [ + { label: "File Management", key: "files", icon: }, + { label: "Visualization", key: "visualization", icon: }, + { label: "Model Training", key: "training", icon: }, + { + label: "Model Inference", + key: "inference", + icon: , + }, + { label: "Tensorboard", key: "monitoring", icon: }, + { label: "SynAnno", key: "synanno", icon: }, + { + label: "Worm Error Handling", + key: "worm-error-handling", + icon: , + }, + { + label: "Project Manager", + key: "project-manager", + icon: , + }, + ]; + + const items = allItems.filter((item) => visibleTabs.has(item.key)); +*/ + const onClick = (e) => { setCurrent(e.key); setVisitedTabs((prev) => new Set(prev).add(e.key)); @@ -78,6 +118,15 @@ function Views() { }; }, [resize, stopResizing]); + useEffect(() => { + const target = lastClientEffects?.navigate_to; + if (!target) return; + const targetKey = target === "model-training" ? "training" : target; + setCurrent(targetKey); + setVisitedTabs((prev) => new Set(prev).add(targetKey)); + consumeClientEffects?.(); + }, [lastClientEffects, consumeClientEffects]); + const renderTabContent = (key, component) => { if (!visitedTabs.has(key)) return null; return ( @@ -141,7 +190,10 @@ function Views() { />, )} {renderTabContent("mask-proofreading", )} - + {/* {renderTabContent("synanno", )} */} + {/* {renderTabContent("worm-error-handling", )} */} + {renderTabContent("project-manager", )} + setIsChatOpen(false)} /> - + ); } diff --git a/client/src/views/Views.test.js b/client/src/views/Views.test.js index 1f08242b..979bbde3 100644 --- a/client/src/views/Views.test.js +++ b/client/src/views/Views.test.js @@ -3,6 +3,10 @@ import { fireEvent, render, screen } from "@testing-library/react"; import Views from "./Views"; +jest.mock("../contexts/WorkflowContext", () => ({ + useWorkflow: () => null, +})); + jest.mock("antd", () => { const React = require("react"); @@ -41,6 +45,7 @@ jest.mock("@ant-design/icons", () => { DashboardOutlined: Icon, BugOutlined: Icon, MessageOutlined: Icon, + ProjectOutlined: Icon, }; }); @@ -49,7 +54,12 @@ jest.mock("./Visualization", () => () =>
Visualization Content
); jest.mock("./ModelTraining", () => () =>
Training Content
); jest.mock("./ModelInference", () => () =>
Inference Content
); jest.mock("./Monitoring", () => () =>
Monitoring Content
); -jest.mock("./MaskProofreading", () => () =>
Mask Proofreading Content
); +jest.mock("./mask-proofreading/MaskProofreading", () => () => ( +
Mask Proofreading Content
+)); +jest.mock("./project-manager/ProjectManager", () => () => ( +
Project Manager Content
+)); jest.mock("../components/Chatbot", () => () =>
Chatbot
); describe("Views", () => { diff --git a/client/src/views/Visualization.js b/client/src/views/Visualization.js index ab2c58e7..4c2de865 100644 --- a/client/src/views/Visualization.js +++ b/client/src/views/Visualization.js @@ -7,11 +7,13 @@ import { } from "@ant-design/icons"; import { getNeuroglancerViewer } from "../api"; import UnifiedFileInput from "../components/UnifiedFileInput"; +import { useWorkflow } from "../contexts/WorkflowContext"; const { Title } = Typography; function Visualization(props) { const { viewers, setViewers } = props; + const workflowContext = useWorkflow(); const [activeKey, setActiveKey] = useState( viewers.length > 0 ? viewers[0].key : null, ); @@ -77,6 +79,7 @@ function Visualization(props) { imagePath, labelPath, scalesArray, + workflowContext?.workflow?.id, ); console.log("Current Viewer at ", res); diff --git a/client/src/views/ehtool/DetectionWorkflow.js b/client/src/views/ehtool/DetectionWorkflow.js index 2207f60c..48ee921c 100644 --- a/client/src/views/ehtool/DetectionWorkflow.js +++ b/client/src/views/ehtool/DetectionWorkflow.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef } from "react"; +import React, { useContext, useState, useEffect, useMemo, useRef } from "react"; import { Layout, message, @@ -20,6 +20,8 @@ import InstanceNavigator from "./InstanceNavigator"; import ProofreadingEditor from "./ProofreadingEditor"; import SliceScheduler from "./SliceScheduler"; import { apiClient } from "../../api"; +import { AppContext } from "../../contexts/GlobalContext"; +import { useWorkflow } from "../../contexts/WorkflowContext"; const { Sider, Content } = Layout; const { Title, Text } = Typography; @@ -55,7 +57,14 @@ const SCRUB_IDLE_MS = parsePositiveInt( 120, ); -function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { +function DetectionWorkflow({ + sessionId, + setSessionId, + refreshTrigger, + workflowId, +}) { + const appContext = useContext(AppContext); + const workflowContext = useWorkflow(); const [projectName, setProjectName] = useState(""); const [totalLayers, setTotalLayers] = useState(0); const [instances, setInstances] = useState([]); @@ -108,6 +117,7 @@ function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { const [persistence, setPersistence] = useState(null); const [showExportModal, setShowExportModal] = useState(false); const [exportPath, setExportPath] = useState(""); + const [lastExportPath, setLastExportPath] = useState(""); const [exportingMasks, setExportingMasks] = useState(false); const [overwritingSource, setOverwritingSource] = useState(false); const lastPersistenceErrorRef = useRef(null); @@ -448,6 +458,7 @@ function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { dataset_path: datasetPath, mask_path: maskPath || null, project_name: projectName, + workflow_id: workflowId || null, }); setSessionId(response.data.session_id); @@ -1767,6 +1778,7 @@ function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { create_backup: true, }); setShowExportModal(false); + setLastExportPath(response.data.written_path); message.success(`Exported masks to ${response.data.written_path}`); if (response.data.backup_path) { message.info(`Backup created at ${response.data.backup_path}`); @@ -1805,6 +1817,7 @@ function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { if (response.data.backup_path) { message.info(`Backup created at ${response.data.backup_path}`); } + setLastExportPath(response.data.written_path); refreshPersistenceStatus(); } catch (error) { Modal.error({ @@ -1822,6 +1835,44 @@ function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { }); }; + const handleStageForRetraining = async () => { + const correctedMaskPath = lastExportPath || persistence?.last_export_path; + if (!correctedMaskPath) { + message.warning("Export corrected masks before staging retraining."); + return; + } + if (!workflowContext?.workflow?.id) { + message.warning("Workflow state is not available yet."); + return; + } + + try { + await workflowContext.updateWorkflow({ + stage: "retraining_staged", + corrected_mask_path: correctedMaskPath, + }); + await workflowContext.appendEvent({ + actor: "user", + event_type: "retraining.staged", + stage: "retraining_staged", + summary: "Staged corrected masks for retraining.", + payload: { + corrected_mask_path: correctedMaskPath, + ehtool_session_id: sessionId, + source: "proofreading_export", + }, + }); + if (appContext?.trainingState?.setInputLabel) { + appContext.trainingState.setInputLabel(correctedMaskPath); + } + message.success("Corrected masks staged for retraining."); + } catch (error) { + message.error( + getErrorMessage(error, "Failed to stage corrected masks for retraining"), + ); + } + }; + if (!sessionId) { return (
@@ -1834,6 +1885,7 @@ function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { sliderZ ?? viewState.zIndex, axisTotal || totalLayers, ); + const exportedMaskPath = lastExportPath || persistence?.last_export_path || ""; const reviewControls = activeInstance && instanceMode !== "none" ? ( @@ -1929,6 +1981,15 @@ function DetectionWorkflow({ sessionId, setSessionId, refreshTrigger }) { + {exportedMaskPath && ( + + )} { // This prop is now nominally used to trigger internal modal diff --git a/client/src/views/mask-proofreading/MaskProofreading.test.js b/client/src/views/mask-proofreading/MaskProofreading.test.js new file mode 100644 index 00000000..4fb7fa95 --- /dev/null +++ b/client/src/views/mask-proofreading/MaskProofreading.test.js @@ -0,0 +1,26 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import MaskProofreading from "./MaskProofreading"; + +jest.mock("../EHTool", () => (props) => ( +
+)); + +jest.mock("../../contexts/WorkflowContext", () => ({ + useWorkflow: () => ({ workflow: { id: 42 } }), +})); + +describe("MaskProofreading", () => { + it("renders EHTool and passes the active workflow id", () => { + render(); + + const ehTool = screen.getByTestId("eh-tool"); + expect(ehTool).toBeTruthy(); + expect(ehTool.getAttribute("data-workflow-id")).toBe("42"); + }); +}); diff --git a/client/src/views/project-manager/AnnotationDashboard.js b/client/src/views/project-manager/AnnotationDashboard.js new file mode 100644 index 00000000..ca66a3eb --- /dev/null +++ b/client/src/views/project-manager/AnnotationDashboard.js @@ -0,0 +1,768 @@ +import React, { useState, useMemo } from "react"; +import { + Card, + Statistic, + Table, + Tag, + Progress, + Button, + Space, + Typography, + Tooltip, + Row, + Col, + Divider, + Badge, +} from "antd"; +import { + CheckCircleOutlined, + ClockCircleOutlined, + StopOutlined, + MinusCircleOutlined, + WarningOutlined, + RiseOutlined, + TeamOutlined, + FileTextOutlined, + ThunderboltOutlined, + DatabaseOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; + +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Title, Text } = Typography; + +const PROJECT_START = dayjs("2025-10-01"); +const PROJECT_END = dayjs("2026-03-31"); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const STATUS_META = { + in_progress: { label: "In Progress", color: "processing" }, + done: { label: "Done", color: "success" }, + blocked: { label: "Blocked", color: "error" }, + not_started: { label: "Not Started", color: "default" }, +}; + +function statusTag(status) { + const { label, color } = STATUS_META[status] || {}; + return ; +} + +// Convert a date to a 0-1 fraction along the 6-month timeline +function dateToFrac(d) { + const total = PROJECT_END.diff(PROJECT_START, "day"); + const elapsed = dayjs(d).diff(PROJECT_START, "day"); + return Math.min(1, Math.max(0, elapsed / total)); +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +/** Custom SVG 6-month horizontal timeline */ +function SixMonthTimeline({ milestones }) { + const W = 900; + const H = 80; + const PAD = 40; + const trackY = 48; + const trackW = W - PAD * 2; + + const months = []; + let cur = PROJECT_START.startOf("month"); + while (cur.isBefore(PROJECT_END) || cur.isSame(PROJECT_END, "month")) { + months.push(cur); + cur = cur.add(1, "month"); + } + + const todayFrac = dateToFrac(dayjs("2026-03-20")); + + return ( + + + + {months.map((m) => { + const x = PAD + dateToFrac(m) * trackW; + return ( + + + + {m.format("MMM")} + + + ); + })} + {milestones.map((ms) => { + const x = PAD + dateToFrac(ms.date) * trackW; + return ( + + + + + {ms.label} + + + + ); + })} + + + Today + + + ); +} + +/** Cumulative progress SVG line chart */ +function CumulativeChart({ cumulativeData, cumulativeTarget }) { + const W = 800; + const H = 180; + const PADL = 52; + const PADR = 16; + const PADT = 16; + const PADB = 32; + const chartW = W - PADL - PADR; + const chartH = H - PADT - PADB; + const maxVal = Math.max(...cumulativeData, ...cumulativeTarget) * 1.05; + const weeks = cumulativeData.length; + const toX = (i) => PADL + (i / (weeks - 1)) * chartW; + const toY = (v) => PADT + chartH - (v / maxVal) * chartH; + const pathD = (data) => + data + .map( + (v, i) => + `${i === 0 ? "M" : "L"} ${toX(i).toFixed(1)},${toY(v).toFixed(1)}`, + ) + .join(" "); + const areaD = (data) => + `${pathD(data)} L ${toX(weeks - 1).toFixed(1)},${(PADT + chartH).toFixed(1)} L ${PADL},${(PADT + chartH).toFixed(1)} Z`; + const yTicks = [0, 10000, 20000, 30000]; + const xTicks = [0, 4, 8, 12, 16, 20, 24]; + return ( + + {yTicks.map((v) => ( + + + + {v === 0 ? "0" : `${v / 1000}k`} + + + ))} + {xTicks.map((i) => ( + + Wk {i + 1} + + ))} + + + + + + + + Actual + + + + Target + + + + ); +} + +// ─── Column definitions ─────────────────────────────────────────────────────── + +const DATASET_COLUMNS = [ + { + title: "Dataset Name", + dataIndex: "name", + key: "name", + render: (name) => {name}, + }, + { + title: "Experiment", + dataIndex: "experiment", + key: "experiment", + render: (t) => {t}, + }, + { + title: "Total Samples", + dataIndex: "total", + key: "total", + align: "right", + render: (n) => n.toLocaleString(), + }, + { + title: "% Proofread", + dataIndex: "proofread", + key: "proofread", + width: 180, + render: (pct) => ( + + = 80 ? "#52c41a" : pct >= 40 ? "#faad14" : "#ff4d4f" + } + showInfo={false} + /> + {pct}% + + ), + }, + { title: "Status", dataIndex: "status", key: "status", render: statusTag }, + { + title: "ETA", + dataIndex: "eta", + key: "eta", + render: (eta) => {eta}, + }, +]; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function AnnotationDashboard() { + const { + quotaData, + allQuotaData, + proofreaderData, + datasets, + milestones, + cumulativeData, + cumulativeTarget, + atRisk, + upcomingMilestones, + globalProgress, + isAdmin, + isWorker, + activeWorker, + } = useProjectManager(); + + const [filter, setFilter] = useState("all"); + + // ── KPI calculations ────────────────────────────────────────────────────── + // Weekly quota progress (uses role-filtered quotaData) + const totalActual = useMemo( + () => + quotaData.reduce( + (sum, row) => + sum + + row.actualMon + + row.actualTue + + row.actualWed + + row.actualThu + + row.actualFri + + row.actualSat + + row.actualSun, + 0, + ), + [quotaData], + ); + const totalTarget = useMemo( + () => + quotaData.reduce( + (sum, row) => + sum + + row.mon + + row.tue + + row.wed + + row.thu + + row.fri + + row.sat + + row.sun, + 0, + ), + [quotaData], + ); + + const totalSamplesCount = useMemo( + () => datasets.reduce((s, d) => s + d.total, 0), + [datasets], + ); + const overallPct = + totalTarget > 0 ? Math.round((totalActual / totalTarget) * 100) : 0; + const activeDatasetsCount = datasets.filter( + (d) => d.status === "in_progress", + ).length; + const activeProofreadersCount = proofreaderData.filter( + (p) => p.status === "online" || p.status === "away", + ).length; + + // Volume progress — admin = global 1000, worker = their 250 + const volProgress = isWorker + ? (globalProgress?.by_worker?.[activeWorker] ?? { + done: 0, + total: 250, + pct: 0, + }) + : (globalProgress ?? { done: 0, total: 1000, pct: 0 }); + + // Filtered datasets + const filteredDatasets = useMemo(() => { + if (filter === "all") return datasets; + if (filter === "high") return datasets.filter((d) => d.priority === "high"); + if (filter === "blocked") + return datasets.filter((d) => d.status === "blocked"); + return datasets; + }, [filter, datasets]); + + const filterBtns = [ + { key: "all", label: "All" }, + { key: "high", label: "High Priority" }, + { key: "blocked", label: "Blocked" }, + ]; + + return ( +
+ {/* ── Header ── */} +
+ + {isWorker ? "My Dashboard" : "Neural Dataset Proofreading – 6 Months"} + + + {PROJECT_START.format("MMM D, YYYY")} →{" "} + {PROJECT_END.format("MMM D, YYYY")} · Real-time Context Sync + +
+ + {/* ── KPI Cards ── */} + + {/* ① Volume Progress — derived from actual done status of 1000 volumes */} + + + + {isWorker ? "My Volumes Done" : "Global Volume Progress"} + + } + value={volProgress.done} + suffix={`/ ${volProgress.total}`} + prefix={} + valueStyle={{ + color: "#722ed1", + fontSize: 20, + fontWeight: "bold", + }} + /> +
+ + + {volProgress.pct ?? 0}% of{" "} + {isWorker ? "250 assigned" : "1,000 total"} volumes done + +
+
+ + + {/* ② Weekly Points */} + + + + Weekly Points + + } + value={totalActual.toLocaleString()} + suffix={`/ ${totalTarget.toLocaleString()}`} + prefix={} + valueStyle={{ + color: "#52c41a", + fontSize: 20, + fontWeight: "bold", + }} + /> +
+ +
+
+ + + {/* ③ Active Datasets (admin only, for worker show accuracy) */} + + + + Active Datasets + + } + value={activeDatasetsCount} + suffix={`/ ${datasets.length}`} + prefix={} + valueStyle={{ + color: "#1890ff", + fontSize: 20, + fontWeight: "bold", + }} + /> + + {totalSamplesCount.toLocaleString()} total samples + + + + + {/* ④ Team status */} + + + + {isWorker ? "My Weekly Points" : "Proofreaders Online"} + + } + value={isWorker ? totalActual : activeProofreadersCount} + suffix={isWorker ? undefined : `/ ${allQuotaData.length}`} + prefix={ + isWorker ? ( + + ) : ( + + ) + } + valueStyle={{ + color: "#fa8c16", + fontSize: 20, + fontWeight: "bold", + }} + /> + + {isWorker ? "Points this week" : "Team-wide active status"} + + + +
+ + {/* ── Timeline ── */} + 6-Month Project Timeline} + style={{ marginBottom: 20, borderRadius: 8 }} + bodyStyle={{ paddingTop: 8 }} + > + + + + {/* ── Main content: Table + Right Sidebar ── */} + + {/* Datasets Table */} + + Datasets} + extra={ + + {filterBtns.map((b) => ( + + ))} + + } + style={{ marginBottom: 20, borderRadius: 8 }} + > + + + + + {/* Right Sidebar */} + + {/* This Week */} + + + Weekly Progress + + } + style={{ marginBottom: 12, borderRadius: 8 }} + > + + Total items annotated vs. target + +
+
+ Completed + + {totalActual.toLocaleString()} + +
+ +
+ + Target: {totalTarget.toLocaleString()} + + + {overallPct}% + +
+
+
+ + {/* At Risk */} + + + At Risk + + } + style={{ marginBottom: 12, borderRadius: 8 }} + > + {atRisk.map((item, idx) => ( +
+ {idx > 0 && } +
+ {item.icon === "clock" ? ( + + ) : item.icon === "blocked" ? ( + + ) : ( + + )} +
+ + {item.label} + +
+ + {item.reason} + +
+
+
+ ))} +
+ + {/* Upcoming Milestones */} + + + Upcoming Milestones + + } + style={{ borderRadius: 8 }} + > + {upcomingMilestones.map((ms, idx) => ( +
+ {idx > 0 && } +
+ {ms.label} + + {ms.date} + +
+
+ ))} +
+ + + + {/* ── Cumulative Progress Chart ── */} + Cumulative Items Proofread over 6 Months} + style={{ marginBottom: 8, borderRadius: 8 }} + bodyStyle={{ paddingTop: 8 }} + > + + + + ); +} + +export default AnnotationDashboard; diff --git a/client/src/views/project-manager/ModelQualityDashboard.js b/client/src/views/project-manager/ModelQualityDashboard.js new file mode 100644 index 00000000..048adf31 --- /dev/null +++ b/client/src/views/project-manager/ModelQualityDashboard.js @@ -0,0 +1,481 @@ +import React, { useState } from "react"; +import { + Card, + Row, + Col, + Statistic, + Progress, + Table, + Tag, + Button, + Typography, + Space, + Select, + Divider, +} from "antd"; +import { + LineChartOutlined, + CheckCircleOutlined, + WarningOutlined, + HistoryOutlined, + ThunderboltOutlined, + SlidersOutlined, +} from "@ant-design/icons"; + +const { Title, Text } = Typography; +const { Option } = Select; + +// ─── Seed Data ─────────────────────────────────────────────────────────────── + +const QUALITY_METRICS = { + accuracy: 94.2, + precision: 92.5, + recall: 91.8, + f1: 92.1, + agreement: 88.5, + disagreement: 4.2, + uncertainty: 2.1, +}; + +const CONFUSION_MATRIX = [ + { key: "1", actual: "Synapse", predSynapse: 1420, predNonSynapse: 85 }, + { key: "2", actual: "Non-Synapse", predSynapse: 112, predNonSynapse: 3200 }, +]; + +const BATCH_QUALITY = [ + { + key: "1", + batch: "Batch #104", + dataset: "Hippocampus_CA3", + agreement: 91.2, + flagged: 12, + revisionNeeded: 5, + status: "verified", + }, + { + key: "2", + batch: "Batch #105", + dataset: "Motor_Cortex_M1", + agreement: 84.5, + flagged: 42, + revisionNeeded: 18, + status: "needs_review", + }, + { + key: "3", + batch: "Batch #106", + dataset: "Cerebellum_PC", + agreement: 89.1, + flagged: 8, + revisionNeeded: 2, + status: "verified", + }, + { + key: "4", + batch: "Batch #107", + dataset: "Retina_GCL", + agreement: 72.0, + flagged: 5, + revisionNeeded: 22, + status: "blocked", + }, +]; + +const TREND_DATA = [82, 84, 85, 83, 86, 88, 87, 89, 91, 93, 94.2]; + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function QualityTrendChart() { + const W = 800; + const H = 200; + const PAD = 40; + const chartW = W - PAD * 2; + const chartH = H - PAD; + + const maxVal = 100; + const minVal = 80; // Zoomed in to show subtle improvements + const range = maxVal - minVal; + + const toX = (i) => PAD + (i / (TREND_DATA.length - 1)) * chartW; + const toY = (v) => PAD / 2 + chartH - ((v - minVal) / range) * chartH; + + const pathD = TREND_DATA.map( + (v, i) => `${i === 0 ? "M" : "L"} ${toX(i)},${toY(v)}`, + ).join(" "); + + return ( + + {/* Grid lines */} + {[80, 85, 90, 95, 100].map((v) => ( + + + + {v}% + + + ))} + + {/* The Line */} + + + {/* Area fill */} + + + {/* Points */} + {TREND_DATA.map((v, i) => ( + + ))} + + {/* Labels */} + {TREND_DATA.map( + (v, i) => + i % 2 === 0 && ( + + v{i + 1} + + ), + )} + + ); +} + +// ─── Column definitions ─────────────────────────────────────────────────────── + +const BATCH_COLUMNS = [ + { + title: "Batch", + dataIndex: "batch", + key: "batch", + render: (text) => {text}, + }, + { + title: "Dataset", + dataIndex: "dataset", + key: "dataset", + render: (text) => {text}, + }, + { + title: "IAA Score", + dataIndex: "agreement", + key: "agreement", + render: (val) => ( + + 85 ? "#52c41a" : "#faad14"} + /> + {val}% + + ), + }, + { + title: "Flagged", + dataIndex: "flagged", + key: "flagged", + render: (val) => 20 ? "red" : "blue"}>{val} items, + }, + { + title: "Revision Rate", + dataIndex: "revisionNeeded", + key: "revisionNeeded", + render: (val) => ( + 10 ? "danger" : "secondary"}>{val}% + ), + }, + { + title: "Status", + dataIndex: "status", + key: "status", + render: (status) => ( + + {status.toUpperCase().replace("_", " ")} + + ), + }, +]; + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function ModelQualityDashboard() { + const [modelVersion, setModelVersion] = useState("v3.2"); + + return ( +
+ {/* ── Header ── */} + +
+ + Model Performance & Data Quality + + + Monitor model accuracy and annotation consistency + + + + + Model Version: + + + + + + + {/* ── Main Metrics Gauges ── */} + + + + ( +
+
{pct}
+
F1 Score
+
+ )} + /> + +
+ + + + + + + + + + + + + + + } + /> + + + } + /> + + + } + /> + + + + + + + + {/* Quality Trend */} + + + + Model Performance Trend (Last 12 Iterations) + + } + extra={ + + } + style={{ marginBottom: 20 }} + > + + + + + {/* Confusion Matrix */} + + Confusion Matrix (v3.2)} + style={{ marginBottom: 20 }} + > +
+
+
+ + Pred Syn + + + Pred Non + + + + Act Syn + +
+ 1,420 +
TP
+
+
+ 85 +
FN
+
+ + + Act Non + +
+ 112 +
FP
+
+
+ 3,200 +
TN
+
+
+
+ + *Based on evaluation dataset: 4,817 samples + +
+
+ + + + + {/* Batch Data Quality Table */} + + + Data Quality Log (Per Batch) + + } + > +
+ + + ); +} + +export default ModelQualityDashboard; diff --git a/client/src/views/project-manager/ProjectManager.js b/client/src/views/project-manager/ProjectManager.js new file mode 100644 index 00000000..09a0b3a3 --- /dev/null +++ b/client/src/views/project-manager/ProjectManager.js @@ -0,0 +1,205 @@ +import React, { useState } from "react"; +import { + Layout, + Menu, + Typography, + ConfigProvider, + Space, + Button, + Tag, +} from "antd"; +import { + DashboardOutlined, + TeamOutlined, + ScheduleOutlined, + LineChartOutlined, + BugOutlined, + SettingOutlined, + DatabaseOutlined, + UserOutlined, + CrownOutlined, +} from "@ant-design/icons"; + +import AnnotationDashboard from "./AnnotationDashboard"; +import ProofreaderProgress from "./ProofreaderProgress"; +import QuotaManagement from "./QuotaManagement"; +import ModelQualityDashboard from "./ModelQualityDashboard"; +import VolumeTracker from "./VolumeTracker"; +import ProjectManagerLogin from "./ProjectManagerLogin"; +import { + ProjectManagerProvider, + useProjectManager, +} from "../../contexts/ProjectManagerContext"; + +const { Sider, Content } = Layout; +const { Title, Text } = Typography; + +const ProjectManagerInner = () => { + const { isAuthenticated, user, logout, isAdmin, isWorker, loading } = + useProjectManager(); + + const [selectedKey, setSelectedKey] = useState("dashboard"); + + // ── Login Gate ──────────────────────────────────────────────────────────── + if (!isAuthenticated) { + return ; + } + + // ── Menu items ──────────────────────────────────────────────────────────── + const menuItems = [ + { key: "dashboard", icon: , label: "Dashboard" }, + { + key: "progress", + icon: , + label: isAdmin ? "Team Progress" : "My Progress", + }, + { + key: "quotas", + icon: , + label: isAdmin ? "Weekly Quotas" : "My Quota", + }, + { + key: "volumes", + icon: , + label: isWorker ? "My Volumes" : "Volume Tracker", + }, + // Admin-only items + ...(isAdmin + ? [ + { + key: "model-quality", + icon: , + label: "Model Quality", + }, + ] + : []), + { type: "divider" }, + { + key: "issues", + icon: , + label: "Issues & Flags", + disabled: true, + }, + { + key: "settings", + icon: , + label: "Settings", + disabled: true, + }, + ]; + + const renderSubView = () => { + switch (selectedKey) { + case "dashboard": + return ; + case "progress": + return ; + case "quotas": + return ; + case "volumes": + return ; + case "model-quality": + return ; + default: + return ; + } + }; + + return ( + + + {/* ── Brand header ── */} +
+ + Project Manager + + + v2.0 · seg.bio + +
+ + {/* ── User Profile & Role Tag ── */} +
+
+ + + {user?.name} + + + + {isAdmin ? "ADMIN" : "WORKER"} + + + + +
+
+ + {/* ── Navigation ── */} + setSelectedKey(e.key)} + items={menuItems} + style={{ borderRight: 0, flex: 1 }} + /> + + + + {renderSubView()} + + + ); +}; + +const ProjectManager = () => ( + + + + + +); + +export default ProjectManager; diff --git a/client/src/views/project-manager/ProjectManagerLogin.js b/client/src/views/project-manager/ProjectManagerLogin.js new file mode 100644 index 00000000..aa9229da --- /dev/null +++ b/client/src/views/project-manager/ProjectManagerLogin.js @@ -0,0 +1,134 @@ +import React, { useState } from "react"; +import { Card, Form, Input, Button, Typography, Space, Divider } from "antd"; +import { UserOutlined, LockOutlined, RocketOutlined } from "@ant-design/icons"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Title, Text } = Typography; + +export default function ProjectManagerLogin() { + const { login } = useProjectManager(); + const [loading, setLoading] = useState(false); + + const onFinish = async (values) => { + setLoading(true); + const success = await login(values.username, values.password); + setLoading(false); + }; + + return ( +
+ +
+
+ +
+ + Project Manager + + Sign in to manage EM connectomics tasks +
+ +
+ + } + placeholder="Username" + style={{ borderRadius: 8 }} + /> + + + + } + placeholder="Password" + style={{ borderRadius: 8 }} + /> + + + + + + + + + + Credentials Template + + + +
+ + + Admin: admin / admin123 + + + Worker: alex / alex123 + + +
+
+
+ ); +} diff --git a/client/src/views/project-manager/ProofreaderProgress.js b/client/src/views/project-manager/ProofreaderProgress.js new file mode 100644 index 00000000..8d356999 --- /dev/null +++ b/client/src/views/project-manager/ProofreaderProgress.js @@ -0,0 +1,484 @@ +import React, { useMemo } from "react"; +import { + Card, + Table, + Avatar, + Progress, + Typography, + Row, + Col, + Tag, + Space, + Badge, + Tooltip, + Divider, + Button, + Popconfirm, + Spin, +} from "antd"; +import { + UserOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + ThunderboltOutlined, + RiseOutlined, + ReloadOutlined, +} from "@ant-design/icons"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +const { Title, Text } = Typography; + +// ─── Read-only display pill (for metrics that must not look editable) ───────── +const ReadOnlyPill = ({ children, color = "#595959", bg = "#fafafa" }) => ( + + {children} + +); + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function WeeklyThroughputChart({ throughput }) { + const W = 800; + const H = 150; + const PAD = 40; + const chartW = W - PAD * 2; + const chartH = H - PAD; + + if (!throughput || throughput.length === 0) return null; + const maxVal = Math.max(...throughput.map((d) => d.count)) * 1.1; + const barWidth = (chartW / throughput.length) * 0.6; + const gap = (chartW / throughput.length) * 0.4; + + return ( + + {[0, 0.5, 1].map((f) => ( + + ))} + {throughput.map((d, i) => { + const x = PAD + i * (barWidth + gap) + gap / 2; + const barH = (d.count / maxVal) * chartH; + const y = PAD / 2 + chartH - barH; + return ( + + + + + + {d.day} + + + {d.count.toLocaleString()} + + + ); + })} + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function ProofreaderProgress() { + const { + quotaData, + proofreaderData, + throughputData, + saving, + resetData, + isWorker, + } = useProjectManager(); + + // Helper to get calculated stats for a proofreader from quotaData + const getStats = (key) => { + const quota = quotaData.find((q) => q.key === key); + if (!quota) return { target: 1500, actual: 0 }; + const target = + quota.mon + + quota.tue + + quota.wed + + quota.thu + + quota.fri + + quota.sat + + quota.sun; + const actual = + (quota.actualMon || 0) + + (quota.actualTue || 0) + + (quota.actualWed || 0) + + (quota.actualThu || 0) + + (quota.actualFri || 0) + + (quota.actualSat || 0) + + (quota.actualSun || 0); + return { target, actual }; + }; + + const topPerformers = useMemo(() => { + const withPoints = proofreaderData.map((p) => ({ + ...p, + weeklyPoints: getStats(p.key).actual, + })); + return [...withPoints] + .sort((a, b) => b.weeklyPoints - a.weeklyPoints) + .slice(0, 3); + }, [proofreaderData, quotaData]); + + const teamAccuracy = + proofreaderData.length > 0 + ? ( + proofreaderData.reduce((sum, p) => sum + p.accuracy, 0) / + proofreaderData.length + ).toFixed(1) + : 0; + + const COLUMNS = [ + { + title: "Proofreader", + dataIndex: "name", + key: "name", + render: (name, record) => ( + + } + size="small" + /> +
+ + {name} + + + {record.role} + +
+
+ ), + }, + { + title: "Total Points", + dataIndex: "totalPoints", + key: "totalPoints", + align: "right", + render: (pts) => {pts.toLocaleString()}, + }, + { + title: "This Week", + key: "weeklyProgress", + align: "right", + render: (_, record) => { + const { target, actual } = getStats(record.key); + const pct = target > 0 ? Math.round((actual / target) * 100) : 0; + return ( + + + {actual.toLocaleString()}{" "} + + / {target.toLocaleString()} + + + = target ? "#52c41a" : "#1890ff"} + /> + + ); + }, + }, + { + title: "Accuracy", + dataIndex: "accuracy", + key: "accuracy", + align: "center", + render: (pct) => ( + = 95 ? "#52c41a" : pct >= 90 ? "#faad14" : "#f5222d"} + bg={pct >= 95 ? "#f6ffed" : pct >= 90 ? "#fffbe6" : "#fff2f0"} + > + {pct}% + + ), + }, + { + title: "Last Active", + dataIndex: "lastActive", + key: "lastActive", + render: (time, record) => ( + + + {time} + + ), + }, + ]; + + return ( +
+ {/* ── Header ── */} +
+
+ + {isWorker ? "My Performance" : "Proofreader Performance"} + + + {isWorker + ? "Your personal throughput and accuracy · auto-saved to server" + : "Real-time throughput and accuracy tracking · auto-saved to server"} + {saving && ( + <> + {" "} + · saving… + + )} + +
+ + + +
+ + {/* ── Top Row: Individual Cards ── */} + + {proofreaderData.map((pr) => { + const { target, actual } = getStats(pr.key); + const pct = target > 0 ? Math.round((actual / target) * 100) : 0; + return ( +
+ + +
+ } + /> + +
+
+ + {pr.name} + + + {pr.role} + +
+
+
+ + Weekly Goal + + + {pct}% + +
+ = target ? "#52c41a" : "#1890ff"} + /> + + {actual.toLocaleString()} / {target.toLocaleString()} pts + +
+
+
+ + ); + })} + + + {/* ── Main Section: Table + Insights ── */} + + + + Active Session Metrics + + } + > +
+ + + + + + Top Performers + + } + style={{ height: "100%" }} + > + + {topPerformers.map((pr, idx) => ( +
+
+ {idx + 1} +
+
+ + {pr.name} + +
+ + {pr.weeklyPoints.toLocaleString()} pts this week + +
+ + +{Math.round(pr.accuracy)}% acc + +
+ ))} + + + +
+ + Team + Accuracy + +
+ + + Target: 95.0% + +
+
+
+
+ + + + {/* ── Bottom Section: Team Throughput ── */} + + Team Throughput (Last 7 Days) + + } + > + + + + ); +} + +export default ProofreaderProgress; diff --git a/client/src/views/project-manager/QuotaManagement.js b/client/src/views/project-manager/QuotaManagement.js new file mode 100644 index 00000000..e3d227df --- /dev/null +++ b/client/src/views/project-manager/QuotaManagement.js @@ -0,0 +1,474 @@ +import React, { useMemo } from "react"; +import { + Card, + Table, + DatePicker, + Button, + Input, + InputNumber, + Row, + Col, + Typography, + Space, + Tag, + Divider, + Progress, + Tooltip, + message, + Popconfirm, + Spin, +} from "antd"; +import { + ScheduleOutlined, + SendOutlined, + ThunderboltOutlined, + LeftOutlined, + RightOutlined, + EditOutlined, + ReloadOutlined, + SaveOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; +import weekday from "dayjs/plugin/weekday"; +import localeData from "dayjs/plugin/localeData"; +import { useState } from "react"; +import { useProjectManager } from "../../contexts/ProjectManagerContext"; + +dayjs.extend(weekday); +dayjs.extend(localeData); + +const { Title, Text } = Typography; +const { TextArea } = Input; + +// ─── Sparkline trend data for the last 8 weeks (attainment percentages) ────── + +const PERFORMANCE_TRENDS = { + 1: [98, 102, 100, 95, 105, 101, 100, 99], + 2: [90, 92, 88, 85, 90, 94, 91, 89], + 3: [70, 75, 65, 80, 72, 60, 55, 62], + 4: [100, 105, 110, 100, 102, 108, 104, 106], +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function getAttainmentColor(percent) { + if (percent >= 100) return "#52c41a"; + if (percent >= 75) return "#faad14"; + return "#f5222d"; +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + +function Sparkline({ data }) { + const W = 100; + const H = 20; + const gap = 2; + const barW = (W - (data.length - 1) * gap) / data.length; + return ( + + {data.map((v, i) => { + const h = (v / 120) * H; + return ( + + ); + })} + + ); +} + +// ─── Main Component ─────────────────────────────────────────────────────────── + +function QuotaManagement() { + const { + quotaData, + msgPreview, + setQuotaData, + setMsgPreview, + saving, + resetData, + isWorker, + activeWorker, + } = useProjectManager(); + + const [selectedWeek, setSelectedWeek] = useState(dayjs().startOf("week")); + + const handleUpdateQuota = (key, day, val) => { + const updated = quotaData.map((item) => + item.key === key ? { ...item, [day]: val } : item, + ); + setQuotaData(updated); + }; + + const handleAutoAllocate = () => { + message.loading("Calculating optimal distribution...", 1.5).then(() => { + message.success( + "Quotas auto-allocated based on user capacity and dataset priority.", + ); + }); + }; + + const handleSendQuotas = () => { + message.success("Weekly quotas dispatched to proofreaders."); + }; + + // Day columns + const dayCols = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map( + (day) => ({ + title: day.charAt(0).toUpperCase() + day.slice(1), + dataIndex: day, + key: day, + width: 100, + render: (val, record) => { + const actual = + record[`actual${day.charAt(0).toUpperCase() + day.slice(1)}`]; + const attainment = val > 0 ? Math.round((actual / val) * 100) : 100; + return ( +
+
+ + Target + + handleUpdateQuota(record.key, day, v)} + style={{ + width: "100%", + fontWeight: "bold", + background: "#e6f7ff", + borderRadius: 4, + border: "1px solid #91d5ff", + }} + controls={false} + /> +
+
+ + Actual + + + + {actual} + + +
+
+ ); + }, + }), + ); + + const finalColumns = [ + { + title: "Proofreader", + dataIndex: "name", + key: "name", + fixed: "left", + width: 160, + render: (text, record) => ( +
+ {text} +
+ {record.datasets.join(", ")} +
+
+ ), + }, + ...dayCols, + { + title: "Weekly Summary", + key: "total", + width: 140, + fixed: "right", + render: (_, record) => { + const targetTotal = + record.mon + + record.tue + + record.wed + + record.thu + + record.fri + + record.sat + + record.sun; + const actualTotal = + record.actualMon + + record.actualTue + + record.actualWed + + record.actualThu + + record.actualFri + + record.actualSat + + record.actualSun; + const attainment = Math.round((actualTotal / targetTotal) * 100); + return ( +
+
+ {actualTotal} + / {targetTotal} +
+ +
+ ); + }, + }, + { + title: "8-Wk Trend", + key: "trend", + width: 120, + fixed: "right", + render: (_, record) => ( + + ), + }, + ]; + + // Dynamic Calculations for Summary + const totals = useMemo(() => { + return quotaData.reduce( + (acc, row) => { + acc.target += + row.mon + row.tue + row.wed + row.thu + row.fri + row.sat + row.sun; + acc.actual += + (row.actualMon || 0) + + (row.actualTue || 0) + + (row.actualWed || 0) + + (row.actualThu || 0) + + (row.actualFri || 0) + + (row.actualSat || 0) + + (row.actualSun || 0); + return acc; + }, + { target: 0, actual: 0 }, + ); + }, [quotaData]); + + const globalUtilization = + totals.target > 0 ? Math.round((totals.actual / totals.target) * 100) : 0; + const remainingBuffer = Math.max(0, totals.target - totals.actual); + + return ( +
+ {/* ── Header ── */} + +
+ + {isWorker ? "My Weekly Quota" : "Weekly Quota Management"} + + + Plan targets and track capacity utilization · Real-time Sync + {saving && ( + <> + {" "} + · saving… + + )} + + + + + + + + + + + + + {/* ── Quota Table ── */} + + + Targets vs Actuals + + Week 10 (Current) + + + } + extra={ + + Input numbers in blue cells to update targets instantly. + + } + style={{ marginBottom: 20, borderRadius: 8, overflow: "hidden" }} + > +
+ + + + {/* Capacity Summary */} + + Capacity Summary} + style={{ borderRadius: 8 }} + > +
+ + + Global Utilization + + + + +
+ Total Weekly Target + {totals.target.toLocaleString()} points +
+
+ Actual points so far + {totals.actual.toLocaleString()} points +
+
+ Remaining to target + 0 ? "#faad14" : "#52c41a" }} + > + {remainingBuffer.toLocaleString()} + +
+
+
+ + + {/* Communication Preview */} + + + Team Communication Preview + + } + extra={ + + } + > +