diff --git a/apps/i15-1/src/components/AbortPlanButton.test.tsx b/apps/i15-1/src/components/AbortPlanButton.test.tsx new file mode 100644 index 0000000..36ae405 --- /dev/null +++ b/apps/i15-1/src/components/AbortPlanButton.test.tsx @@ -0,0 +1,43 @@ +import { render, screen, userEvent } from "@atlas/vitest-conf"; +import { AbortPlanButton } from "./AbortPlanButton"; +import { useSetWorkerState } from "@atlas/blueapi-query"; +import type { WorkerState, WorkerStateRequest } from "@atlas/blueapi"; +import type { UseMutationResult } from "@tanstack/react-query"; + +describe("AbortPlanButton", () => { + vi.mock("@atlas/blueapi-query"); + const mockedHook = vi.mocked(useSetWorkerState); + + const mutate = vi.fn(); + + /* eslint-disable @typescript-eslint/no-explicit-any */ + mockedHook.mockReturnValue({ mutate } as any as UseMutationResult< + WorkerState, + Error, + WorkerStateRequest + >); + + it("renders default Abort button", () => { + render(); + + expect(screen.getByText("Abort")); + }); + + it("when abort clicked the worker state changes and alert comes on screen", async () => { + const expectedRequest: WorkerStateRequest = { + new_state: "ABORTING", + reason: "Abort button pressed", + }; + const user = userEvent.setup(); + render(); + + const button = screen.getByText("Abort"); + await user.click(button); + expect(mutate).toHaveBeenCalledWith(expectedRequest); + expect( + screen.findByTestId("Abort button pressed, will abort current plan ..."), + ); + const alert = await screen.findByRole("alert"); + expect(alert).toBeVisible(); + }); +}); diff --git a/apps/i15-1/src/components/AbortPlanButton.tsx b/apps/i15-1/src/components/AbortPlanButton.tsx new file mode 100644 index 0000000..6e9446d --- /dev/null +++ b/apps/i15-1/src/components/AbortPlanButton.tsx @@ -0,0 +1,65 @@ +import { + Alert, + Button, + Snackbar, + Tooltip, + type SnackbarCloseReason, +} from "@mui/material"; + +import type { WorkerStateRequest } from "@atlas/blueapi"; +import { useSetWorkerState } from "@atlas/blueapi-query"; +import React, { useState } from "react"; + +export function AbortPlanButton() { + const workerState = useSetWorkerState(); + const [openSnackbar, setOpenSnackbar] = useState(false); + + const abortPlan = async () => { + const workerRequest: WorkerStateRequest = { + new_state: "ABORTING", + reason: "Abort button pressed", + }; + workerState.mutate(workerRequest); + }; + + const handleClick = async () => { + setOpenSnackbar(true); + await abortPlan(); + }; + + const handleSnackbarClose = ( + _event: React.SyntheticEvent | Event, + reason?: SnackbarCloseReason, + ) => { + if (reason === "clickaway") { + return; + } + + setOpenSnackbar(false); + }; + + return ( + + + + + + + Abort button pressed, will abort current plan ... + + + + ); +} diff --git a/apps/i15-1/src/components/BlueapiWorkerState.tsx b/apps/i15-1/src/components/BlueapiWorkerState.tsx new file mode 100644 index 0000000..9433ed4 --- /dev/null +++ b/apps/i15-1/src/components/BlueapiWorkerState.tsx @@ -0,0 +1,64 @@ +import { useGetWorkerState } from "@atlas/blueapi-query"; +import { + Card, + CardContent, + Stack, + Typography, + useTheme, + type Theme, +} from "@mui/material"; + +function getStateColorMap(theme: Theme) { + return { + IDLE: theme.palette.info.main, + RUNNING: theme.palette.success.main, + PAUSING: theme.palette.warning.main, + PAUSED: theme.palette.warning.main, + HALTING: theme.palette.warning.main, + STOPPING: theme.palette.error.main, + ABORTING: theme.palette.error.main, + SUSPENDING: theme.palette.error.main, + PANICKED: theme.palette.error.main, + UNKNOWN: theme.palette.background.paper, + }; +} + +export function BlueapiWorkerState() { + const theme = useTheme(); + const workerState = useGetWorkerState(); + const stateMap = getStateColorMap(theme); + + return ( + + + + + Blueapi worker state:{" "} + + + {workerState.data} + + + + + ); +} diff --git a/apps/i15-1/src/mocks/handlers.ts b/apps/i15-1/src/mocks/handlers.ts index 1a4b59f..c27182e 100644 --- a/apps/i15-1/src/mocks/handlers.ts +++ b/apps/i15-1/src/mocks/handlers.ts @@ -79,6 +79,10 @@ const fakeExperiments = { }, }; +function setWorkerState(new_state: string) { + workerStatus.status = new_state; +} + const fakePvws = ws.link("wss://pvws.diamond.ac.uk/pvws/pv"); const fakeHistory = [ @@ -522,7 +526,7 @@ export const handlers = [ : []), http.put("/api/blueapi/worker/task", () => { - workerStatus.status = "RUNNING"; + setWorkerState("RUNNING"); return HttpResponse.json({ task_id: fakeTaskId, }); @@ -534,8 +538,24 @@ export const handlers = [ }); }), - http.put("/api/blueapi/worker/state", () => { - return HttpResponse.json("IDLE"); + http.get("/api/blueapi/tasks/:task_id", () => { + return HttpResponse.json({ + task_id: fakeTaskId, + task: { name: "fake-task", params: {}, metadata: {} }, + request_id: "00", + is_complete: true, + is_pending: false, + errors: [], + outcome: { outcome: "success", type: "str", result: null }, + }); + }), + + http.put("/api/blueapi/worker/state", async ({ request }) => { + const { new_state } = (await request.json()) as { new_state: string }; + if (new_state === "ABORTING") { + setWorkerState(new_state); + } + return HttpResponse.json(workerStatus.status); }), http.get("/oauth2/userinfo", () => { diff --git a/apps/i15-1/src/routes/Robot.tsx b/apps/i15-1/src/routes/Robot.tsx index 32f5562..19f862f 100644 --- a/apps/i15-1/src/routes/Robot.tsx +++ b/apps/i15-1/src/routes/Robot.tsx @@ -5,7 +5,9 @@ import { NumberInput } from "../components/NumberInput"; import { RunPlanButton } from "@atlas/blueapi-ui"; import { ReadOnlyPv } from "@atlas/pvws-config"; import { StatusCard } from "../components/StatusCard"; +import { AbortPlanButton } from "../components/AbortPlanButton"; import { WebcamStreamFromPv } from "../components/Webcam"; +import { BlueapiWorkerState } from "../components/BlueapiWorkerState"; type RobotSampleFormData = { puck: number; @@ -53,6 +55,7 @@ function StatusSidebar() { pv="ca://BL15J-EA-LOC-01:SAMPLE:INDEX" /> + ); @@ -96,17 +99,20 @@ function RobotControl() { }} /> - - + + + + + ); diff --git a/packages/blueapi-ui/src/RunPlanButton.test.tsx b/packages/blueapi-ui/src/RunPlanButton.test.tsx index 73285d6..00bb843 100644 --- a/packages/blueapi-ui/src/RunPlanButton.test.tsx +++ b/packages/blueapi-ui/src/RunPlanButton.test.tsx @@ -1,19 +1,53 @@ import { render, screen } from "@atlas/vitest-conf"; import { RunPlanButton } from "./RunPlanButton"; -import { useGetWorkerState, useSubmitTask } from "@atlas/blueapi-query"; -import type { Api, TaskResponse } from "@atlas/blueapi"; +import type { Api, TaskResponse, TrackableTask } from "@atlas/blueapi"; -vi.mock("@atlas/blueapi-query"); -const workerStateMock = vi.mocked(useGetWorkerState); -const submitTaskMock = vi.mocked(useSubmitTask); +// Mocks with starting return values const mockResponse: TaskResponse = { task_id: "92e6a0c3-52ff-4161-84ec-73096697e571", }; +const workerStateMock = vi.fn(() => ({ data: "IDLE" })); +const submitTaskMock = { + mutateAsync: vi.fn(() => Promise.resolve(mockResponse)), +}; +const setActiveTaskMock = { + mutateAsync: vi.fn(() => Promise.resolve(mockResponse)), +}; + +const mockTask: TrackableTask = { + task_id: "92e6a0c3-52ff-4161-84ec-73096697e571", + task: { name: "test_plan", params: {}, metadata: {} }, + request_id: null, + is_complete: true, + is_pending: false, + errors: [], + outcome: { outcome: "success" }, +}; + +const api = { + worker: { get: vi.fn(() => Promise.resolve("IDLE")) }, + tasks: { get: vi.fn(() => Promise.resolve(mockTask)) }, +} as unknown as Api; + +vi.mock("@atlas/blueapi-query", () => ({ + useGetWorkerState: () => workerStateMock(), + useSubmitTask: () => submitTaskMock, + useSetActiveTask: () => setActiveTaskMock, + useBlueapi: () => api, +})); describe("RunPlanButton", () => { + beforeEach(() => { + vi.clearAllMocks(); + workerStateMock.mockReset(); + workerStateMock.mockReturnValue({ data: "IDLE" }); + submitTaskMock.mutateAsync.mockClear(); + setActiveTaskMock.mutateAsync.mockClear(); + }); + it("renders default button with Run", () => { workerStateMock.mockReturnValue({ data: "IDLE" } as any); - submitTaskMock.mockReturnValue({ data: mockResponse } as any); + submitTaskMock.mutateAsync.mockReturnValue({ data: mockResponse } as any); render( { it("renders button with custom text", () => { workerStateMock.mockReturnValue({ data: "IDLE" } as any); - submitTaskMock.mockReturnValue({ data: mockResponse } as any); + submitTaskMock.mutateAsync.mockReturnValue({ data: mockResponse } as any); render( { it("success message appears when button is pressed with successful response", () => { workerStateMock.mockReturnValue({ data: "IDLE" } as any); - submitTaskMock.mockReturnValue({ data: mockResponse } as any); + submitTaskMock.mutateAsync.mockReturnValue({ data: mockResponse } as any); + setActiveTaskMock.mutateAsync.mockReturnValue({ + data: mockTask, + } as any); + (render( + , + ), + screen.getByText("Run").click()); + expect(screen.findByTestId("Plan submission successful!")); + expect(screen.findByTestId("Plan succeeded")); + }); + + it("failure message appears when button is pressed with failed response", async () => { + submitTaskMock.mutateAsync.mockReturnValue({ data: null } as any); + setActiveTaskMock.mutateAsync.mockRejectedValue; render( { />, ); screen.getByText("Run").click(); - expect(screen.findByText("Plan submission successful!")); + expect(screen.findByTestId("Plan submission failed!")); + expect( + screen.findByTestId( + "Failed to run plan test_plan, see console and blueapi logs for full error.", + ), + ); + const alert = await screen.findByRole("alert"); + expect(alert).toBeVisible(); }); - it("failure message appears when button is pressed with failed response", () => { - submitTaskMock.mockRejectedValue; + it("Plan submission succeeds but plan has errors during execution", async () => { + const mockFailedTask: TrackableTask = { + task_id: "92e6a0c3-52ff-4161-84ec-73096697e571", + task: { name: "test_plan", params: {}, metadata: {} }, + request_id: null, + is_complete: true, + is_pending: false, + errors: ["Some error"], + outcome: { outcome: "error" }, + }; + + submitTaskMock.mutateAsync.mockReturnValue({ data: mockResponse } as any); + setActiveTaskMock.mutateAsync.mockReturnValue({ + data: mockFailedTask, + } as any); render( { />, ); screen.getByText("Run").click(); - expect(screen.findByTestId("Plan submission failed!")); + expect(screen.findByTestId("Plan submission successful!")); + expect( + screen.findByTestId( + "Failed to run plan test_plan, see console and blueapi logs for full error.", + ), + ); + const alert = await screen.findByRole("alert"); + expect(alert).toBeVisible(); }); }); diff --git a/packages/blueapi-ui/src/RunPlanButton.tsx b/packages/blueapi-ui/src/RunPlanButton.tsx index 0bfeecf..e812a63 100644 --- a/packages/blueapi-ui/src/RunPlanButton.tsx +++ b/packages/blueapi-ui/src/RunPlanButton.tsx @@ -1,13 +1,18 @@ -import { Button } from "@mui/material"; -import Snackbar, { type SnackbarCloseReason } from "@mui/material/Snackbar"; -import { useState } from "react"; +import { + Alert, + Button, + Snackbar, + type SnackbarCloseReason, +} from "@mui/material"; +import React, { useState } from "react"; import { + useBlueapi, useGetWorkerState, useSetActiveTask, useSubmitTask, } from "@atlas/blueapi-query"; -import type { TaskRequest } from "@atlas/blueapi"; +import type { TaskRequest, TaskResponse } from "@atlas/blueapi"; export type RunPlanButtonProps = { name: string; @@ -16,69 +21,109 @@ export type RunPlanButtonProps = { buttonText?: string; }; +type SeverityLevel = "success" | "info" | "warning" | "error"; + +const idleState = "IDLE"; +const abortState = "ABORTING"; + export function RunPlanButton({ name, params, instrumentSession, buttonText = "Run", }: RunPlanButtonProps) { + const [openSnackbar, setOpenSnackbar] = useState(false); + const [msg, setMsg] = useState(`Running ${name} plan`); + const [severity, setSeverity] = useState("info"); + + const [loading, setLoading] = useState(false); + + const blueapi = useBlueapi(); + const submitTask = useSubmitTask(); const startTask = useSetActiveTask(); - const submitAndRunTask = async (task: TaskRequest) => { - await submitTask - .mutateAsync(task) - .then((response) => startTask.mutateAsync(response.task_id)); + + const waitForIdle = async (timeoutInMs: number): Promise => { + return new Promise((res) => setTimeout(res, timeoutInMs)); }; - const [loading, setLoading] = useState(false); - const [planSubmitted, setPlanSubmitted] = useState(false); - const [planSubmissionResult, setPlanSubmissionResult] = useState< - boolean | null - >(null); - const handleCloseSnackbar = ( - event: React.SyntheticEvent | Event, - reason?: SnackbarCloseReason, - ) => { - if (reason === "clickaway") { - return; - } - setPlanSubmitted(false); - setPlanSubmissionResult(null); + const runTask = async (task_id: string) => { + await startTask.mutateAsync(task_id).then(async (response) => { + if (response) { + let status = await blueapi.worker.getState(); + while (status !== idleState && status !== abortState) { + await waitForIdle(100); + status = await blueapi.worker.getState(); + } + const data = await blueapi.tasks.get(task_id); + if (data.is_complete) { + if (data.outcome?.outcome === "success") { + setSeverity("success"); + setMsg("Plan succeeded"); + } else if (data.outcome?.outcome === "error") { + throw new Error(`${data.errors[0]}`); + } + } + } + }); + }; + + const submitAndRunTask = async ( + task: TaskRequest, + ): Promise => { + await submitTask.mutateAsync(task).then(async (response) => { + if (response) { + setSeverity("info"); + setMsg("Plan submission successful!"); + await runTask(response.task_id).catch((error) => { + throw new Error(error); + }); + } else { + setSeverity("error"); + setMsg("Plan submission failed!"); + throw new Error("Task couldn't be submitted"); + } + }); }; const handleClick = async () => { + setOpenSnackbar(true); + setLoading(true); const taskRequest: TaskRequest = { name: name, params: params, instrument_session: instrumentSession, }; - setLoading(true); - try { - await submitAndRunTask(taskRequest); - setPlanSubmissionResult(true); - setPlanSubmitted(true); - } catch (error) { - setPlanSubmissionResult(false); - setPlanSubmitted(true); - } finally { - setLoading(false); - } + await submitAndRunTask(taskRequest).catch((error) => { + setSeverity("error"); + setMsg( + `Failed to run plan ${name}, see console and blueapi logs for full error.`, + ); + console.log(`${msg}.\n Reason: ${error}`); + }); + setLoading(false); }; - const snackbarMessage = planSubmissionResult - ? "Plan submission successful!" - : planSubmissionResult === false - ? "Plan submission failed!" - : "Plan submission state unknown!"; + const handleSnackbarClose = ( + _event: React.SyntheticEvent | Event, + reason?: SnackbarCloseReason, + ) => { + if (reason === "clickaway") { + return; + } + + setOpenSnackbar(false); + }; const isButtonDisabled = () => { const workerState = useGetWorkerState(); - const disable = workerState.data !== "IDLE"; + const disable = workerState.data !== idleState; + return disable; }; return ( -
+
+ open={openSnackbar} + autoHideDuration={10000} + onClose={handleSnackbarClose} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + > + + {msg} + + + ); }