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..1a12bdb --- /dev/null +++ b/apps/i15-1/src/components/BlueapiWorkerState.tsx @@ -0,0 +1,48 @@ +import { useGetWorkerState } from "@atlas/blueapi-query"; +import { Card, CardContent, Stack, Typography, useTheme } from "@mui/material"; + +function getStateColorMap() { + const theme = useTheme(); + 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(); + + return ( + + + + Blueapi worker state: + + {workerState.data} + + + + + ); +} diff --git a/apps/i15-1/src/components/RunPlanButton.tsx b/apps/i15-1/src/components/RunPlanButton.tsx index b28f8f6..0e363d4 100644 --- a/apps/i15-1/src/components/RunPlanButton.tsx +++ b/apps/i15-1/src/components/RunPlanButton.tsx @@ -1,12 +1,20 @@ -import { Button } from "@mui/material"; -import { useState } from "react"; +import { + Alert, + Button, + Snackbar, + Tooltip, + type SnackbarCloseReason, +} from "@mui/material"; +import React, { useState } from "react"; import { + useBlueapi, useGetWorkerState, useSetActiveTask, useSubmitTask, + useTask, } from "@atlas/blueapi-query"; -import type { TaskRequest } from "@atlas/blueapi"; +import type { TaskRequest, TaskResponse } from "@atlas/blueapi"; import { useUserAuth } from "../context/userAuth/useUserAuth"; type RunPlanButtonProps = { @@ -16,36 +24,97 @@ type RunPlanButtonProps = { buttonText?: string; }; +type SeverityLevel = "success" | "info" | "warning" | "error"; + const 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 user = useUserAuth(); + const workerState = useGetWorkerState(); + 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 (ms: number): Promise => { + return new Promise((res) => setTimeout(res, ms)); + }; + + const runTask = async (task_id: string) => { + await startTask.mutateAsync(task_id).then(async (response) => { + if (response) { + let status = workerState.data; + while (status !== "IDLE" && status !== "ABORTING") { + await waitForIdle(10); + status = workerState.data; + } + 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) { + await runTask(response.task_id).catch((error) => { + throw new Error(error); + }); + } else { + throw new Error("Task couldn't be submitted"); + } + }); }; - const [loading, setLoading] = useState(false); const handleClick = async () => { + setOpenSnackbar(true); + setLoading(true); const taskRequest: TaskRequest = { name: name, params: params, instrument_session: instrumentSession, }; - setLoading(true); - await submitAndRunTask(taskRequest); + 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 handleSnackbarClose = ( + _event: React.SyntheticEvent | Event, + reason?: SnackbarCloseReason, + ) => { + if (reason === "clickaway") { + return; + } + + setOpenSnackbar(false); + }; + const isButtonDisabled = () => { - const workerState = useGetWorkerState(); const disable = user.person == null || user.person == undefined || @@ -54,15 +123,27 @@ const RunPlanButton = ({ }; return ( - + + + + + {msg} + + + ); }; diff --git a/apps/i15-1/src/mocks/handlers.ts b/apps/i15-1/src/mocks/handlers.ts index 695f4b4..e2c24dd 100644 --- a/apps/i15-1/src/mocks/handlers.ts +++ b/apps/i15-1/src/mocks/handlers.ts @@ -3,11 +3,15 @@ import { http, HttpResponse, ws } from "msw"; const fakeTaskId = "7304e8e0-81c6-4978-9a9d-9046ab79ce3c"; let workerStatus = { status: "IDLE", duration: 0 }; +function setWorkerState(new_state: string) { + workerStatus.status = new_state; +} + const fakePvws = ws.link("wss://pvws.diamond.ac.uk/pvws/pv"); export const handlers = [ http.put("/api/worker/task", () => { - workerStatus.status = "RUNNING"; + setWorkerState("RUNNING"); return HttpResponse.json({ task_id: fakeTaskId, }); @@ -19,8 +23,25 @@ export const handlers = [ }); }), - http.put("/api/worker/state", () => { - return HttpResponse.json("IDLE"); + http.get("/api/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/worker/state", async ({ request }) => { + // @ts-ignore + const { new_state } = await request.json(); + 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 ee0a2e4..f614d97 100644 --- a/apps/i15-1/src/routes/Robot.tsx +++ b/apps/i15-1/src/routes/Robot.tsx @@ -1,10 +1,12 @@ import { useInstrumentSession } from "../context/instrumentSession/useInstrumentSession"; -import { Box, Typography, Stack, useTheme, TextField } from "@mui/material"; +import { Box, Typography, Stack, useTheme } from "@mui/material"; import { useState } from "react"; import { NumberInput } from "../components/NumberInput"; import RunPlanButton from "../components/RunPlanButton"; import { ReadOnlyPv } from "@atlas/pvws-config"; import { StatusCard } from "../components/StatusCard"; +import { BlueapiWorkerState } from "../components/BlueapiWorkerState"; +import { AbortPlanButton } from "../components/AbortPlanButton"; type RobotSampleFormData = { puck: number; @@ -16,6 +18,7 @@ function StatusSidebar() { return ( + - + Sample Position @@ -98,17 +100,20 @@ function Robot() { }} /> - - + + + + + diff --git a/packages/blueapi-query/src/index.ts b/packages/blueapi-query/src/index.ts index 3cd43be..9cbdb5b 100644 --- a/packages/blueapi-query/src/index.ts +++ b/packages/blueapi-query/src/index.ts @@ -3,4 +3,4 @@ export * from "./tasks"; export * from "./worker"; export * from "./devices"; -export { BlueapiProvider } from "./provider"; +export { BlueapiProvider, useBlueapi } from "./provider"; diff --git a/packages/blueapi-query/src/tasks.test.ts b/packages/blueapi-query/src/tasks.test.ts index a4cea6c..228a740 100644 --- a/packages/blueapi-query/src/tasks.test.ts +++ b/packages/blueapi-query/src/tasks.test.ts @@ -55,6 +55,7 @@ describe("Task hooks", () => { is_pending: true, is_complete: false, errors: [], + outcome: { outcome: "success" }, }; (api.tasks.get as any).mockResolvedValue(task); diff --git a/packages/blueapi/src/tasks.ts b/packages/blueapi/src/tasks.ts index 20e91ca..59bdd36 100644 --- a/packages/blueapi/src/tasks.ts +++ b/packages/blueapi/src/tasks.ts @@ -12,6 +12,20 @@ export interface Task { metadata: object; } +type TaskResult = { + outcome: "success"; + result?: any; + type?: string; +}; + +type TaskError = { + outcome: "error"; + type?: string; + message?: string; +}; + +export type TaskOutcome = TaskResult | TaskError | null; + /** A representation of a task that the worker recognizes */ export interface TrackableTask { task_id: string; @@ -20,6 +34,7 @@ export interface TrackableTask { is_complete: boolean; is_pending: boolean; errors: string[]; + outcome: TaskOutcome; } /** Diagnostic information on the tasks */