From 0560e3035b7783b9b801c0f21d59b30d492d26b4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 18 May 2026 11:16:04 +0000 Subject: [PATCH 01/18] add blueapi stuffs --- apps/p99/package.json | 2 ++ pnpm-lock.yaml | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/apps/p99/package.json b/apps/p99/package.json index ae40421..a32e513 100644 --- a/apps/p99/package.json +++ b/apps/p99/package.json @@ -14,6 +14,8 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "@atlas/blueapi": "workspace:*", + "@atlas/blueapi-query": "workspace:*", "@diamondlightsource/sci-react-ui": "^0.2.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7bc823..175f13a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,12 @@ importers: apps/p99: dependencies: + '@atlas/blueapi': + specifier: workspace:* + version: link:../../packages/blueapi + '@atlas/blueapi-query': + specifier: workspace:* + version: link:../../packages/blueapi-query '@diamondlightsource/sci-react-ui': specifier: ^0.2.0 version: 0.2.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@mui/icons-material@6.5.0(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@mui/material@6.5.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) @@ -4440,6 +4446,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate.io-boolean@1.0.4: From 21ed5e1fc2a1456f4e2000f4cf2c7e58c75faa34 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Wed, 20 May 2026 13:15:54 +0000 Subject: [PATCH 02/18] plan cards --- apps/p99/package.json | 4 +- apps/p99/src/components/PlanCard.tsx | 94 +++++++ .../hooks/scanEvents/useAvailablePlans.tsx | 14 + apps/p99/src/main.tsx | 12 +- apps/p99/src/routes/Dashboard.tsx | 253 +++++++++++++++++- pnpm-lock.yaml | 6 + 6 files changed, 374 insertions(+), 9 deletions(-) create mode 100644 apps/p99/src/components/PlanCard.tsx create mode 100644 apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx diff --git a/apps/p99/package.json b/apps/p99/package.json index a32e513..bf67344 100644 --- a/apps/p99/package.json +++ b/apps/p99/package.json @@ -21,7 +21,9 @@ "@emotion/styled": "^11.14.1", "@mui/icons-material": "^6.5.0", "@mui/material": "<7.0.0", - "react-router-dom": "^7.7.1" + "@tanstack/react-query": "^5.90.21", + "react-router-dom": "^7.7.1", + "vite-plugin-relay": "^2.1.0" }, "devDependencies": { "@atlas/vitest-conf": "workspace:*", diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx new file mode 100644 index 0000000..307bf79 --- /dev/null +++ b/apps/p99/src/components/PlanCard.tsx @@ -0,0 +1,94 @@ +import { + Card, + CardContent, + Typography, + Chip, + Stack, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import CodeIcon from "@mui/icons-material/Code"; + +export const PlanCard = ({ plan }: { plan: any }) => { + return ( + + + + + {plan.name}() + + + + + + {plan.description || "No description provided."} + + + + + {/* This section handles the "Python-like" function arguments */} + + }> + + + + Parameters ({Object.keys(plan.schema?.properties || {}).length}) + + + + + + {Object.entries(plan.schema?.properties || {}).map( + ([key, value]: [string, any]) => ( + + + {key} + + + {value.type} + + + ), + )} + + + + + + ); +}; diff --git a/apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx b/apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx new file mode 100644 index 0000000..360428e --- /dev/null +++ b/apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx @@ -0,0 +1,14 @@ +import { usePlans } from "@atlas/blueapi-query"; + +export const useAvailablePlans = () => { + const query = usePlans(); + + return { + plans: query.data, + isLoading: query.isLoading, + isError: query.isError, + error: query.error, + // This allows the UI to trigger a refresh + refresh: query.refetch, + }; +}; diff --git a/apps/p99/src/main.tsx b/apps/p99/src/main.tsx index 68187c4..5b1e2d4 100644 --- a/apps/p99/src/main.tsx +++ b/apps/p99/src/main.tsx @@ -6,6 +6,10 @@ import { createRoot } from "react-dom/client"; import Dashboard from "./routes/Dashboard.tsx"; import { Layout } from "./routes/Layout.tsx"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createApi } from "@atlas/blueapi"; +import { BlueapiProvider } from "@atlas/blueapi-query"; + const router = createBrowserRouter([ { path: "/", @@ -19,10 +23,16 @@ const router = createBrowserRouter([ }, ]); +const queryClient = new QueryClient(); +export const api = createApi("/api"); createRoot(document.getElementById("root")!).render( - + + + + + , ); diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx index 2456fc8..c0374dc 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -1,16 +1,255 @@ -import { Box, Container, Typography } from "@mui/material"; +import { + Box, + Container, + Typography, + Button, + CircularProgress, + Alert, + Card, + CardContent, + Stack, + Grid, + Chip, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import CodeIcon from "@mui/icons-material/Code"; +import { usePlans } from "@atlas/blueapi-query"; +import type { Plan } from "@atlas/blueapi"; function Dashboard() { + const { data, isFetching, isError, refetch } = usePlans(); + return ( - - - - P99 + + {/* --- HEADER SECTION --- */} + + + P99 Control - - Minimal P99 + + Beamline Plan Library + + {/* --- REFRESH BUTTON --- */} + + + + + {/* --- ERROR HANDLING --- */} + {isError && ( + + Unauthorized or Service Unreachable. Please verify your Keycloak + session and proxy settings. + + )} + + {/* --- PLANS GRID --- */} + + {data?.plans?.map((plan: Plan) => { + // 1. Clean the description: Remove everything after "Parameters" + const cleanDescription = plan.description + ?.split(/parameters/i)[0] + .trim(); + + // 2. Cast schema to 'any' to avoid the "Property properties does not exist" error + const properties = (plan.schema as any)?.properties; + + return ( + + + + + + {plan.name} + + + + + + {cleanDescription || + "No documentation available for this protocol."} + + + + + {/* --- PARAMETERS ACCORDION --- */} + + } + sx={{ px: 0 }} + > + + + + View Function Parameters + + + + + {properties ? ( + Object.entries(properties).map( + ([key, value]: [string, any]) => { + // 1. Check if this specific key is in the 'required' array + const isRequired = ( + plan.schema as any + )?.required?.includes(key); + + return ( + + + + {key} + + + {/* 2. Visual indicator for required fields */} + {isRequired && ( + + * + + )} + + + + {/* 3. Optional: Add a "Required" label for extra clarity */} + {isRequired && ( + + Required + + )} + + {value.type || "any"} + + + + ); + }, + ) + ) : ( + + No arguments required for this plan. + + )} + + + + + + ); + })} + + + {/* --- EMPTY STATE --- */} + {!isFetching && !data?.plans?.length && !isError && ( + + + No plans detected. Click "Refresh" to query the Blueapi worker. + + + )} ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 175f13a..da0afc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,9 +136,15 @@ importers: '@mui/material': specifier: <7.0.0 version: 6.5.0(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react@18.3.1))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@18.3.1) react-router-dom: specifier: ^7.7.1 version: 7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + vite-plugin-relay: + specifier: ^2.1.0 + version: 2.1.0(babel-plugin-relay@20.1.1)(vite@7.3.1(@types/node@25.3.3)(terser@5.46.1)) devDependencies: '@atlas/vitest-conf': specifier: workspace:* From 870ed7567515d0df9dd9d4d8a8d210ed4575e113 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Wed, 20 May 2026 13:18:08 +0000 Subject: [PATCH 03/18] vite config.ts switch to blueapi --- apps/p99/vite.config.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/p99/vite.config.ts b/apps/p99/vite.config.ts index 98967c2..55b101d 100644 --- a/apps/p99/vite.config.ts +++ b/apps/p99/vite.config.ts @@ -1,9 +1,22 @@ +// apps/p99/vite.config.ts import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; +import relay from "vite-plugin-relay"; export default defineConfig({ - plugins: [react()], + plugins: [react(), relay], define: { global: {}, }, + // redirect API calls to the backend during development + // server: { + // proxy: { + // "/api": { + // target: "https://p99-blueapi.diamond.ac.uk", + // changeOrigin: true, + // secure: false, + // rewrite: path => path.replace(/^\/api/, ""), + // }, + // }, + // }, }); From 7773e056c8d58d745b4e23616fd0f52ebee2be3f Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 13:10:54 +0000 Subject: [PATCH 04/18] cleanup --- apps/p99/src/api.ts | 3 + apps/p99/src/components/PlanCard.tsx | 193 ++++++++---- apps/p99/src/components/WorkerStatusBar.tsx | 116 +++++++ .../hooks/scanEvents/useAvailablePlans.tsx | 14 - apps/p99/src/hooks/useWorkerStatus.ts | 34 ++ apps/p99/src/main.tsx | 3 +- apps/p99/src/routes/Dashboard.tsx | 297 ++++-------------- apps/p99/vite.config.ts | 22 +- 8 files changed, 358 insertions(+), 324 deletions(-) create mode 100644 apps/p99/src/api.ts create mode 100644 apps/p99/src/components/WorkerStatusBar.tsx delete mode 100644 apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx create mode 100644 apps/p99/src/hooks/useWorkerStatus.ts diff --git a/apps/p99/src/api.ts b/apps/p99/src/api.ts new file mode 100644 index 0000000..1964915 --- /dev/null +++ b/apps/p99/src/api.ts @@ -0,0 +1,3 @@ +import { createApi } from "@atlas/blueapi"; + +export const api = createApi("/api"); diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx index 307bf79..8d82ad6 100644 --- a/apps/p99/src/components/PlanCard.tsx +++ b/apps/p99/src/components/PlanCard.tsx @@ -1,94 +1,181 @@ +import { useState } from "react"; import { Card, CardContent, + Stack, Typography, Chip, - Stack, Divider, Accordion, AccordionSummary, AccordionDetails, + TextField, + MenuItem, + Box, + Button, + CircularProgress, } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import CodeIcon from "@mui/icons-material/Code"; +import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import type { Plan } from "@atlas/blueapi"; +import { api } from "../api"; + +interface PlanCardProps { + plan: Plan; + devicesData: any; + isWorkerRunning: boolean; + onSuccess: (msg: string) => void; + onError: (msg: string) => void; +} + +export function PlanCard({ + plan, + devicesData, + isWorkerRunning, + onSuccess, + onError, +}: PlanCardProps) { + const [formValues, setFormValues] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + const cleanDescription = plan.description?.split(/parameters/i)[0].trim(); + const properties = (plan.schema as any)?.properties || {}; + const requiredFields = (plan.schema as any)?.required || []; + + const handleInputChange = (paramName: string, value: any) => { + setFormValues(prev => ({ ...prev, [paramName]: value })); + }; + + const handleSubmit = async () => { + setSubmitting(true); + try { + const submitResult = await api.tasks.submit({ + name: plan.name, + params: formValues, + instrument_session: "p99-session-01", + }); + + await api.worker.setActiveTask(submitResult.task_id); + onSuccess(`Plan ${plan.name} started successfully!`); + } catch (err: any) { + onError( + err.response?.data?.detail || `Execution failed for ${plan.name}.`, + ); + } finally { + setSubmitting(false); + } + }; -export const PlanCard = ({ plan }: { plan: any }) => { return ( - - + + - {plan.name}() + {plan.name} - + - {plan.description || "No description provided."} + {cleanDescription || "No documentation available."} - + - {/* This section handles the "Python-like" function arguments */} - - }> + + } sx={{ px: 0 }}> - - - Parameters ({Object.keys(plan.schema?.properties || {}).length}) + + + Configuration - - - {Object.entries(plan.schema?.properties || {}).map( - ([key, value]: [string, any]) => ( - + + {Object.entries(properties).map(([key, value]: [string, any]) => { + const isRequired = requiredFields.includes(key); + const isDevice = + key.toLowerCase().includes("device") || + key.toLowerCase().includes("detector"); + + return ( + handleInputChange(key, e.target.value)} + helperText={value.description} + slotProps={{ + inputLabel: { shrink: true }, + }} > - - {key} - - - {value.type} - - - ), - )} + {isDevice + ? devicesData?.devices?.map((d: any) => ( + + {d.name} + + )) + : null} + + ); + })} + + + + ); -}; +} diff --git a/apps/p99/src/components/WorkerStatusBar.tsx b/apps/p99/src/components/WorkerStatusBar.tsx new file mode 100644 index 0000000..adea0a7 --- /dev/null +++ b/apps/p99/src/components/WorkerStatusBar.tsx @@ -0,0 +1,116 @@ +import { + Box, + Typography, + Stack, + Button, + CircularProgress, + Paper, +} from "@mui/material"; +import LoopIcon from "@mui/icons-material/Loop"; + +import type { WorkerState } from "@atlas/blueapi"; + +interface WorkerStatusBarProps { + workerState: WorkerState; + activeTaskId: string | null; + isFetching: boolean; + onSync: () => void; +} + +const getStatusColor = (state: WorkerState) => { + switch (state) { + case "RUNNING": + return { bg: "#e8f5e9", text: "#2e7d32", border: "#a5d6a7" }; + case "IDLE": + return { bg: "#e3f2fd", text: "#1565c0", border: "#90caf9" }; + case "PAUSED": + return { bg: "#fff3e0", text: "#ef6c00", border: "#ffcc80" }; + case "PANICKED": + case "ABORTING": + return { bg: "#ffebee", text: "#c62828", border: "#ef9a9a" }; + default: + return { bg: "#f5f5f5", text: "#616161", border: "#e0e0e0" }; + } +}; +export function WorkerStatusBar({ + workerState, + activeTaskId, + isFetching, + onSync, +}: WorkerStatusBarProps) { + const statusStyle = getStatusColor(workerState); + return ( + + + + + P99 Control + + + Beamline Plan Library + + + + + + {workerState === "RUNNING" && ( + + )} + STATE: {workerState} + {activeTaskId && ( + + ID: {activeTaskId.substring(0, 8)}... + + )} + + + + + + + ); +} diff --git a/apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx b/apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx deleted file mode 100644 index 360428e..0000000 --- a/apps/p99/src/hooks/scanEvents/useAvailablePlans.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { usePlans } from "@atlas/blueapi-query"; - -export const useAvailablePlans = () => { - const query = usePlans(); - - return { - plans: query.data, - isLoading: query.isLoading, - isError: query.isError, - error: query.error, - // This allows the UI to trigger a refresh - refresh: query.refetch, - }; -}; diff --git a/apps/p99/src/hooks/useWorkerStatus.ts b/apps/p99/src/hooks/useWorkerStatus.ts new file mode 100644 index 0000000..0a955f0 --- /dev/null +++ b/apps/p99/src/hooks/useWorkerStatus.ts @@ -0,0 +1,34 @@ +import { useState, useEffect } from "react"; +import { api } from "../api"; + +// Blueapi Hooks & Types + +import type { WorkerState } from "@atlas/blueapi"; + +export function useWorkerStatus() { + const [workerState, setWorkerState] = useState("UNKNOWN"); + const [activeTaskId, setActiveTaskId] = useState(null); + useEffect(() => { + const fetchStatus = async () => { + try { + const state = await api.worker.getState(); + setWorkerState(state); + + if (state === "RUNNING") { + const activeTask = await api.worker.getActiveTask(); + setActiveTaskId(activeTask?.task_id || "Active"); + } else { + setActiveTaskId(null); + } + } catch (err) { + setWorkerState("UNKNOWN"); + setActiveTaskId(null); + } + }; + fetchStatus(); + const interval = setInterval(fetchStatus, 1000); + return () => clearInterval(interval); + }, []); + + return { workerState, activeTaskId }; +} diff --git a/apps/p99/src/main.tsx b/apps/p99/src/main.tsx index 5b1e2d4..b60e67e 100644 --- a/apps/p99/src/main.tsx +++ b/apps/p99/src/main.tsx @@ -7,8 +7,8 @@ import Dashboard from "./routes/Dashboard.tsx"; import { Layout } from "./routes/Layout.tsx"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { createApi } from "@atlas/blueapi"; import { BlueapiProvider } from "@atlas/blueapi-query"; +import { api } from "./api"; const router = createBrowserRouter([ { @@ -24,7 +24,6 @@ const router = createBrowserRouter([ ]); const queryClient = new QueryClient(); -export const api = createApi("/api"); createRoot(document.getElementById("root")!).render( diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx index c0374dc..54d9fc5 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -1,256 +1,65 @@ -import { - Box, - Container, - Typography, - Button, - CircularProgress, - Alert, - Card, - CardContent, - Stack, - Grid, - Chip, - Divider, - Accordion, - AccordionSummary, - AccordionDetails, -} from "@mui/material"; -import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import CodeIcon from "@mui/icons-material/Code"; -import { usePlans } from "@atlas/blueapi-query"; +import { useState } from "react"; +import { Box, Container, Alert, Grid2 } from "@mui/material"; +import { usePlans, useDevices } from "@atlas/blueapi-query"; import type { Plan } from "@atlas/blueapi"; -function Dashboard() { - const { data, isFetching, isError, refetch } = usePlans(); - - return ( - - {/* --- HEADER SECTION --- */} - - - P99 Control - - - Beamline Plan Library - - - - {/* --- REFRESH BUTTON --- */} - - - - - {/* --- ERROR HANDLING --- */} - {isError && ( - - Unauthorized or Service Unreachable. Please verify your Keycloak - session and proxy settings. - - )} +import { useWorkerStatus } from "../hooks/useWorkerStatus"; +import { WorkerStatusBar } from "../components/WorkerStatusBar"; +import { PlanCard } from "../components/PlanCard"; - {/* --- PLANS GRID --- */} - - {data?.plans?.map((plan: Plan) => { - // 1. Clean the description: Remove everything after "Parameters" - const cleanDescription = plan.description - ?.split(/parameters/i)[0] - .trim(); - - // 2. Cast schema to 'any' to avoid the "Property properties does not exist" error - const properties = (plan.schema as any)?.properties; - - return ( - - - - - - {plan.name} - - - - - - {cleanDescription || - "No documentation available for this protocol."} - +function Dashboard() { + const { data: plansData, isFetching, isError, refetch } = usePlans(); + const { data: devicesData } = useDevices(); - + const { workerState, activeTaskId } = useWorkerStatus(); - {/* --- PARAMETERS ACCORDION --- */} - - } - sx={{ px: 0 }} - > - - - - View Function Parameters - - - - - {properties ? ( - Object.entries(properties).map( - ([key, value]: [string, any]) => { - // 1. Check if this specific key is in the 'required' array - const isRequired = ( - plan.schema as any - )?.required?.includes(key); + const [feedback, setFeedback] = useState<{ + type: "success" | "error"; + msg: string; + } | null>(null); - return ( - - - - {key} - + return ( + + - {/* 2. Visual indicator for required fields */} - {isRequired && ( - - * - - )} - + + {feedback && ( + setFeedback(null)} + > + {feedback.msg} + + )} - - {/* 3. Optional: Add a "Required" label for extra clarity */} - {isRequired && ( - - Required - - )} - - {value.type || "any"} - - - - ); - }, - ) - ) : ( - - No arguments required for this plan. - - )} - - - - - - ); - })} - + {isError && ( + + Unauthorized or Service Unreachable. Check proxy configs or Keycloak + authentication. + + )} - {/* --- EMPTY STATE --- */} - {!isFetching && !data?.plans?.length && !isError && ( - - - No plans detected. Click "Refresh" to query the Blueapi worker. - - - )} - + + {plansData?.plans?.map((plan: Plan) => ( + + setFeedback({ type: "success", msg })} + onError={msg => setFeedback({ type: "error", msg })} + /> + + ))} + + + ); } diff --git a/apps/p99/vite.config.ts b/apps/p99/vite.config.ts index 55b101d..f66be93 100644 --- a/apps/p99/vite.config.ts +++ b/apps/p99/vite.config.ts @@ -8,15 +8,15 @@ export default defineConfig({ define: { global: {}, }, - // redirect API calls to the backend during development - // server: { - // proxy: { - // "/api": { - // target: "https://p99-blueapi.diamond.ac.uk", - // changeOrigin: true, - // secure: false, - // rewrite: path => path.replace(/^\/api/, ""), - // }, - // }, - // }, + server: { + proxy: { + "/api": { + //target: "https://p99-blueapi.diamond.ac.uk", + target: "http://localhost:8000", + changeOrigin: true, + secure: false, + rewrite: path => path.replace(/^\/api/, ""), + }, + }, + }, }); From f4e53f19d02323bbd2ac50b37e23807a01afbc46 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 14:33:01 +0000 Subject: [PATCH 05/18] add devices and some warning --- apps/p99/src/components/DevicePanel.tsx | 77 ++++++++++++++ apps/p99/src/components/PlanCard.tsx | 132 ++++++++++++++++++------ apps/p99/src/routes/Dashboard.tsx | 75 ++++++++++---- 3 files changed, 235 insertions(+), 49 deletions(-) create mode 100644 apps/p99/src/components/DevicePanel.tsx diff --git a/apps/p99/src/components/DevicePanel.tsx b/apps/p99/src/components/DevicePanel.tsx new file mode 100644 index 0000000..3af9c92 --- /dev/null +++ b/apps/p99/src/components/DevicePanel.tsx @@ -0,0 +1,77 @@ +import { + Accordion, + AccordionSummary, + AccordionDetails, + Typography, + Stack, + Chip, + Box, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; + +interface DevicePanelProps { + devicesData: any; +} + +export function DevicePanel({ devicesData }: DevicePanelProps) { + const devices = devicesData?.devices || []; + + return ( + // Cleaned up container: Just a standard block wrapper now + + + }> + + + + Connected Devices ({devices.length}) + + + + + {devices.length === 0 ? ( + + No devices detected on the worker. + + ) : ( + + {devices.map((device: any) => ( + + ))} + + )} + + + + ); +} diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx index 8d82ad6..62fa28e 100644 --- a/apps/p99/src/components/PlanCard.tsx +++ b/apps/p99/src/components/PlanCard.tsx @@ -10,7 +10,6 @@ import { AccordionSummary, AccordionDetails, TextField, - MenuItem, Box, Button, CircularProgress, @@ -50,18 +49,56 @@ export function PlanCard({ const handleSubmit = async () => { setSubmitting(true); try { + const processedParams: Record = {}; + + Object.entries(properties).map(([key, value]: [string, any]) => { + const userValue = formValues[key]; + if (userValue === undefined || userValue === "") return; + + // Handle parameters expecting an Array (e.g., detectors: ["det1", "det2"]) + if (value.type === "array") { + if (typeof userValue === "string") { + // Splits by commas and cleans up whitespace: "det1, det2" -> ["det1", "det2"] + processedParams[key] = userValue + .split(",") + .map(item => item.trim()) + .filter(item => item !== ""); + } else { + processedParams[key] = [userValue]; + } + } else if (value.type === "number" || value.type === "integer") { + processedParams[key] = Number(userValue); + } else { + processedParams[key] = userValue; + } + }); + const submitResult = await api.tasks.submit({ name: plan.name, - params: formValues, + params: processedParams, instrument_session: "p99-session-01", }); await api.worker.setActiveTask(submitResult.task_id); onSuccess(`Plan ${plan.name} started successfully!`); } catch (err: any) { - onError( - err.response?.data?.detail || `Execution failed for ${plan.name}.`, - ); + const errorData = err.response?.data?.detail; + let userFriendlyMessage = `Execution failed for ${plan.name}.`; + + if (typeof errorData === "string") { + userFriendlyMessage = errorData; + } else if (Array.isArray(errorData)) { + userFriendlyMessage = errorData + .map((e: any) => { + const field = e.loc ? e.loc[e.loc.length - 1] : "Parameter"; + return `${field}: ${e.msg}`; + }) + .join(" | "); + } else if (errorData && typeof errorData === "object") { + userFriendlyMessage = errorData.msg || JSON.stringify(errorData); + } + + onError(userFriendlyMessage); } finally { setSubmitting(false); } @@ -76,7 +113,7 @@ export function PlanCard({ - - {cleanDescription || "No documentation available."} - - - + - Configuration + Configure & View Details + - + + {cleanDescription && ( + + + Protocol Documentation: + + + {cleanDescription} + + + )} + {Object.entries(properties).map(([key, value]: [string, any]) => { const isRequired = requiredFields.includes(key); const isDevice = @@ -124,8 +186,7 @@ export function PlanCard({ return ( handleInputChange(key, e.target.value)} - helperText={value.description} - slotProps={{ - inputLabel: { shrink: true }, + helperText={ + isDevice + ? `Enter device ID (e.g., det1). ${value.description || ""}` + : value.description + } + InputLabelProps={{ shrink: true }} + placeholder={isDevice ? "e.g., detector_a" : ""} + sx={{ + ...(isRequired && { + "& .MuiInputLabel-root": { + color: "warning.main", + fontWeight: "bold", + }, + "& .MuiOutlinedInput-root": { + "& fieldset": { + borderColor: "rgba(237, 108, 2, 0.5)", + }, + "&:hover fieldset": { + borderColor: "warning.main", + }, + }, + }), }} - > - {isDevice - ? devicesData?.devices?.map((d: any) => ( - - {d.name} - - )) - : null} - + /> ); })} diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx index 54d9fc5..082504f 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -1,16 +1,17 @@ import { useState } from "react"; -import { Box, Container, Alert, Grid2 } from "@mui/material"; +import { Box, Container, Alert, Grid } from "@mui/material"; import { usePlans, useDevices } from "@atlas/blueapi-query"; import type { Plan } from "@atlas/blueapi"; +// Components & Hooks import { useWorkerStatus } from "../hooks/useWorkerStatus"; import { WorkerStatusBar } from "../components/WorkerStatusBar"; +import { DevicePanel } from "../components/DevicePanel"; import { PlanCard } from "../components/PlanCard"; function Dashboard() { const { data: plansData, isFetching, isError, refetch } = usePlans(); const { data: devicesData } = useDevices(); - const { workerState, activeTaskId } = useWorkerStatus(); const [feedback, setFeedback] = useState<{ @@ -20,6 +21,7 @@ function Dashboard() { return ( + {/* Sticky Global Status Header */} - - {feedback && ( + {/* Sticky Global Feedback Alert Wrapper */} + {feedback && ( + setFeedback(null)} + elevation={3} > {feedback.msg} - )} + + )} + {isError && ( Unauthorized or Service Unreachable. Check proxy configs or Keycloak @@ -45,19 +59,42 @@ function Dashboard() { )} - - {plansData?.plans?.map((plan: Plan) => ( - - setFeedback({ type: "success", msg })} - onError={msg => setFeedback({ type: "error", msg })} - /> - - ))} - + + {/* LEFT COLUMN: Scrollable Plans Library */} + + + {plansData?.plans?.map((plan: Plan) => ( + + setFeedback({ type: "success", msg })} + onError={msg => setFeedback({ type: "error", msg })} + /> + + ))} + + + + {/* RIGHT COLUMN: Persistent Device Panel + FIX: We apply sticky properties directly to the grid column item. + */} + + + + ); From 790d5402fb583e7fd0158ed6fff50654d42a80ec Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 14:48:03 +0000 Subject: [PATCH 06/18] fix lints --- apps/p99/src/components/DevicePanel.tsx | 14 ++++- apps/p99/src/components/PlanCard.tsx | 75 +++++++++++++++++-------- apps/p99/src/routes/Dashboard.tsx | 35 +++++++----- apps/p99/vite.config.ts | 28 +++++---- 4 files changed, 100 insertions(+), 52 deletions(-) diff --git a/apps/p99/src/components/DevicePanel.tsx b/apps/p99/src/components/DevicePanel.tsx index 3af9c92..5d6c14d 100644 --- a/apps/p99/src/components/DevicePanel.tsx +++ b/apps/p99/src/components/DevicePanel.tsx @@ -10,15 +10,23 @@ import { import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent"; +interface DeviceInfo { + name: string; + [key: string]: unknown; +} + interface DevicePanelProps { - devicesData: any; + devicesData: + | { + devices?: DeviceInfo[]; + } + | undefined; } export function DevicePanel({ devicesData }: DevicePanelProps) { const devices = devicesData?.devices || []; return ( - // Cleaned up container: Just a standard block wrapper now - {devices.map((device: any) => ( + {devices.map((device: DeviceInfo) => ( void; onError: (msg: string) => void; @@ -30,35 +41,43 @@ interface PlanCardProps { export function PlanCard({ plan, - devicesData, isWorkerRunning, onSuccess, onError, }: PlanCardProps) { - const [formValues, setFormValues] = useState>({}); + const [formValues, setFormValues] = useState>( + {}, + ); const [submitting, setSubmitting] = useState(false); const cleanDescription = plan.description?.split(/parameters/i)[0].trim(); - const properties = (plan.schema as any)?.properties || {}; - const requiredFields = (plan.schema as any)?.required || []; - const handleInputChange = (paramName: string, value: any) => { + // Safely cast out the JSON Schema blocks from the generic Plan type object + const planSchema = plan.schema as + | { properties?: Record; required?: string[] } + | undefined; + const properties = planSchema?.properties || {}; + const requiredFields = planSchema?.required || []; + + const handleInputChange = (paramName: string, value: string) => { setFormValues(prev => ({ ...prev, [paramName]: value })); }; const handleSubmit = async () => { setSubmitting(true); try { - const processedParams: Record = {}; + const processedParams: Record< + string, + string | number | (string | number)[] + > = {}; - Object.entries(properties).map(([key, value]: [string, any]) => { + Object.entries(properties).forEach(([key, value]) => { const userValue = formValues[key]; + if (userValue === undefined || userValue === "") return; - // Handle parameters expecting an Array (e.g., detectors: ["det1", "det2"]) if (value.type === "array") { if (typeof userValue === "string") { - // Splits by commas and cleans up whitespace: "det1, det2" -> ["det1", "det2"] processedParams[key] = userValue .split(",") .map(item => item.trim()) @@ -74,28 +93,30 @@ export function PlanCard({ }); const submitResult = await api.tasks.submit({ - name: plan.name, + name: plan.name || "", params: processedParams, instrument_session: "p99-session-01", }); await api.worker.setActiveTask(submitResult.task_id); onSuccess(`Plan ${plan.name} started successfully!`); - } catch (err: any) { - const errorData = err.response?.data?.detail; + } catch (err: unknown) { + // Safe, strongly typed error wrapper instead of raw 'any' parsing + const axiosError = err as { + response?: { data?: { detail?: string | PydanticValidationError[] } }; + }; + const errorData = axiosError.response?.data?.detail; let userFriendlyMessage = `Execution failed for ${plan.name}.`; if (typeof errorData === "string") { userFriendlyMessage = errorData; } else if (Array.isArray(errorData)) { userFriendlyMessage = errorData - .map((e: any) => { + .map((e: PydanticValidationError) => { const field = e.loc ? e.loc[e.loc.length - 1] : "Parameter"; return `${field}: ${e.msg}`; }) .join(" | "); - } else if (errorData && typeof errorData === "object") { - userFriendlyMessage = errorData.msg || JSON.stringify(errorData); } onError(userFriendlyMessage); @@ -177,7 +198,7 @@ export function PlanCard({ )} - {Object.entries(properties).map(([key, value]: [string, any]) => { + {Object.entries(properties).map(([key, value]) => { const isRequired = requiredFields.includes(key); const isDevice = key.toLowerCase().includes("device") || @@ -198,12 +219,20 @@ export function PlanCard({ value={formValues[key] ?? ""} onChange={e => handleInputChange(key, e.target.value)} helperText={ - isDevice - ? `Enter device ID (e.g., det1). ${value.description || ""}` - : value.description + value.type === "array" + ? `Enter values separated by commas (e.g. det1, det2). ${value.description || ""}` + : isDevice + ? `Enter device ID (e.g. motor_x). ${value.description || ""}` + : value.description } InputLabelProps={{ shrink: true }} - placeholder={isDevice ? "e.g., detector_a" : ""} + placeholder={ + value.type === "array" + ? "det1, det2" + : isDevice + ? "e.g., detector_a" + : "" + } sx={{ ...(isRequired && { "& .MuiInputLabel-root": { @@ -214,9 +243,7 @@ export function PlanCard({ "& fieldset": { borderColor: "rgba(237, 108, 2, 0.5)", }, - "&:hover fieldset": { - borderColor: "warning.main", - }, + "&:hover fieldset": { borderColor: "warning.main" }, }, }), }} diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx index 082504f..ec83c60 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -3,21 +3,22 @@ import { Box, Container, Alert, Grid } from "@mui/material"; import { usePlans, useDevices } from "@atlas/blueapi-query"; import type { Plan } from "@atlas/blueapi"; -// Components & Hooks import { useWorkerStatus } from "../hooks/useWorkerStatus"; import { WorkerStatusBar } from "../components/WorkerStatusBar"; import { DevicePanel } from "../components/DevicePanel"; import { PlanCard } from "../components/PlanCard"; +interface FeedbackState { + type: "success" | "error"; + msg: string; +} + function Dashboard() { const { data: plansData, isFetching, isError, refetch } = usePlans(); const { data: devicesData } = useDevices(); const { workerState, activeTaskId } = useWorkerStatus(); - const [feedback, setFeedback] = useState<{ - type: "success" | "error"; - msg: string; - } | null>(null); + const [feedback, setFeedback] = useState(null); return ( @@ -63,23 +64,25 @@ function Dashboard() { {/* LEFT COLUMN: Scrollable Plans Library */} - {plansData?.plans?.map((plan: Plan) => ( - + {/* Added a fallback array check to satisfy mapping safety rules */} + {(plansData?.plans || []).map((plan: Plan) => ( + setFeedback({ type: "success", msg })} - onError={msg => setFeedback({ type: "error", msg })} + onSuccess={(msg: string) => + setFeedback({ type: "success", msg }) + } + onError={(msg: string) => + setFeedback({ type: "error", msg }) + } /> ))} - {/* RIGHT COLUMN: Persistent Device Panel - FIX: We apply sticky properties directly to the grid column item. - */} + {/* RIGHT COLUMN: Persistent Device Panel */} - + diff --git a/apps/p99/vite.config.ts b/apps/p99/vite.config.ts index f66be93..bbbc621 100644 --- a/apps/p99/vite.config.ts +++ b/apps/p99/vite.config.ts @@ -3,20 +3,26 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import relay from "vite-plugin-relay"; +// export default defineConfig({ +// plugins: [react(), relay], +// define: { +// global: {}, +// }, +// server: { +// proxy: { +// "/api": { +// target: "https://p99-blueapi.diamond.ac.uk", +// //target: "http://localhost:8000", +// changeOrigin: true, +// secure: false, +// rewrite: path => path.replace(/^\/api/, ""), +// }, +// }, +// }, +// }); export default defineConfig({ plugins: [react(), relay], define: { global: {}, }, - server: { - proxy: { - "/api": { - //target: "https://p99-blueapi.diamond.ac.uk", - target: "http://localhost:8000", - changeOrigin: true, - secure: false, - rewrite: path => path.replace(/^\/api/, ""), - }, - }, - }, }); From dca78d5638ae051e68c7291cc65386c63b9278ee Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 15:10:10 +0000 Subject: [PATCH 07/18] more lint fix --- apps/p99/src/hooks/useWorkerStatus.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/p99/src/hooks/useWorkerStatus.ts b/apps/p99/src/hooks/useWorkerStatus.ts index 0a955f0..4ce214f 100644 --- a/apps/p99/src/hooks/useWorkerStatus.ts +++ b/apps/p99/src/hooks/useWorkerStatus.ts @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { api } from "../api"; -// Blueapi Hooks & Types +// Blueapi import type { WorkerState } from "@atlas/blueapi"; @@ -20,7 +20,7 @@ export function useWorkerStatus() { } else { setActiveTaskId(null); } - } catch (err) { + } catch { setWorkerState("UNKNOWN"); setActiveTaskId(null); } From 693f0dd6d083cbaf4f4f5c77d219c9d728ee42cf Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 15:40:31 +0000 Subject: [PATCH 08/18] add tests --- apps/p99/package.json | 1 + apps/p99/src/components/DevicePanel.test.tsx | 72 ++++++++ apps/p99/src/components/PlanCard.test.tsx | 152 ++++++++++++++++ .../src/components/WorkerStatusBar.test.tsx | 168 ++++++++++++++++++ apps/p99/src/hooks/useWorkerStatus.test.ts | 24 +++ pnpm-lock.yaml | 3 + 6 files changed, 420 insertions(+) create mode 100644 apps/p99/src/components/DevicePanel.test.tsx create mode 100644 apps/p99/src/components/PlanCard.test.tsx create mode 100644 apps/p99/src/components/WorkerStatusBar.test.tsx create mode 100644 apps/p99/src/hooks/useWorkerStatus.test.ts diff --git a/apps/p99/package.json b/apps/p99/package.json index bf67344..d245b92 100644 --- a/apps/p99/package.json +++ b/apps/p99/package.json @@ -22,6 +22,7 @@ "@mui/icons-material": "^6.5.0", "@mui/material": "<7.0.0", "@tanstack/react-query": "^5.90.21", + "@testing-library/react": "^15.0.7", "react-router-dom": "^7.7.1", "vite-plugin-relay": "^2.1.0" }, diff --git a/apps/p99/src/components/DevicePanel.test.tsx b/apps/p99/src/components/DevicePanel.test.tsx new file mode 100644 index 0000000..253ff29 --- /dev/null +++ b/apps/p99/src/components/DevicePanel.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from "@atlas/vitest-conf"; +import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui"; +import { DevicePanel } from "./DevicePanel"; +import { describe, it, expect } from "vitest"; + +const mockDevicesData = { + devices: [ + { name: "detector-1", type: "detector" }, + { name: "motor-x", type: "motor" }, + { name: "motor-y", type: "motor" }, + ], +}; + +describe("DevicePanel", () => { + it("renders the devices panel with title", () => { + render( + + + , + ); + + expect(screen.getByText(/Connected Devices/)).toBeInTheDocument(); + }); + + it("displays the correct device count", () => { + render( + + + , + ); + + expect(screen.getByText(/Connected Devices \(3\)/)).toBeInTheDocument(); + }); + + it("renders all devices as chips", () => { + render( + + + , + ); + + expect(screen.getByText("detector-1")).toBeInTheDocument(); + expect(screen.getByText("motor-x")).toBeInTheDocument(); + expect(screen.getByText("motor-y")).toBeInTheDocument(); + }); + + it("shows empty state when no devices are present", () => { + render( + + + , + ); + + expect(screen.getByText(/Connected Devices \(0\)/)).toBeInTheDocument(); + expect( + screen.getByText(/No devices detected on the worker/), + ).toBeInTheDocument(); + }); + + it("handles undefined devicesData gracefully", () => { + render( + + + , + ); + + expect(screen.getByText(/Connected Devices \(0\)/)).toBeInTheDocument(); + expect( + screen.getByText(/No devices detected on the worker/), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/p99/src/components/PlanCard.test.tsx b/apps/p99/src/components/PlanCard.test.tsx new file mode 100644 index 0000000..329e48e --- /dev/null +++ b/apps/p99/src/components/PlanCard.test.tsx @@ -0,0 +1,152 @@ +import { render, screen, waitFor } from "@atlas/vitest-conf"; +import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui"; +import { PlanCard } from "./PlanCard"; +import type { Plan } from "@atlas/blueapi"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +// Mock the api module +vi.mock("../api", () => ({ + api: { + tasks: { + submit: vi.fn(), + }, + worker: { + setActiveTask: vi.fn(), + }, + }, +})); + +const mockPlan: Plan = { + name: "test-plan", + description: "Test plan description\n\nParameters:\nSome params", + schema: { + properties: { + param1: { + type: "string", + title: "Parameter 1", + description: "First parameter", + }, + param2: { + type: "number", + title: "Parameter 2", + description: "Second parameter", + }, + }, + required: ["param1"], + }, +}; + +describe("PlanCard", () => { + const mockOnSuccess = vi.fn(); + const mockOnError = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the plan name and description", () => { + render( + + + , + ); + + expect(screen.getByText("test-plan")).toBeInTheDocument(); + expect(screen.getByText(/Test plan description/)).toBeInTheDocument(); + }); + + it("displays Python chip", () => { + render( + + + , + ); + + expect(screen.getByText("Python")).toBeInTheDocument(); + }); + + it("renders accordion with configure details", async () => { + render( + + + , + ); + + // Click accordion to expand + const accordionButton = screen.getByRole("button", { + name: /Configure & View Details/i, + }); + accordionButton.click(); + + await waitFor(() => { + expect(screen.getByText(/Protocol Documentation/)).toBeInTheDocument(); + }); + }); + + it("enables submit button when worker is idle", () => { + render( + + + , + ); + + const buttons = screen.getAllByRole("button"); + const runButton = buttons.find(btn => btn.textContent?.includes("Run")); + expect(runButton).not.toBeDisabled(); + }); + + it("disables submit button when worker is busy", () => { + render( + + + , + ); + + const buttons = screen.getAllByRole("button"); + const runButton = buttons.find(btn => btn.textContent?.includes("Run")); + expect(runButton).toBeDisabled(); + }); + + it("shows configure & view details button", () => { + render( + + + , + ); + + const configButton = screen.getByRole("button", { + name: /Configure & View Details/i, + }); + expect(configButton).toBeInTheDocument(); + }); +}); diff --git a/apps/p99/src/components/WorkerStatusBar.test.tsx b/apps/p99/src/components/WorkerStatusBar.test.tsx new file mode 100644 index 0000000..667b07d --- /dev/null +++ b/apps/p99/src/components/WorkerStatusBar.test.tsx @@ -0,0 +1,168 @@ +import { render, screen, waitFor } from "@atlas/vitest-conf"; +import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui"; +import { WorkerStatusBar } from "./WorkerStatusBar"; +import type { WorkerState } from "@atlas/blueapi"; +import { describe, it, expect, vi } from "vitest"; + +describe("WorkerStatusBar", () => { + const mockOnSync = vi.fn(); + + it("renders the title and subtitle", () => { + render( + + + , + ); + + expect(screen.getByText("P99 Control")).toBeInTheDocument(); + expect(screen.getByText("Beamline Plan Library")).toBeInTheDocument(); + }); + + it("displays worker state as IDLE", () => { + render( + + + , + ); + + expect(screen.getByText(/STATE: IDLE/)).toBeInTheDocument(); + }); + + it("displays worker state as RUNNING", () => { + render( + + + , + ); + + expect(screen.getByText(/STATE: RUNNING/)).toBeInTheDocument(); + expect(screen.getByText(/ID: task-/)).toBeInTheDocument(); + }); + + it("displays worker state as PAUSED", () => { + render( + + + , + ); + + expect(screen.getByText(/STATE: PAUSED/)).toBeInTheDocument(); + }); + + it("displays PANICKED state", () => { + render( + + + , + ); + + expect(screen.getByText(/STATE: PANICKED/)).toBeInTheDocument(); + }); + + it("displays ABORTING state", () => { + render( + + + , + ); + + expect(screen.getByText(/STATE: ABORTING/)).toBeInTheDocument(); + }); + + it("renders sync button", () => { + render( + + + , + ); + + const syncButton = screen.getByRole("button", { name: /sync/i }); + expect(syncButton).toBeInTheDocument(); + }); + + it("disables sync button when fetching", () => { + render( + + + , + ); + + const syncButton = screen.getByRole("button", { name: /sync/i }); + expect(syncButton).toBeDisabled(); + }); + + it("calls onSync when sync button is clicked", async () => { + render( + + + , + ); + + const syncButton = screen.getByRole("button", { name: /sync/i }); + syncButton.click(); + + await waitFor(() => { + expect(mockOnSync).toHaveBeenCalled(); + }); + }); + + it("displays UNKNOWN state", () => { + render( + + + , + ); + + expect(screen.getByText(/STATE: UNKNOWN/)).toBeInTheDocument(); + }); +}); diff --git a/apps/p99/src/hooks/useWorkerStatus.test.ts b/apps/p99/src/hooks/useWorkerStatus.test.ts new file mode 100644 index 0000000..9b786c7 --- /dev/null +++ b/apps/p99/src/hooks/useWorkerStatus.test.ts @@ -0,0 +1,24 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { useWorkerStatus } from "./useWorkerStatus"; + +vi.mock("../api", () => ({ + api: { + worker: { + getState: vi.fn().mockResolvedValue("IDLE"), + getActiveTask: vi.fn().mockResolvedValue(null), + }, + }, +})); + +describe("useWorkerStatus Hook", () => { + it("should initialize with default parameters and update state safely", async () => { + const { result } = renderHook(() => useWorkerStatus()); + + expect(result.current.workerState).toBe("UNKNOWN"); + + await waitFor(() => { + expect(result.current.workerState).toBe("IDLE"); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da0afc8..c59c8b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@18.3.1) + '@testing-library/react': + specifier: ^15.0.7 + version: 15.0.7(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^7.7.1 version: 7.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From a2028e707e3942241f750707d13f77bf8ff7b2d3 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 15:42:46 +0000 Subject: [PATCH 09/18] lint --- apps/p99/src/components/WorkerStatusBar.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/p99/src/components/WorkerStatusBar.test.tsx b/apps/p99/src/components/WorkerStatusBar.test.tsx index 667b07d..ff45357 100644 --- a/apps/p99/src/components/WorkerStatusBar.test.tsx +++ b/apps/p99/src/components/WorkerStatusBar.test.tsx @@ -1,7 +1,6 @@ import { render, screen, waitFor } from "@atlas/vitest-conf"; import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui"; import { WorkerStatusBar } from "./WorkerStatusBar"; -import type { WorkerState } from "@atlas/blueapi"; import { describe, it, expect, vi } from "vitest"; describe("WorkerStatusBar", () => { From 6cfa21e11dfd3fbdf29fdffca01395b01a77cf2e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 15:51:15 +0000 Subject: [PATCH 10/18] add upstreams api --- apps/p99/helm/values.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/p99/helm/values.yaml b/apps/p99/helm/values.yaml index 4af2932..a4046b5 100644 --- a/apps/p99/helm/values.yaml +++ b/apps/p99/helm/values.yaml @@ -5,3 +5,11 @@ ui-base: image: repository: ghcr.io/diamondlightsource/atlas/p99 tag: # Updated by CI job with latest release version + + upstreams: + - id: blueapi + path: /api/ + rewriteTarget: / + target: + external: + uri: https://p99-blueapi.diamond.ac.uk/ From c509362b421e885992ad422f6273a76d9eb00e8e Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 21 May 2026 16:30:16 +0000 Subject: [PATCH 11/18] make session ID changeable on status bar --- apps/p99/src/components/PlanCard.test.tsx | 6 ++++++ apps/p99/src/components/PlanCard.tsx | 6 ++++-- .../src/components/WorkerStatusBar.test.tsx | 21 +++++++++++++++++++ apps/p99/src/components/WorkerStatusBar.tsx | 14 +++++++++++++ apps/p99/src/routes/Dashboard.tsx | 4 ++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/p99/src/components/PlanCard.test.tsx b/apps/p99/src/components/PlanCard.test.tsx index 329e48e..c0e2b5d 100644 --- a/apps/p99/src/components/PlanCard.test.tsx +++ b/apps/p99/src/components/PlanCard.test.tsx @@ -50,6 +50,7 @@ describe("PlanCard", () => { @@ -66,6 +67,7 @@ describe("PlanCard", () => { @@ -81,6 +83,7 @@ describe("PlanCard", () => { @@ -104,6 +107,7 @@ describe("PlanCard", () => { @@ -121,6 +125,7 @@ describe("PlanCard", () => { @@ -138,6 +143,7 @@ describe("PlanCard", () => { diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx index 14924bf..a422940 100644 --- a/apps/p99/src/components/PlanCard.tsx +++ b/apps/p99/src/components/PlanCard.tsx @@ -35,6 +35,7 @@ interface PydanticValidationError { interface PlanCardProps { plan: Plan; isWorkerRunning: boolean; + instrumentSession: string; onSuccess: (msg: string) => void; onError: (msg: string) => void; } @@ -42,6 +43,7 @@ interface PlanCardProps { export function PlanCard({ plan, isWorkerRunning, + instrumentSession, onSuccess, onError, }: PlanCardProps) { @@ -52,7 +54,7 @@ export function PlanCard({ const cleanDescription = plan.description?.split(/parameters/i)[0].trim(); - // Safely cast out the JSON Schema blocks from the generic Plan type object + // JSON Schema blocks from the generic Plan type object const planSchema = plan.schema as | { properties?: Record; required?: string[] } | undefined; @@ -95,7 +97,7 @@ export function PlanCard({ const submitResult = await api.tasks.submit({ name: plan.name || "", params: processedParams, - instrument_session: "p99-session-01", + instrument_session: instrumentSession, }); await api.worker.setActiveTask(submitResult.task_id); diff --git a/apps/p99/src/components/WorkerStatusBar.test.tsx b/apps/p99/src/components/WorkerStatusBar.test.tsx index ff45357..acf1fb1 100644 --- a/apps/p99/src/components/WorkerStatusBar.test.tsx +++ b/apps/p99/src/components/WorkerStatusBar.test.tsx @@ -5,6 +5,7 @@ import { describe, it, expect, vi } from "vitest"; describe("WorkerStatusBar", () => { const mockOnSync = vi.fn(); + const mockOnSessionChange = vi.fn(); it("renders the title and subtitle", () => { render( @@ -14,6 +15,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -30,6 +33,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -45,6 +50,8 @@ describe("WorkerStatusBar", () => { activeTaskId="task-123" isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -61,6 +68,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -76,6 +85,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -91,6 +102,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -106,6 +119,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -122,6 +137,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={true} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -138,6 +155,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); @@ -158,6 +177,8 @@ describe("WorkerStatusBar", () => { activeTaskId={null} isFetching={false} onSync={mockOnSync} + instrumentSession="p99-session-01" + onInstrumentSessionChange={mockOnSessionChange} /> , ); diff --git a/apps/p99/src/components/WorkerStatusBar.tsx b/apps/p99/src/components/WorkerStatusBar.tsx index adea0a7..ac12b8a 100644 --- a/apps/p99/src/components/WorkerStatusBar.tsx +++ b/apps/p99/src/components/WorkerStatusBar.tsx @@ -5,6 +5,7 @@ import { Button, CircularProgress, Paper, + TextField, } from "@mui/material"; import LoopIcon from "@mui/icons-material/Loop"; @@ -15,6 +16,8 @@ interface WorkerStatusBarProps { activeTaskId: string | null; isFetching: boolean; onSync: () => void; + instrumentSession: string; + onInstrumentSessionChange: (session: string) => void; } const getStatusColor = (state: WorkerState) => { @@ -37,6 +40,8 @@ export function WorkerStatusBar({ activeTaskId, isFetching, onSync, + instrumentSession, + onInstrumentSessionChange, }: WorkerStatusBarProps) { const statusStyle = getStatusColor(workerState); return ( @@ -70,6 +75,15 @@ export function WorkerStatusBar({ + onInstrumentSessionChange(e.target.value)} + sx={{ minWidth: 180, bgcolor: "background.paper" }} + InputLabelProps={{ shrink: true }} + /> + (null); + const [instrumentSession, setInstrumentSession] = useState("p99-session-01"); return ( @@ -28,6 +29,8 @@ function Dashboard() { activeTaskId={activeTaskId} isFetching={isFetching} onSync={refetch} + instrumentSession={instrumentSession} + onInstrumentSessionChange={setInstrumentSession} /> {/* Sticky Global Feedback Alert Wrapper */} @@ -70,6 +73,7 @@ function Dashboard() { setFeedback({ type: "success", msg }) } From 49150a09898c167bc2e27575b6ce7fcb8b45394d Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 22 May 2026 10:50:47 +0000 Subject: [PATCH 12/18] minnor fix --- apps/p99/src/components/PlanCard.tsx | 3 +-- apps/p99/src/components/WorkerStatusBar.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx index a422940..71cda8b 100644 --- a/apps/p99/src/components/PlanCard.tsx +++ b/apps/p99/src/components/PlanCard.tsx @@ -103,7 +103,6 @@ export function PlanCard({ await api.worker.setActiveTask(submitResult.task_id); onSuccess(`Plan ${plan.name} started successfully!`); } catch (err: unknown) { - // Safe, strongly typed error wrapper instead of raw 'any' parsing const axiosError = err as { response?: { data?: { detail?: string | PydanticValidationError[] } }; }; @@ -227,7 +226,7 @@ export function PlanCard({ ? `Enter device ID (e.g. motor_x). ${value.description || ""}` : value.description } - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} placeholder={ value.type === "array" ? "det1, det2" diff --git a/apps/p99/src/components/WorkerStatusBar.tsx b/apps/p99/src/components/WorkerStatusBar.tsx index ac12b8a..e90614c 100644 --- a/apps/p99/src/components/WorkerStatusBar.tsx +++ b/apps/p99/src/components/WorkerStatusBar.tsx @@ -81,7 +81,7 @@ export function WorkerStatusBar({ value={instrumentSession} onChange={e => onInstrumentSessionChange(e.target.value)} sx={{ minWidth: 180, bgcolor: "background.paper" }} - InputLabelProps={{ shrink: true }} + slotProps={{ inputLabel: { shrink: true } }} /> Date: Fri, 22 May 2026 10:54:36 +0000 Subject: [PATCH 13/18] more lint --- apps/p99/src/routes/Dashboard.tsx | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx index 06d4303..0c01630 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Box, Container, Alert, Grid } from "@mui/material"; +import { Box, Container, Alert, Grid2 } from "@mui/material"; import { usePlans, useDevices } from "@atlas/blueapi-query"; import type { Plan } from "@atlas/blueapi"; @@ -63,13 +63,15 @@ function Dashboard() { )} - + {/* LEFT COLUMN: Scrollable Plans Library */} - - - {/* Added a fallback array check to satisfy mapping safety rules */} + + {(plansData?.plans || []).map((plan: Plan) => ( - + - + ))} - - + + {/* RIGHT COLUMN: Persistent Device Panel */} - - - + + ); From c761c41c2057b66492abbc62849e271e8e7d3570 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 22 May 2026 13:45:14 +0000 Subject: [PATCH 14/18] move to use useGetWorkerState and useActiveTask in atlas/blueapi --- apps/p99/src/hooks/useWorkerStatus.test.ts | 24 --------------- apps/p99/src/hooks/useWorkerStatus.ts | 34 ---------------------- apps/p99/src/routes/Dashboard.tsx | 24 ++++++++++----- 3 files changed, 17 insertions(+), 65 deletions(-) delete mode 100644 apps/p99/src/hooks/useWorkerStatus.test.ts delete mode 100644 apps/p99/src/hooks/useWorkerStatus.ts diff --git a/apps/p99/src/hooks/useWorkerStatus.test.ts b/apps/p99/src/hooks/useWorkerStatus.test.ts deleted file mode 100644 index 9b786c7..0000000 --- a/apps/p99/src/hooks/useWorkerStatus.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; -import { useWorkerStatus } from "./useWorkerStatus"; - -vi.mock("../api", () => ({ - api: { - worker: { - getState: vi.fn().mockResolvedValue("IDLE"), - getActiveTask: vi.fn().mockResolvedValue(null), - }, - }, -})); - -describe("useWorkerStatus Hook", () => { - it("should initialize with default parameters and update state safely", async () => { - const { result } = renderHook(() => useWorkerStatus()); - - expect(result.current.workerState).toBe("UNKNOWN"); - - await waitFor(() => { - expect(result.current.workerState).toBe("IDLE"); - }); - }); -}); diff --git a/apps/p99/src/hooks/useWorkerStatus.ts b/apps/p99/src/hooks/useWorkerStatus.ts deleted file mode 100644 index 4ce214f..0000000 --- a/apps/p99/src/hooks/useWorkerStatus.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { useState, useEffect } from "react"; -import { api } from "../api"; - -// Blueapi - -import type { WorkerState } from "@atlas/blueapi"; - -export function useWorkerStatus() { - const [workerState, setWorkerState] = useState("UNKNOWN"); - const [activeTaskId, setActiveTaskId] = useState(null); - useEffect(() => { - const fetchStatus = async () => { - try { - const state = await api.worker.getState(); - setWorkerState(state); - - if (state === "RUNNING") { - const activeTask = await api.worker.getActiveTask(); - setActiveTaskId(activeTask?.task_id || "Active"); - } else { - setActiveTaskId(null); - } - } catch { - setWorkerState("UNKNOWN"); - setActiveTaskId(null); - } - }; - fetchStatus(); - const interval = setInterval(fetchStatus, 1000); - return () => clearInterval(interval); - }, []); - - return { workerState, activeTaskId }; -} diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx index 0c01630..9ea063d 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -1,9 +1,13 @@ import { useState } from "react"; import { Box, Container, Alert, Grid2 } from "@mui/material"; -import { usePlans, useDevices } from "@atlas/blueapi-query"; +import { + usePlans, + useDevices, + useGetWorkerState, + useActiveTask, +} from "@atlas/blueapi-query"; import type { Plan } from "@atlas/blueapi"; -import { useWorkerStatus } from "../hooks/useWorkerStatus"; import { WorkerStatusBar } from "../components/WorkerStatusBar"; import { DevicePanel } from "../components/DevicePanel"; import { PlanCard } from "../components/PlanCard"; @@ -14,9 +18,15 @@ interface FeedbackState { } function Dashboard() { - const { data: plansData, isFetching, isError, refetch } = usePlans(); + const { data: plansData, isError } = usePlans(); const { data: devicesData } = useDevices(); - const { workerState, activeTaskId } = useWorkerStatus(); + const { + data: workerState, + isFetching: isWorkerFetching, + refetch: refetchWorkerState, + } = useGetWorkerState(); + const { data: activeTask } = useActiveTask(); + const activeTaskId = activeTask?.task_id ?? null; const [feedback, setFeedback] = useState(null); const [instrumentSession, setInstrumentSession] = useState("p99-session-01"); @@ -25,10 +35,10 @@ function Dashboard() { {/* Sticky Global Status Header */} From 73e15ee562431bbaca734950258bef9b4c5cb452 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 22 May 2026 14:07:43 +0000 Subject: [PATCH 15/18] move plancard to use "@atlas/blueapi-query" --- apps/p99/src/components/PlanCard.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx index 71cda8b..76e54cf 100644 --- a/apps/p99/src/components/PlanCard.tsx +++ b/apps/p99/src/components/PlanCard.tsx @@ -18,7 +18,13 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import CodeIcon from "@mui/icons-material/Code"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import type { Plan } from "@atlas/blueapi"; -import { api } from "../api"; +//import { api } from "../api"; + +import { + useGetWorkerState, + useSetActiveTask, + useSubmitTask, +} from "@atlas/blueapi-query"; interface SchemaProperty { type?: string; @@ -51,6 +57,9 @@ export function PlanCard({ {}, ); const [submitting, setSubmitting] = useState(false); + const submitTask = useSubmitTask(); + const startTask = useSetActiveTask(); + const workerState = useGetWorkerState(); const cleanDescription = plan.description?.split(/parameters/i)[0].trim(); @@ -94,13 +103,13 @@ export function PlanCard({ } }); - const submitResult = await api.tasks.submit({ + const submitResult = await submitTask.mutateAsync({ name: plan.name || "", params: processedParams, instrument_session: instrumentSession, }); - await api.worker.setActiveTask(submitResult.task_id); + await startTask.mutateAsync(submitResult.task_id); onSuccess(`Plan ${plan.name} started successfully!`); } catch (err: unknown) { const axiosError = err as { @@ -271,7 +280,7 @@ export function PlanCard({ submitting ? : } onClick={handleSubmit} - disabled={submitting || isWorkerRunning} + disabled={submitting || workerState.data !== "IDLE"} > {submitting ? "Running..." : `Run ${plan.name}`} From ba78c7c282ce934958942be813b9d5ff7676c17b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 22 May 2026 14:09:28 +0000 Subject: [PATCH 16/18] clean up main no longer need to pass api around --- apps/p99/src/api.ts | 3 --- apps/p99/src/main.tsx | 4 +++- 2 files changed, 3 insertions(+), 4 deletions(-) delete mode 100644 apps/p99/src/api.ts diff --git a/apps/p99/src/api.ts b/apps/p99/src/api.ts deleted file mode 100644 index 1964915..0000000 --- a/apps/p99/src/api.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createApi } from "@atlas/blueapi"; - -export const api = createApi("/api"); diff --git a/apps/p99/src/main.tsx b/apps/p99/src/main.tsx index b60e67e..72851a2 100644 --- a/apps/p99/src/main.tsx +++ b/apps/p99/src/main.tsx @@ -8,7 +8,7 @@ import { Layout } from "./routes/Layout.tsx"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BlueapiProvider } from "@atlas/blueapi-query"; -import { api } from "./api"; +import { createApi } from "@atlas/blueapi"; const router = createBrowserRouter([ { @@ -24,6 +24,8 @@ const router = createBrowserRouter([ ]); const queryClient = new QueryClient(); +export const api = createApi("/api"); + createRoot(document.getElementById("root")!).render( From fdf5dabba8078a4f862aedc9dab78bf095ca6cdc Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Fri, 22 May 2026 14:28:55 +0000 Subject: [PATCH 17/18] cleanup test --- apps/p99/src/components/PlanCard.test.tsx | 35 ++++++++++++----------- apps/p99/src/components/PlanCard.tsx | 2 -- apps/p99/src/routes/Dashboard.tsx | 1 - 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/p99/src/components/PlanCard.test.tsx b/apps/p99/src/components/PlanCard.test.tsx index c0e2b5d..3156b05 100644 --- a/apps/p99/src/components/PlanCard.test.tsx +++ b/apps/p99/src/components/PlanCard.test.tsx @@ -1,19 +1,20 @@ import { render, screen, waitFor } from "@atlas/vitest-conf"; +import { fireEvent } from "@testing-library/react"; import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui"; import { PlanCard } from "./PlanCard"; import type { Plan } from "@atlas/blueapi"; import { vi, describe, it, expect, beforeEach } from "vitest"; -// Mock the api module -vi.mock("../api", () => ({ - api: { - tasks: { - submit: vi.fn(), - }, - worker: { - setActiveTask: vi.fn(), - }, - }, +const mockUseGetWorkerState = vi.fn(() => ({ data: "IDLE" })); +const mockSubmitTask = { + mutateAsync: vi.fn(() => Promise.resolve({ task_id: "task-1" })), +}; +const mockStartTask = { mutateAsync: vi.fn(() => Promise.resolve()) }; + +vi.mock("@atlas/blueapi-query", () => ({ + useGetWorkerState: () => mockUseGetWorkerState(), + useSubmitTask: () => mockSubmitTask, + useSetActiveTask: () => mockStartTask, })); const mockPlan: Plan = { @@ -42,6 +43,10 @@ describe("PlanCard", () => { beforeEach(() => { vi.clearAllMocks(); + mockUseGetWorkerState.mockReset(); + mockUseGetWorkerState.mockReturnValue({ data: "IDLE" }); + mockSubmitTask.mutateAsync.mockClear(); + mockStartTask.mutateAsync.mockClear(); }); it("renders the plan name and description", () => { @@ -49,7 +54,6 @@ describe("PlanCard", () => { { { { const accordionButton = screen.getByRole("button", { name: /Configure & View Details/i, }); - accordionButton.click(); + fireEvent.click(accordionButton); await waitFor(() => { expect(screen.getByText(/Protocol Documentation/)).toBeInTheDocument(); @@ -106,7 +108,6 @@ describe("PlanCard", () => { { }); it("disables submit button when worker is busy", () => { + mockUseGetWorkerState.mockReturnValue({ data: "RUNNING" }); + render( { void; onError: (msg: string) => void; @@ -48,7 +47,6 @@ interface PlanCardProps { export function PlanCard({ plan, - isWorkerRunning, instrumentSession, onSuccess, onError, diff --git a/apps/p99/src/routes/Dashboard.tsx b/apps/p99/src/routes/Dashboard.tsx index 9ea063d..55169e6 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -84,7 +84,6 @@ function Dashboard() { > setFeedback({ type: "success", msg }) From 849247eb0fe176597b4b931cccc1fc0e1e30ffe3 Mon Sep 17 00:00:00 2001 From: Raymond Fan Date: Fri, 22 May 2026 15:52:11 +0100 Subject: [PATCH 18/18] Remove unused import statement in PlanCard.tsx --- apps/p99/src/components/PlanCard.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/p99/src/components/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx index d5cead2..58e1a2a 100644 --- a/apps/p99/src/components/PlanCard.tsx +++ b/apps/p99/src/components/PlanCard.tsx @@ -18,8 +18,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import CodeIcon from "@mui/icons-material/Code"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import type { Plan } from "@atlas/blueapi"; -//import { api } from "../api"; - import { useGetWorkerState, useSetActiveTask,