diff --git a/.env b/.env index 58d81c9..58ccc2c 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ VITE_BLUEAPI_SOCKET="https://i24-blueapi.diamond.ac.uk:8000" VITE_BLUEAPI_SOCKET_DEV="http://localhost:25565" -VITE_CONFIG_SOCKET="https://daq-config.diamond.ac.uk" \ No newline at end of file +VITE_CONFIG_SOCKET="https://daq-config.diamond.ac.uk" +VITE_OAV_STREAM_SOCKET_DEV="http://bl24i-di-serv-01.diamond.ac.uk:8080" diff --git a/Dockerfile b/Dockerfile index f1ce61f..ffee616 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ FROM base AS deps # ARG FOO=BAR # ENV REACT_APP_FOO=${FOO} -COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +COPY package.json yarn.lock* pnpm-workspace.yaml package-lock.json* pnpm-lock.yaml* .npmrc* ./ # Uncomment the next line if you're not using Classic Yarn #COPY ./.yarn ./.yarn @@ -51,5 +51,15 @@ COPY --from=builder /app/dist /usr/share/nginx/html # Copy your custom nginx config COPY nginx.conf /etc/nginx/nginx.conf +# The OAV stream proxy, whose upstream the entrypoint fills in from the environment at +# start up. Set OAV_STREAM_SERVER on the container to proxy somewhere other than i24's +# MJPG server; the filter keeps envsubst off nginx's own $variables. +# The proxy resolves that server per request, from the resolvers the entrypoint reads +# out of /etc/resolv.conf, so that a server which is down does not stop nginx starting. +COPY oav-stream.conf.template /etc/nginx/templates/oav-stream.conf.template +ENV OAV_STREAM_SERVER=http://bl24i-di-serv-01.diamond.ac.uk:8080 +ENV NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 +ENV NGINX_ENVSUBST_FILTER=^(OAV_STREAM_SERVER|NGINX_LOCAL_RESOLVERS)$ + EXPOSE 8080 CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/README.md b/README.md index 81f8a8e..52d2bd9 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,45 @@ pnpm install To connect to the Diamond PVWS instance at `pvws.diamond.ac.uk`, we take advantage of the [cs-web-lib]https://github.com/DiamondLightSource/cs-web-lib) package - the current stable version being `0.9.10`. PVWS is now configured by setting up the parameters in a JSON config file which is loaded at runtime. The `pvwsconfig.json` is located in the `/public` directory to make it always accessible at runtime. +### OAV video stream + +The OAV IOC advertises its MJPG stream over http, which a browser will not load into a +page served over https - it blocks it as mixed content, so the stream comes up blank +wherever the app is deployed behind TLS. The app therefore asks for the stream at +`/oav-stream` on its own origin, and that is proxied on to the MJPG server: by nginx in +the built image, and by the dev server otherwise. Which stream is shown still comes from +the IOC's `MJPG_URL_RBV`; only which server it is fetched from is set here, and in both +cases the environment sets it: + +| | Proxy | Which server | Default | +| ----------- | -------------------------------------------------------- | ---------------------------- | ----------------------- | +| Built image | `oav-stream.conf.template`, filled in at container start | `OAV_STREAM_SERVER` | set in the `Dockerfile` | +| `pnpm dev` | `vite.config.ts` | `VITE_OAV_STREAM_SOCKET_DEV` | set in `.env` | + +Set `OAV_STREAM_SERVER` on the container to proxy a different MJPG server; leaving it +empty leaves nginx refusing to start rather than quietly serving nothing. + +The MJPG server is looked up per request, so the app starts, and serves everything else, +whether or not that server is up: while it is down the stream is a broken image and a +502 in the nginx log, and it starts working again when the server does, with no restart. +The cost is that the lookup is DNS only - `OAV_STREAM_SERVER` must be a fully qualified +name, since nginx's resolver reads neither `/etc/hosts` nor the search domains. + ### Environment variables -To connect to the BlueAPI instance for I24, the environment variables `VITE_BLUEAPI_SOCKET` must be set to the URL in the file `.env`. The URL is currently set to localhost - but should change to the ingress once the UI is deployed to the beamline cluster. +`.env` holds the URLs the app talks to: + +| Variable | Read by | What it does | +| ---------------------------- | ----------------------------------- | ----------------------------------------- | +| `VITE_CONFIG_SOCKET` | `src/config_server/configServer.ts` | the daq-config server | +| `VITE_BLUEAPI_SOCKET_DEV` | `vite.config.ts` | where `pnpm dev` proxies `/api` to | +| `VITE_BLUEAPI_SOCKET` | nothing, currently | see below | +| `VITE_OAV_STREAM_SOCKET_DEV` | `vite.config.ts` | where `pnpm dev` proxies `/oav-stream` to | + +blueapi is addressed as the origin-relative `/api` (`src/blueapi/blueapi.ts`), so in a +deployment whatever sits in front of the app routes that on, and `VITE_BLUEAPI_SOCKET` is +not read - the line that read it is still there, commented out. The dev server has nothing +in front of it, so it proxies `/api` itself, to `VITE_BLUEAPI_SOCKET_DEV`. ### BlueAPI config diff --git a/nginx.conf b/nginx.conf index 9638784..c47c4f5 100644 --- a/nginx.conf +++ b/nginx.conf @@ -20,6 +20,13 @@ http { index index.html index.htm; try_files $uri $uri/ /index.html; } + # The OAV's MJPG server speaks http only, so a browser on an https page refuses + # to load the stream from it directly - it is mixed content. Serve it from this + # origin instead and proxy it on. The IOC's MJPG_URL_RBV still chooses the stream; + # this only says which server it comes from, and it is templated (see + # oav-stream.conf.template) so that a deployment can say a different one. + include /etc/nginx/conf.d/oav-stream.conf; + error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; diff --git a/oav-stream.conf.template b/oav-stream.conf.template new file mode 100644 index 0000000..ba6530d --- /dev/null +++ b/oav-stream.conf.template @@ -0,0 +1,30 @@ +# The /oav-stream location, templated so the MJPG server it proxies to is a deployment +# setting rather than a compiled-in one. The nginx entrypoint runs envsubst over +# /etc/nginx/templates/*.template at container start, writing the result into +# /etc/nginx/conf.d/, which nginx.conf includes; OAV_STREAM_SERVER has a default in the +# Dockerfile, so it only has to be set to point somewhere other than i24's server. +# +# NGINX_ENVSUBST_FILTER (also set in the Dockerfile) keeps envsubst to the two names +# below, so that nginx's own $variables survive being templated. +# +# Setting OAV_STREAM_SERVER to nothing, or unsetting it, leaves nginx refusing to start +# rather than quietly serving a stream from nowhere. +location /oav-stream/ { + # Look the MJPG server up per request, so that this app still starts when the server + # is down or its name does not resolve: naming it in a variable is what defers the + # lookup, where a literal proxy_pass is resolved as the configuration is read and + # refuses to start if that fails. The resolvers are the container's own, from + # /etc/resolv.conf - but nginx does not use the search domains from there, so + # OAV_STREAM_SERVER has to be a fully qualified name. + resolver ${NGINX_LOCAL_RESOLVERS} valid=30s ipv6=off; + set $oav_stream_server "${OAV_STREAM_SERVER}"; + # A proxy_pass with a variable in it passes the request URI on untouched, so the + # prefix this location is mounted at has to be taken off by hand. + rewrite ^/oav-stream/(.*)$ /$1 break; + proxy_pass $oav_stream_server; + proxy_http_version 1.1; + # An MJPG stream is one multipart response that never ends, so there is nothing to + # buffer up and no request that completes inside a read timeout. + proxy_buffering off; + proxy_read_timeout 24h; +} diff --git a/package.json b/package.json index e524532..14073c8 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "version": "0.0.11", "type": "module", + "packageManager": "pnpm@11.13.1", "scripts": { "dev": "vite", "build": "tsc -b && vite build", diff --git a/src/blueapi/BlueapiComponents.tsx b/src/blueapi/BlueapiComponents.tsx index c11fae6..e3ef084 100644 --- a/src/blueapi/BlueapiComponents.tsx +++ b/src/blueapi/BlueapiComponents.tsx @@ -30,6 +30,7 @@ type RunPlanButtonProps = { sx?: object; tooltipSx?: object; typographySx?: object; + onSuccess?: () => void | Promise; // Optional callback after plan succeeds }; export function RunPlanButton(props: RunPlanButtonProps) { @@ -63,13 +64,17 @@ export function RunPlanButton(props: RunPlanButtonProps) { planName: props.planName, planParams: params, instrumentSession: instrumentSession, - }).catch((error) => { - setSeverity("error"); - setMsg( - `Failed to run plan ${props.planName}, see console and logs for full error`, - ); - console.log(`${msg}. Reason: ${error}`); - }); + }) + .then(() => { + props.onSuccess?.(); + }) + .catch((error) => { + setSeverity("error"); + setMsg( + `Failed to run plan ${props.planName}, see console and logs for full error`, + ); + console.log(`${msg}. Reason: ${error}`); + }); } catch (error) { setSeverity("error"); setMsg( diff --git a/src/components/JungFrau/CollectDarksPanel.tsx b/src/components/JungFrau/CollectDarksPanel.tsx index 8031161..28b4209 100644 --- a/src/components/JungFrau/CollectDarksPanel.tsx +++ b/src/components/JungFrau/CollectDarksPanel.tsx @@ -37,7 +37,7 @@ export function CollectDarksPanel() { { const value = props.value ? props.value : "undefined"; - if ( - !streamUrl.startsWith("http") && - value.toString().startsWith("http") - ) { - setStreamUrl(value.toString()); + if (value.toString().startsWith("http")) { + // Not the URL the IOC gives, which is an http one this page may not be allowed + // to load; the one on this origin that proxies to it. + const url = oavStreamUrl(value.toString()); + if (url !== streamUrl) { + setStreamUrl(url); + } } return ( @@ -116,8 +118,6 @@ function VideoBoxWithOverlay(props: { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); }, [props.crosshairX, props.crosshairY, width, height]); - console.info(); - return ( { + test("serves the stream the IOC names from this origin", () => { + // An https page cannot load the http URL the IOC advertises, so the same stream is + // asked for from here instead, and proxied on to the MJPG server. + expect( + oavStreamUrl("http://bl24i-di-serv-01.diamond.ac.uk:8080/OAV1.mjpg.mjpg"), + ).toBe("/oav-stream/OAV1.mjpg.mjpg"); + }); + + test("keeps whatever the URL asks of the stream", () => { + expect( + oavStreamUrl( + "http://bl24i-di-serv-01.diamond.ac.uk:8080/OAV1.mjpg?fps=5", + ), + ).toBe("/oav-stream/OAV1.mjpg?fps=5"); + }); +}); diff --git a/src/components/OavVideoStreamHelper.ts b/src/components/OavVideoStreamHelper.ts index 284d5dc..45aaea6 100644 --- a/src/components/OavVideoStreamHelper.ts +++ b/src/components/OavVideoStreamHelper.ts @@ -23,3 +23,21 @@ export const useContainerDimensions = ( return dimensions; }; + +/** Where this app serves the OAV stream from, proxied to the MJPG server itself. */ +export const OAV_STREAM_PATH = "/oav-stream"; + +/** + * Where the browser should fetch the OAV stream from, given the URL the IOC advertises. + * + * The MJPG server speaks http only, and a page served over https will not load an image + * over http - the browser blocks it as mixed content, which is what happens whenever + * this app is deployed to the cluster rather than run locally. So the stream is fetched + * from this app's own origin, which proxies it on to the server (see the location block + * in nginx.conf, and the dev server's proxy in vite.config.ts). The IOC still says which + * stream to show; only where the browser collects it from moves. + */ +export const oavStreamUrl = (iocUrl: string): string => { + const { pathname, search } = new URL(iocUrl); + return `${OAV_STREAM_PATH}${pathname}${search}`; +}; diff --git a/src/config_server/configServer.ts b/src/config_server/configServer.ts index 0fbf23d..ea2f961 100644 --- a/src/config_server/configServer.ts +++ b/src/config_server/configServer.ts @@ -22,6 +22,6 @@ export function useConfigCall( return await response.text(); }; return useQuery(queryKey ?? "ConfigCall", fetchCall, { - refetchInterval: pollRateMillis ?? 500, + refetchInterval: pollRateMillis ?? false, }); } diff --git a/src/context/beamcenter/BeamCenterContext.ts b/src/context/beamcenter/BeamCenterContext.ts new file mode 100644 index 0000000..6552751 --- /dev/null +++ b/src/context/beamcenter/BeamCenterContext.ts @@ -0,0 +1,11 @@ +import { createContext } from "react"; + +type BeamCenterQueryResult = { + data: string | null | undefined; + refetch: () => void; +}; + +export const BeamCenterContext = createContext({ + data: null, + refetch: () => {}, +}); diff --git a/src/context/beamcenter/BeamCenterProvider.test.tsx b/src/context/beamcenter/BeamCenterProvider.test.tsx new file mode 100644 index 0000000..4cca674 --- /dev/null +++ b/src/context/beamcenter/BeamCenterProvider.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { useContext } from "react"; +import "@testing-library/jest-dom/vitest"; +import { BeamCenterProvider } from "./BeamCenterProvider"; +import { BeamCenterContext } from "./BeamCenterContext"; +import { useConfigCall } from "#/config_server/configServer.ts"; +import type { UseQueryResult } from "react-query"; + +vi.mock("#/config_server/configServer.ts", () => ({ + useConfigCall: vi.fn(), +})); + +const TestConsumer = () => { + const value = useContext(BeamCenterContext); + return ( + <> +
{value.data}
+ + + ); +}; + +describe("BeamCenterProvider", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockRefetch = vi.fn(); + const mockQueryResult = { + data: "mock config text", + refetch: mockRefetch, + }; + + beforeEach(() => + vi + .mocked(useConfigCall) + .mockReturnValue( + mockQueryResult as unknown as UseQueryResult, + ), + ); + + it("calls useConfigCall with the correct endpoint", () => { + render( + + + , + ); + + expect(useConfigCall).toHaveBeenCalledWith( + "/dls_sw/i24/software/daq_configuration/domain/display.configuration", + ); + }); + + it("provides the data to consumers via context", () => { + render( + + + , + ); + + expect(screen.getByTestId("context-value")).toHaveTextContent( + "mock config text", + ); + }); + + it("passes refetch function through context and it can be called", () => { + render( + + + , + ); + + fireEvent.click(screen.getByTestId("refetch-button")); + expect(mockRefetch).toHaveBeenCalled(); + }); +}); diff --git a/src/context/beamcenter/BeamCenterProvider.tsx b/src/context/beamcenter/BeamCenterProvider.tsx new file mode 100644 index 0000000..a9db5f2 --- /dev/null +++ b/src/context/beamcenter/BeamCenterProvider.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from "react"; +import { useConfigCall } from "#/config_server/configServer.ts"; +import { BeamCenterContext } from "./BeamCenterContext"; + +const DISPLAY_CONFIG_ENDPOINT = + "/dls_sw/i24/software/daq_configuration/domain/display.configuration"; + +export const BeamCenterProvider = ({ children }: { children: ReactNode }) => { + const beamCenterQuery = useConfigCall(DISPLAY_CONFIG_ENDPOINT); + + return ( + + {children} + + ); +}; diff --git a/src/routes/BeamlineI24.tsx b/src/routes/BeamlineI24.tsx index 40bea1b..c684cbb 100644 --- a/src/routes/BeamlineI24.tsx +++ b/src/routes/BeamlineI24.tsx @@ -2,9 +2,10 @@ import { BeamlineStatsTabPanel } from "#/screens/BeamlineStats.tsx"; import { DetectorMotionTabPanel } from "#/screens/DetectorMotion.tsx"; import { FallbackScreen } from "#/screens/FallbackScreen.tsx"; import { OavMover } from "#/screens/OavMover/OAVStageController.tsx"; +import { BeamCenterProvider } from "#/context/beamcenter/BeamCenterProvider.tsx"; import { Box, Tab, Tabs, useTheme } from "@mui/material"; -import React from "react"; import { ErrorBoundary } from "react-error-boundary"; +import { useState } from "react"; interface TabPanelProps { children?: React.ReactNode; @@ -37,7 +38,7 @@ function CustomTabPanel(props: TabPanelProps) { export function BeamlineI24() { const theme = useTheme(); - const [tab, setTab] = React.useState(0); + const [tab, setTab] = useState(0); const handleChange = (_event: React.SyntheticEvent, newTab: number) => { setTab(newTab); @@ -73,7 +74,9 @@ export function BeamlineI24() { - + + +
diff --git a/src/screens/OavMover/OAVMoveController.tsx b/src/screens/OavMover/OAVMoveController.tsx index 9c139a8..4429f17 100644 --- a/src/screens/OavMover/OAVMoveController.tsx +++ b/src/screens/OavMover/OAVMoveController.tsx @@ -1,4 +1,6 @@ import { RunPlanButton } from "#/blueapi/BlueapiComponents.tsx"; +import { useContext } from "react"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; import { KeyboardDoubleArrowUp, KeyboardArrowUp, @@ -37,7 +39,7 @@ const arrowsScreenSizing = { }, }; -function BlockMove(props: TabPanelProps) { +function BlockMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; return ( @@ -48,6 +50,7 @@ function BlockMove(props: TabPanelProps) { planName={"move_block_on_arrow_click"} planParams={{ direction: "up" }} btnVariant="outlined" + onSuccess={props.onMoveSuccess} /> ); } -function NudgeMove(props: TabPanelProps) { +function NudgeMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; return ( @@ -87,6 +93,7 @@ function NudgeMove(props: TabPanelProps) { planParams={{ direction: "up", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -110,6 +119,7 @@ function NudgeMove(props: TabPanelProps) { planParams={{ direction: "left", size_of_move: "small" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -125,6 +136,7 @@ function NudgeMove(props: TabPanelProps) { planParams={{ direction: "right", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> ); } -function WindowMove(props: TabPanelProps) { +function WindowMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; return ( @@ -158,6 +172,7 @@ function WindowMove(props: TabPanelProps) { planParams={{ direction: "up", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -181,6 +198,7 @@ function WindowMove(props: TabPanelProps) { planParams={{ direction: "left", size_of_move: "small" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -196,6 +215,7 @@ function WindowMove(props: TabPanelProps) { planParams={{ direction: "right", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> ); } -function FocusMove(props: TabPanelProps) { +function FocusMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; const focus_move = [ { direction: "in", size_of_move: "big", label: "IN x3" }, @@ -252,7 +274,7 @@ function FocusMove(props: TabPanelProps) { export function MoveArrows() { const theme = useTheme(); - + const beamCenterQuery = useContext(BeamCenterContext); const [value, setValue] = useState(0); const isSmall = useMediaQuery(theme.breakpoints.down("xl")); @@ -292,10 +314,28 @@ export function MoveArrows() { - - - - + { + beamCenterQuery?.refetch(); + }} + /> + beamCenterQuery?.refetch()} + /> + beamCenterQuery?.refetch()} + /> + beamCenterQuery?.refetch()} + /> ); } diff --git a/src/screens/OavMover/OAVStageController.test.tsx b/src/screens/OavMover/OAVStageController.test.tsx new file mode 100644 index 0000000..e4acd21 --- /dev/null +++ b/src/screens/OavMover/OAVStageController.test.tsx @@ -0,0 +1,119 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, vi, beforeEach, expect } from "vitest"; +import { useZoomAndCrosshair } from "./OAVStageController"; +import { useParsedPvConnection } from "#/pv/util.ts"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; +import type { RawValue } from "#/pv/types.ts"; + +vi.mock("#/pv/util.ts", () => ({ + ...vi.importActual("#/pv/util.ts"), + useParsedPvConnection: vi.fn(), + forceString: (x: RawValue | string | number) => String(x), +})); + +type validateZoomTestType = { + zoomLevel: string; + expectedX: number; + expectedY: number; +}; + +describe("useZoomAndCrosshair", () => { + const mockRefetch = vi.fn(); + const mockBeamCenterData = [ + "zoomLevel = 1.0", + "crosshairX = 561", + "crosshairY = 321", + "topLeftX = 611", + "topLeftY = 441", + "bottomRightX = 631", + "bottomRightY = 461", + "zoomLevel = 2.0", + "crosshairX = 562", + "crosshairY = 322", + "topLeftX = 612", + "topLeftY = 442", + "bottomRightX = 632", + "bottomRightY = 462", + "zoomLevel = 3.0", + "crosshairX = 563", + "crosshairY = 323", + "topLeftX = 613", + "topLeftY = 443", + "bottomRightX = 633", + "bottomRightY = 463", + ].join("\n"); + + beforeEach(() => { + vi.mocked(useParsedPvConnection).mockReturnValue("2.0"); + }); + + it.each` + zoomLevel | expectedX | expectedY + ${"1.0"} | ${561} | ${321} + ${"2.0"} | ${562} | ${322} + ${"3.0"} | ${563} | ${323} + `( + "returns ( $expectedX , $expectedY ) for zoom level '$zoomLevel'", + ({ zoomLevel, expectedX, expectedY }: validateZoomTestType) => { + vi.mocked(useParsedPvConnection).mockReturnValue(zoomLevel); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useZoomAndCrosshair(), { wrapper }); + + expect(result.current.crosshairX).toBe(expectedX); + expect(result.current.crosshairY).toBe(expectedY); + }, + ); + + it("returns NaN for crosshair if zoomIndex is not found", () => { + vi.mocked(useParsedPvConnection).mockReturnValue("99.0"); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useZoomAndCrosshair(), { wrapper }); + + expect(result.current.crosshairX).toBeNaN(); + expect(result.current.crosshairY).toBeNaN(); + }); + + it("returns NaN if beamCenter data is missing", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useZoomAndCrosshair(), { wrapper }); + + expect(result.current.crosshairX).toBeNaN(); + expect(result.current.crosshairY).toBeNaN(); + }); + + it("calls refetch when zoom level changes", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + renderHook(() => useZoomAndCrosshair(), { wrapper }); + vi.mocked(useParsedPvConnection).mockReturnValue("3.0"); + expect(mockRefetch).toHaveBeenCalled(); + }); +}); diff --git a/src/screens/OavMover/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index 0098340..0cf4037 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -1,25 +1,33 @@ import { Grid2, useTheme } from "@mui/material"; +import { useContext, useRef } from "react"; import { OAVSideBar } from "./OAVSideBar"; import { submitAndRunPlanImmediately } from "#/blueapi/blueapi.ts"; import { readVisitFromPv, parseInstrumentSession } from "#/blueapi/visit.ts"; import { OavVideoStream } from "#/components/OavVideoStream.tsx"; -import { useConfigCall } from "#/config_server/configServer.ts"; import { forceString, useParsedPvConnection } from "#/pv/util.ts"; import { ZoomLevels } from "#/pv/enumPvValues.ts"; -import { useMemo } from "react"; +import { useMemo, useEffect } from "react"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; -const DISPLAY_CONFIG_ENDPOINT = - "/dls_sw/i24/software/daq_configuration/domain/display.configuration"; +const ZOOM_PV = "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT"; +const BEAM_CENTER_LINES_PER_ZOOM = 7; -export function OavMover() { - const beamCenterQuery = useConfigCall(DISPLAY_CONFIG_ENDPOINT); +export function useZoomAndCrosshair() { + const beamCenterQuery = useContext(BeamCenterContext); const currentZoomValue = String( useParsedPvConnection({ - pv: "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT", + pv: ZOOM_PV, label: "zoom-level", transformValue: forceString, }), ); + + const beamCenterQueryRef = useRef(beamCenterQuery); + + useEffect(() => { + beamCenterQueryRef.current.refetch(); + }, [currentZoomValue]); + const zoomIndex = ZoomLevels.findIndex( (element: string) => element == currentZoomValue, ); @@ -30,8 +38,8 @@ export function OavMover() { } const lines = beamCenterQuery.data.split("\n"); - const xLine = lines[zoomIndex * 7 + 1]; - const yLine = lines[zoomIndex * 7 + 2]; + const xLine = lines[zoomIndex * BEAM_CENTER_LINES_PER_ZOOM + 1]; + const yLine = lines[zoomIndex * BEAM_CENTER_LINES_PER_ZOOM + 2]; if (!xLine || !yLine) { return [NaN, NaN]; @@ -40,10 +48,17 @@ export function OavMover() { return [Number(xLine.split(" ")[2]), Number(yLine.split(" ")[2])]; }, [beamCenterQuery.data, zoomIndex]); + return { crosshairX, crosshairY }; +} + +export function OavMover() { + const { crosshairX, crosshairY } = useZoomAndCrosshair(); + const theme = useTheme(); const bgColor = theme.palette.background.paper; const fullVisit = readVisitFromPv(); + const beamCenterQuery = useContext(BeamCenterContext); function onCoordClick(x: number, y: number) { submitAndRunPlanImmediately({ @@ -52,9 +67,11 @@ export function OavMover() { instrumentSession: parseInstrumentSession(fullVisit), }).catch((error) => { console.log( - `Failed to run plan , see console and logs for full error. Reason: ${error}`, + `Failed to run plan, see console and logs for full error. Reason: ${error}`, ); }); + + beamCenterQuery.refetch(); } return ( diff --git a/vite.config.ts b/vite.config.ts index b8c1302..25333a5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,19 +1,44 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; +// Where the i24 OAV's MJPG server lives, for when the environment does not say. +const OAV_STREAM_SERVER = "http://bl24i-di-serv-01.diamond.ac.uk:8080"; + // https://vite.dev/config/ -export default defineConfig({ - plugins: [react()], - define: { - "process.env": { - VITE_PVWS_SOCKET: "pvws.diamond.ac.uk", - VITE_PVWS_SSL: "true", +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, __dirname, "VITE_"); + return { + plugins: [react()], + define: { + "process.env": { + VITE_PVWS_SOCKET: "pvws.diamond.ac.uk", + VITE_PVWS_SSL: "true", + }, + }, + resolve: { + alias: { + "#": path.resolve(__dirname, "src"), // <-- maps #/* → src/* + }, }, - }, - resolve: { - alias: { - "#": path.resolve(__dirname, "src"), // <-- maps #/* → src/* + server: { + // blueapi is addressed as the origin-relative /api (see src/blueapi/blueapi.ts); + // in production something upstream routes that, so in dev we proxy it ourselves. + proxy: { + "/api": { + target: env.VITE_BLUEAPI_SOCKET_DEV ?? "http://localhost:25565", + changeOrigin: true, + rewrite: (p) => p.replace(/^\/api/, ""), + }, + // The OAV stream is served from this origin too, so that an https deployment + // is not blocked from loading it over http (see oavStreamUrl). In production + // nginx proxies it; here we do. + "/oav-stream": { + target: env.VITE_OAV_STREAM_SOCKET_DEV ?? OAV_STREAM_SERVER, + changeOrigin: true, + rewrite: (p) => p.replace(/^\/oav-stream/, ""), + }, + }, }, - }, + }; });