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/ diff --git a/apps/p99/package.json b/apps/p99/package.json index ae40421..d245b92 100644 --- a/apps/p99/package.json +++ b/apps/p99/package.json @@ -14,12 +14,17 @@ "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", "@mui/icons-material": "^6.5.0", "@mui/material": "<7.0.0", - "react-router-dom": "^7.7.1" + "@tanstack/react-query": "^5.90.21", + "@testing-library/react": "^15.0.7", + "react-router-dom": "^7.7.1", + "vite-plugin-relay": "^2.1.0" }, "devDependencies": { "@atlas/vitest-conf": "workspace:*", 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/DevicePanel.tsx b/apps/p99/src/components/DevicePanel.tsx new file mode 100644 index 0000000..5d6c14d --- /dev/null +++ b/apps/p99/src/components/DevicePanel.tsx @@ -0,0 +1,85 @@ +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 DeviceInfo { + name: string; + [key: string]: unknown; +} + +interface DevicePanelProps { + devicesData: + | { + devices?: DeviceInfo[]; + } + | undefined; +} + +export function DevicePanel({ devicesData }: DevicePanelProps) { + const devices = devicesData?.devices || []; + + return ( + + + }> + + + + Connected Devices ({devices.length}) + + + + + {devices.length === 0 ? ( + + No devices detected on the worker. + + ) : ( + + {devices.map((device: DeviceInfo) => ( + + ))} + + )} + + + + ); +} diff --git a/apps/p99/src/components/PlanCard.test.tsx b/apps/p99/src/components/PlanCard.test.tsx new file mode 100644 index 0000000..3156b05 --- /dev/null +++ b/apps/p99/src/components/PlanCard.test.tsx @@ -0,0 +1,159 @@ +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"; + +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 = { + 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(); + mockUseGetWorkerState.mockReset(); + mockUseGetWorkerState.mockReturnValue({ data: "IDLE" }); + mockSubmitTask.mutateAsync.mockClear(); + mockStartTask.mutateAsync.mockClear(); + }); + + 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, + }); + fireEvent.click(accordionButton); + + 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", () => { + mockUseGetWorkerState.mockReturnValue({ data: "RUNNING" }); + + 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/PlanCard.tsx b/apps/p99/src/components/PlanCard.tsx new file mode 100644 index 0000000..58e1a2a --- /dev/null +++ b/apps/p99/src/components/PlanCard.tsx @@ -0,0 +1,286 @@ +import { useState } from "react"; +import { + Card, + CardContent, + Stack, + Typography, + Chip, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + TextField, + 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 { + useGetWorkerState, + useSetActiveTask, + useSubmitTask, +} from "@atlas/blueapi-query"; + +interface SchemaProperty { + type?: string; + title?: string; + description?: string; +} + +interface PydanticValidationError { + loc?: (string | number)[]; + msg: string; + type: string; +} + +interface PlanCardProps { + plan: Plan; + instrumentSession: string; + onSuccess: (msg: string) => void; + onError: (msg: string) => void; +} + +export function PlanCard({ + plan, + instrumentSession, + onSuccess, + onError, +}: PlanCardProps) { + const [formValues, setFormValues] = useState>( + {}, + ); + const [submitting, setSubmitting] = useState(false); + const submitTask = useSubmitTask(); + const startTask = useSetActiveTask(); + const workerState = useGetWorkerState(); + + const cleanDescription = plan.description?.split(/parameters/i)[0].trim(); + + // 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< + string, + string | number | (string | number)[] + > = {}; + + Object.entries(properties).forEach(([key, value]) => { + const userValue = formValues[key]; + + if (userValue === undefined || userValue === "") return; + + if (value.type === "array") { + if (typeof userValue === "string") { + 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 submitTask.mutateAsync({ + name: plan.name || "", + params: processedParams, + instrument_session: instrumentSession, + }); + + await startTask.mutateAsync(submitResult.task_id); + onSuccess(`Plan ${plan.name} started successfully!`); + } catch (err: unknown) { + 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: PydanticValidationError) => { + const field = e.loc ? e.loc[e.loc.length - 1] : "Parameter"; + return `${field}: ${e.msg}`; + }) + .join(" | "); + } + + onError(userFriendlyMessage); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + {plan.name} + + + + + + + + } sx={{ px: 0 }}> + + + + Configure & View Details + + + + + + + {cleanDescription && ( + + + Protocol Documentation: + + + {cleanDescription} + + + )} + + {Object.entries(properties).map(([key, value]) => { + const isRequired = requiredFields.includes(key); + const isDevice = + key.toLowerCase().includes("device") || + key.toLowerCase().includes("detector"); + + return ( + handleInputChange(key, e.target.value)} + helperText={ + 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 + } + slotProps={{ inputLabel: { shrink: true } }} + placeholder={ + value.type === "array" + ? "det1, det2" + : 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" }, + }, + }), + }} + /> + ); + })} + + + + + + + + + + ); +} diff --git a/apps/p99/src/components/WorkerStatusBar.test.tsx b/apps/p99/src/components/WorkerStatusBar.test.tsx new file mode 100644 index 0000000..acf1fb1 --- /dev/null +++ b/apps/p99/src/components/WorkerStatusBar.test.tsx @@ -0,0 +1,188 @@ +import { render, screen, waitFor } from "@atlas/vitest-conf"; +import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui"; +import { WorkerStatusBar } from "./WorkerStatusBar"; +import { describe, it, expect, vi } from "vitest"; + +describe("WorkerStatusBar", () => { + const mockOnSync = vi.fn(); + const mockOnSessionChange = 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/components/WorkerStatusBar.tsx b/apps/p99/src/components/WorkerStatusBar.tsx new file mode 100644 index 0000000..e90614c --- /dev/null +++ b/apps/p99/src/components/WorkerStatusBar.tsx @@ -0,0 +1,130 @@ +import { + Box, + Typography, + Stack, + Button, + CircularProgress, + Paper, + TextField, +} 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; + instrumentSession: string; + onInstrumentSessionChange: (session: string) => 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, + instrumentSession, + onInstrumentSessionChange, +}: WorkerStatusBarProps) { + const statusStyle = getStatusColor(workerState); + return ( + + + + + P99 Control + + + Beamline Plan Library + + + + + onInstrumentSessionChange(e.target.value)} + sx={{ minWidth: 180, bgcolor: "background.paper" }} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + {workerState === "RUNNING" && ( + + )} + STATE: {workerState} + {activeTaskId && ( + + ID: {activeTaskId.substring(0, 8)}... + + )} + + + + + + + ); +} diff --git a/apps/p99/src/main.tsx b/apps/p99/src/main.tsx index 68187c4..72851a2 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 { BlueapiProvider } from "@atlas/blueapi-query"; +import { createApi } from "@atlas/blueapi"; + const router = createBrowserRouter([ { path: "/", @@ -19,10 +23,17 @@ 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..55169e6 100644 --- a/apps/p99/src/routes/Dashboard.tsx +++ b/apps/p99/src/routes/Dashboard.tsx @@ -1,17 +1,121 @@ -import { Box, Container, Typography } from "@mui/material"; +import { useState } from "react"; +import { Box, Container, Alert, Grid2 } from "@mui/material"; +import { + usePlans, + useDevices, + useGetWorkerState, + useActiveTask, +} from "@atlas/blueapi-query"; +import type { Plan } from "@atlas/blueapi"; + +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, isError } = usePlans(); + const { data: devicesData } = useDevices(); + 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"); + return ( - - - - P99 - - - Minimal P99 - - - + + {/* Sticky Global Status Header */} + + + {/* Sticky Global Feedback Alert Wrapper */} + {feedback && ( + + setFeedback(null)} + elevation={3} + > + {feedback.msg} + + + )} + + + {isError && ( + + Unauthorized or Service Unreachable. Check proxy configs or Keycloak + authentication. + + )} + + + {/* LEFT COLUMN: Scrollable Plans Library */} + + + {(plansData?.plans || []).map((plan: Plan) => ( + + + setFeedback({ type: "success", msg }) + } + onError={(msg: string) => + setFeedback({ type: "error", msg }) + } + /> + + ))} + + + + {/* RIGHT COLUMN: Persistent Device Panel */} + + + + + + ); } diff --git a/apps/p99/vite.config.ts b/apps/p99/vite.config.ts index 98967c2..bbbc621 100644 --- a/apps/p99/vite.config.ts +++ b/apps/p99/vite.config.ts @@ -1,8 +1,27 @@ +// 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(), 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()], + plugins: [react(), relay], define: { global: {}, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7bc823..c59c8b7 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) @@ -130,9 +136,18 @@ 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) + '@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) + 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:* @@ -4440,6 +4455,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: