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}
+
+
+
);
}