From 056719530dca7837574aafe071c4eaa5bf5c2e9f Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Tue, 24 Feb 2026 16:21:30 +0000 Subject: [PATCH 01/18] Removed empty log and set polling to false. --- src/components/OavVideoStream.tsx | 2 -- src/config_server/configServer.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index 585d71f..8e5f439 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -116,8 +116,6 @@ function VideoBoxWithOverlay(props: { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); }, [props.crosshairX, props.crosshairY, width, height]); - console.info(); - return ( Date: Tue, 24 Feb 2026 17:17:24 +0000 Subject: [PATCH 02/18] Added useEffect to refetch each time zoomValue is changed --- src/screens/OavMover/OAVStageController.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/screens/OavMover/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index 0098340..6d55ee6 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -6,7 +6,7 @@ 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"; const DISPLAY_CONFIG_ENDPOINT = "/dls_sw/i24/software/daq_configuration/domain/display.configuration"; @@ -20,6 +20,9 @@ export function OavMover() { transformValue: forceString, }), ); + useEffect(() => { + beamCenterQuery.refetch(); + }, [currentZoomValue]); const zoomIndex = ZoomLevels.findIndex( (element: string) => element == currentZoomValue, ); From afa52ff04358980ac55fe1b6ef4e05be3c464c62 Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Wed, 25 Feb 2026 17:39:36 +0000 Subject: [PATCH 03/18] Refactored OAVStageController into smaller functions --- src/screens/OavMover/OAVStageController.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/screens/OavMover/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index 6d55ee6..d47b9ea 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -10,19 +10,22 @@ import { useMemo, useEffect } from "react"; const DISPLAY_CONFIG_ENDPOINT = "/dls_sw/i24/software/daq_configuration/domain/display.configuration"; +const ZOOM_PV = "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT"; -export function OavMover() { +function useZoomAndCrosshair() { const beamCenterQuery = useConfigCall(DISPLAY_CONFIG_ENDPOINT); const currentZoomValue = String( useParsedPvConnection({ - pv: "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT", + pv: ZOOM_PV, label: "zoom-level", transformValue: forceString, }), ); + useEffect(() => { beamCenterQuery.refetch(); }, [currentZoomValue]); + const zoomIndex = ZoomLevels.findIndex( (element: string) => element == currentZoomValue, ); @@ -43,6 +46,12 @@ 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; From 1178cbcc58b3fef962505c5d701c01b7e19df221 Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Fri, 27 Feb 2026 15:12:04 +0000 Subject: [PATCH 04/18] Created BeamCenter Context and some refactoring --- src/context/BeamCenterContext.ts | 8 ++++++++ src/context/BeamCenterProvider.tsx | 16 ++++++++++++++++ src/routes/BeamlineI24.tsx | 9 ++++++--- src/screens/OavMover/OAVStageController.tsx | 16 ++++++++-------- 4 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 src/context/BeamCenterContext.ts create mode 100644 src/context/BeamCenterProvider.tsx diff --git a/src/context/BeamCenterContext.ts b/src/context/BeamCenterContext.ts new file mode 100644 index 0000000..37ce7e2 --- /dev/null +++ b/src/context/BeamCenterContext.ts @@ -0,0 +1,8 @@ +import { createContext } from "react"; +import { UseQueryResult } from "react-query"; + +type BeamCenterQueryResult = UseQueryResult; + +export const BeamCenterContext = createContext( + null as unknown as BeamCenterQueryResult, +); diff --git a/src/context/BeamCenterProvider.tsx b/src/context/BeamCenterProvider.tsx new file mode 100644 index 0000000..a9db5f2 --- /dev/null +++ b/src/context/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..3f14120 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/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/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index d47b9ea..8773c0e 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -1,19 +1,19 @@ import { Grid2, useTheme } from "@mui/material"; +import { useContext } 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, useEffect } from "react"; +import { BeamCenterContext } from "#/context/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; function useZoomAndCrosshair() { - const beamCenterQuery = useConfigCall(DISPLAY_CONFIG_ENDPOINT); + const beamCenterQuery = useContext(BeamCenterContext); const currentZoomValue = String( useParsedPvConnection({ pv: ZOOM_PV, @@ -24,7 +24,7 @@ function useZoomAndCrosshair() { useEffect(() => { beamCenterQuery.refetch(); - }, [currentZoomValue]); + }, [currentZoomValue, beamCenterQuery]); const zoomIndex = ZoomLevels.findIndex( (element: string) => element == currentZoomValue, @@ -36,8 +36,8 @@ function useZoomAndCrosshair() { } 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]; @@ -64,7 +64,7 @@ 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}`, ); }); } From 6d65ffef28a45bc130baff287e6c6ead6ea8f786 Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Fri, 27 Feb 2026 15:26:54 +0000 Subject: [PATCH 05/18] Added refetch to canvas click --- src/components/OavVideoStream.tsx | 5 ++++- src/screens/OavMover/OAVStageController.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index 8e5f439..0283471 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -1,5 +1,5 @@ import { Box } from "@mui/material"; -import React, { useEffect } from "react"; +import React, { useContext, useEffect } from "react"; import { useContainerDimensions } from "./OavVideoStreamHelper"; import { PvComponent } from "#/pv/PvComponent.tsx"; import { PvDescription, PvItem } from "#/pv/types.ts"; @@ -8,6 +8,7 @@ import { parseNumericPv, pvIntArrayToString, } from "#/pv/util.ts"; +import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; /* * A viewer which allows overlaying a crosshair (takes numbers which could be the values from a react useState hook) @@ -111,6 +112,7 @@ function VideoBoxWithOverlay(props: { const canvasRef = React.useRef(null); const videoBoxRef = React.useRef(null); const { width, height } = useContainerDimensions(videoBoxRef); + const beamCenterQuery = useContext(BeamCenterContext); useEffect(() => { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); @@ -137,6 +139,7 @@ function VideoBoxWithOverlay(props: { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; props.onCoordClick(x, y); + beamCenterQuery.refetch(); } } }} diff --git a/src/screens/OavMover/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index 8773c0e..8590f4f 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -24,7 +24,7 @@ function useZoomAndCrosshair() { useEffect(() => { beamCenterQuery.refetch(); - }, [currentZoomValue, beamCenterQuery]); + }, [currentZoomValue]); const zoomIndex = ZoomLevels.findIndex( (element: string) => element == currentZoomValue, From 60ee32518bd1fdaf813e5ee965045f75711947bd Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Fri, 27 Feb 2026 15:55:04 +0000 Subject: [PATCH 06/18] Buttons added to run refetch on click --- src/blueapi/BlueapiComponents.tsx | 19 ++++--- src/screens/OavMover/OAVMoveController.tsx | 58 ++++++++++++++++++---- 2 files changed, 61 insertions(+), 16 deletions(-) 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/screens/OavMover/OAVMoveController.tsx b/src/screens/OavMover/OAVMoveController.tsx index 9c139a8..50571ca 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/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()} + /> ); } From 6a8a36424cd430d64da6ecd5b0632d6b5cff9199 Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Mon, 2 Mar 2026 15:33:13 +0000 Subject: [PATCH 07/18] Moved context files and added tests --- src/components/OavVideoStream.tsx | 2 +- .../{ => beamcenter}/BeamCenterContext.ts | 0 .../beamcenter/BeamCenterProvider.test.tsx | 80 +++++++++++++++++++ .../{ => beamcenter}/BeamCenterProvider.tsx | 0 src/routes/BeamlineI24.tsx | 2 +- src/screens/OavMover/OAVMoveController.tsx | 2 +- src/screens/OavMover/OAVStageController.tsx | 2 +- 7 files changed, 84 insertions(+), 4 deletions(-) rename src/context/{ => beamcenter}/BeamCenterContext.ts (100%) create mode 100644 src/context/beamcenter/BeamCenterProvider.test.tsx rename src/context/{ => beamcenter}/BeamCenterProvider.tsx (100%) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index 0283471..9754619 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -8,7 +8,7 @@ import { parseNumericPv, pvIntArrayToString, } from "#/pv/util.ts"; -import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; /* * A viewer which allows overlaying a crosshair (takes numbers which could be the values from a react useState hook) diff --git a/src/context/BeamCenterContext.ts b/src/context/beamcenter/BeamCenterContext.ts similarity index 100% rename from src/context/BeamCenterContext.ts rename to src/context/beamcenter/BeamCenterContext.ts 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/BeamCenterProvider.tsx b/src/context/beamcenter/BeamCenterProvider.tsx similarity index 100% rename from src/context/BeamCenterProvider.tsx rename to src/context/beamcenter/BeamCenterProvider.tsx diff --git a/src/routes/BeamlineI24.tsx b/src/routes/BeamlineI24.tsx index 3f14120..c684cbb 100644 --- a/src/routes/BeamlineI24.tsx +++ b/src/routes/BeamlineI24.tsx @@ -2,7 +2,7 @@ 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/BeamCenterProvider.tsx"; +import { BeamCenterProvider } from "#/context/beamcenter/BeamCenterProvider.tsx"; import { Box, Tab, Tabs, useTheme } from "@mui/material"; import { ErrorBoundary } from "react-error-boundary"; import { useState } from "react"; diff --git a/src/screens/OavMover/OAVMoveController.tsx b/src/screens/OavMover/OAVMoveController.tsx index 50571ca..4429f17 100644 --- a/src/screens/OavMover/OAVMoveController.tsx +++ b/src/screens/OavMover/OAVMoveController.tsx @@ -1,6 +1,6 @@ import { RunPlanButton } from "#/blueapi/BlueapiComponents.tsx"; import { useContext } from "react"; -import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; import { KeyboardDoubleArrowUp, KeyboardArrowUp, diff --git a/src/screens/OavMover/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index 8590f4f..da8791c 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -7,7 +7,7 @@ import { OavVideoStream } from "#/components/OavVideoStream.tsx"; import { forceString, useParsedPvConnection } from "#/pv/util.ts"; import { ZoomLevels } from "#/pv/enumPvValues.ts"; import { useMemo, useEffect } from "react"; -import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; const ZOOM_PV = "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT"; const BEAM_CENTER_LINES_PER_ZOOM = 7; From f7acd31a5d12a274d7b6d76f72a2d5e9ba630320 Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Mon, 2 Mar 2026 16:11:23 +0000 Subject: [PATCH 08/18] Refactor of context --- src/components/OavVideoStream.tsx | 5 +---- src/screens/OavMover/OAVStageController.tsx | 9 +++++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index 9754619..8e5f439 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -1,5 +1,5 @@ import { Box } from "@mui/material"; -import React, { useContext, useEffect } from "react"; +import React, { useEffect } from "react"; import { useContainerDimensions } from "./OavVideoStreamHelper"; import { PvComponent } from "#/pv/PvComponent.tsx"; import { PvDescription, PvItem } from "#/pv/types.ts"; @@ -8,7 +8,6 @@ import { parseNumericPv, pvIntArrayToString, } from "#/pv/util.ts"; -import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; /* * A viewer which allows overlaying a crosshair (takes numbers which could be the values from a react useState hook) @@ -112,7 +111,6 @@ function VideoBoxWithOverlay(props: { const canvasRef = React.useRef(null); const videoBoxRef = React.useRef(null); const { width, height } = useContainerDimensions(videoBoxRef); - const beamCenterQuery = useContext(BeamCenterContext); useEffect(() => { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); @@ -139,7 +137,6 @@ function VideoBoxWithOverlay(props: { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; props.onCoordClick(x, y); - beamCenterQuery.refetch(); } } }} diff --git a/src/screens/OavMover/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index da8791c..4729c35 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -1,5 +1,5 @@ import { Grid2, useTheme } from "@mui/material"; -import { useContext } from "react"; +import { useContext, useRef } from "react"; import { OAVSideBar } from "./OAVSideBar"; import { submitAndRunPlanImmediately } from "#/blueapi/blueapi.ts"; import { readVisitFromPv, parseInstrumentSession } from "#/blueapi/visit.ts"; @@ -22,8 +22,10 @@ function useZoomAndCrosshair() { }), ); + const beamCenterQueryRef = useRef(beamCenterQuery); + useEffect(() => { - beamCenterQuery.refetch(); + beamCenterQueryRef.current.refetch(); }, [currentZoomValue]); const zoomIndex = ZoomLevels.findIndex( @@ -56,6 +58,7 @@ export function OavMover() { const bgColor = theme.palette.background.paper; const fullVisit = readVisitFromPv(); + const beamCenterQuery = useContext(BeamCenterContext); function onCoordClick(x: number, y: number) { submitAndRunPlanImmediately({ @@ -67,6 +70,8 @@ export function OavMover() { `Failed to run plan, see console and logs for full error. Reason: ${error}`, ); }); + + beamCenterQuery.refetch(); } return ( From 1c2dadd7b60728e85c7ab3125dfd1a47009f8bcf Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Wed, 4 Mar 2026 12:11:29 +0000 Subject: [PATCH 09/18] Created tests for the fetching of beamCenterQuery --- src/context/beamcenter/BeamCenterContext.ts | 13 +- .../OavMover/OAVStageController.test.tsx | 119 ++++++++++++++++++ src/screens/OavMover/OAVStageController.tsx | 2 +- 3 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 src/screens/OavMover/OAVStageController.test.tsx diff --git a/src/context/beamcenter/BeamCenterContext.ts b/src/context/beamcenter/BeamCenterContext.ts index 37ce7e2..6552751 100644 --- a/src/context/beamcenter/BeamCenterContext.ts +++ b/src/context/beamcenter/BeamCenterContext.ts @@ -1,8 +1,11 @@ import { createContext } from "react"; -import { UseQueryResult } from "react-query"; -type BeamCenterQueryResult = UseQueryResult; +type BeamCenterQueryResult = { + data: string | null | undefined; + refetch: () => void; +}; -export const BeamCenterContext = createContext( - null as unknown as BeamCenterQueryResult, -); +export const BeamCenterContext = createContext({ + data: null, + 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 4729c35..0cf4037 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -12,7 +12,7 @@ import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; const ZOOM_PV = "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT"; const BEAM_CENTER_LINES_PER_ZOOM = 7; -function useZoomAndCrosshair() { +export function useZoomAndCrosshair() { const beamCenterQuery = useContext(BeamCenterContext); const currentZoomValue = String( useParsedPvConnection({ From 00add2b20440359ae83ee065f14a0005dc12c29d Mon Sep 17 00:00:00 2001 From: Nicholas Devenish Date: Wed, 5 Aug 2026 17:05:03 +0100 Subject: [PATCH 10/18] Use text.primary for Jungfrau panel headings (#144) These headings sit on the page and card backgrounds, not on a primary coloured surface, so primary.contrastText was wrong: it resolves to #ffffff in both schemes, leaving white text on the #fff paper of the light scheme. text.primary is scheme aware, so the headings stay readable in both. --- src/components/JungFrau/CollectDarksPanel.tsx | 6 +++--- src/components/JungFrau/CollectionSetupJf.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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() { Date: Thu, 20 Aug 2026 09:56:58 +0100 Subject: [PATCH 11/18] Pinning pnpm version (#145) * Pinning pnpm version * Pinned --------- Co-authored-by: Tamoor Shahid --- package.json | 1 + 1 file changed, 1 insertion(+) 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", From 6b8c415d15710ed2f674310abe73ebcebf0b47ef Mon Sep 17 00:00:00 2001 From: Tamoor Date: Thu, 20 Aug 2026 10:10:59 +0100 Subject: [PATCH 12/18] Dockerfix (#146) * Pinning pnpm version * Pinned * Added pnpm workspace to docker --------- Co-authored-by: Tamoor Shahid --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f1ce61f..1f6b2a4 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 From d19e4b44dc2d6e4e90158c431fc501ca8b3e0f2d Mon Sep 17 00:00:00 2001 From: Nicholas Devenish Date: Wed, 29 Jul 2026 14:47:51 +0100 Subject: [PATCH 13/18] Proxy /api to blueapi in the vite dev server The blueapi base URL is the origin-relative /api, so in dev it resolved against the dev server itself (localhost:5173/api/...) and 404'd. Proxy it to VITE_BLUEAPI_SOCKET_DEV instead, stripping the /api prefix. Production is unchanged and still relies on upstream routing. The config becomes a function so loadEnv can read .env, which is not otherwise visible as process.env inside a vite config. The README still described the arrangement that predates all of this - set VITE_BLUEAPI_SOCKET, which nothing has read since blueapi.ts was pointed at /api - so say instead what each variable in .env is read by, and which one is now inert. --- README.md | 13 ++++++++++++- vite.config.ts | 38 ++++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 81f8a8e..0248aee 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,18 @@ To connect to the Diamond PVWS instance at `pvws.diamond.ac.uk`, we take advanta ### 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 | + +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/vite.config.ts b/vite.config.ts index b8c1302..040d930 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,19 +1,33 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; // 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/, ""), + }, + }, + }, + }; }); From 31525e80fe610b7cb542cff7bf46d82885295a7e Mon Sep 17 00:00:00 2001 From: Nicholas Devenish Date: Fri, 21 Aug 2026 11:32:01 +0100 Subject: [PATCH 14/18] Serve the OAV stream from this app's origin The IOC advertises its MJPG stream over http, and a page served over https will not load an image over http - the browser blocks it as mixed content, so the viewer is blank wherever the app is deployed behind TLS, which is every deployment on the cluster. So ask for the stream at /oav-stream on our own origin, and proxy it on to the MJPG server: nginx does it in the built image, the dev server does it otherwise. MJPG_URL_RBV still chooses which stream is shown; only the server it is fetched from is named by the proxies. nginx has no way to read the environment itself, but the entrypoint of the image we deploy runs envsubst over /etc/nginx/templates at start up, so the location block is a template with the upstream left in it. OAV_STREAM_SERVER defaults, in the Dockerfile, to i24's MJPG server, and a deployment that wants another one now says so rather than needing its own image. Unset or empty, nginx refuses to start. The upstream is named through a variable rather than written into proxy_pass literally, because a literal is resolved while the configuration is read: an OAV server that was down, or whose name would not resolve, stopped nginx starting at all - taking the whole UI with it over a video stream. Deferring the lookup to the request needs a resolver, which the entrypoint reads from the container's own /etc/resolv.conf when NGINX_ENTRYPOINT_LOCAL_RESOLVERS is set. A stream from a server that is down is now a 502 and a broken image, and starts working when the server does, without a restart. Since a proxy_pass with a variable in it passes the URI on untouched, the location's prefix is stripped by an explicit rewrite. --- .env | 3 +- Dockerfile | 10 ++++++ README.md | 35 ++++++++++++++++++--- nginx.conf | 7 +++++ oav-stream.conf.template | 30 ++++++++++++++++++ src/components/OavVideoStream.tsx | 14 +++++---- src/components/OavVideoStreamHelper.test.ts | 20 ++++++++++++ src/components/OavVideoStreamHelper.ts | 18 +++++++++++ vite.config.ts | 11 +++++++ 9 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 oav-stream.conf.template create mode 100644 src/components/OavVideoStreamHelper.test.ts 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 1f6b2a4..ffee616 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 0248aee..52d2bd9 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,40 @@ 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 `.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 | +| 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 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/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index 8e5f439..c9eff7c 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -1,6 +1,6 @@ import { Box } from "@mui/material"; import React, { useEffect } from "react"; -import { useContainerDimensions } from "./OavVideoStreamHelper"; +import { oavStreamUrl, useContainerDimensions } from "./OavVideoStreamHelper"; import { PvComponent } from "#/pv/PvComponent.tsx"; import { PvDescription, PvItem } from "#/pv/types.ts"; import { @@ -46,11 +46,13 @@ export function OavVideoStream( label: props.label, render: (props: PvItem) => { 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 ( diff --git a/src/components/OavVideoStreamHelper.test.ts b/src/components/OavVideoStreamHelper.test.ts new file mode 100644 index 0000000..754a22b --- /dev/null +++ b/src/components/OavVideoStreamHelper.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "vitest"; +import { oavStreamUrl } from "./OavVideoStreamHelper"; + +describe("oavStreamUrl", () => { + 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/vite.config.ts b/vite.config.ts index 040d930..25333a5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,9 @@ 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(({ mode }) => { const env = loadEnv(mode, __dirname, "VITE_"); @@ -27,6 +30,14 @@ export default defineConfig(({ mode }) => { 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/, ""), + }, }, }, }; From d43e66928ef8d3fbc4dd066e3f2c5023d94e27da Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Fri, 27 Feb 2026 15:26:54 +0000 Subject: [PATCH 15/18] Added refetch to canvas click --- src/components/OavVideoStream.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index c9eff7c..9e7953f 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -1,6 +1,6 @@ import { Box } from "@mui/material"; -import React, { useEffect } from "react"; import { oavStreamUrl, useContainerDimensions } from "./OavVideoStreamHelper"; +import React, { useContext, useEffect } from "react"; import { PvComponent } from "#/pv/PvComponent.tsx"; import { PvDescription, PvItem } from "#/pv/types.ts"; import { @@ -8,6 +8,7 @@ import { parseNumericPv, pvIntArrayToString, } from "#/pv/util.ts"; +import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; /* * A viewer which allows overlaying a crosshair (takes numbers which could be the values from a react useState hook) @@ -113,6 +114,7 @@ function VideoBoxWithOverlay(props: { const canvasRef = React.useRef(null); const videoBoxRef = React.useRef(null); const { width, height } = useContainerDimensions(videoBoxRef); + const beamCenterQuery = useContext(BeamCenterContext); useEffect(() => { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); @@ -139,6 +141,7 @@ function VideoBoxWithOverlay(props: { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; props.onCoordClick(x, y); + beamCenterQuery.refetch(); } } }} From 89e7ee61244ec1936aee3e9b5188a4db394052a6 Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Mon, 2 Mar 2026 16:11:23 +0000 Subject: [PATCH 16/18] Refactor of context --- src/components/OavVideoStream.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index 9e7953f..c9eff7c 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -1,6 +1,6 @@ import { Box } from "@mui/material"; +import React, { useEffect } from "react"; import { oavStreamUrl, useContainerDimensions } from "./OavVideoStreamHelper"; -import React, { useContext, useEffect } from "react"; import { PvComponent } from "#/pv/PvComponent.tsx"; import { PvDescription, PvItem } from "#/pv/types.ts"; import { @@ -8,7 +8,6 @@ import { parseNumericPv, pvIntArrayToString, } from "#/pv/util.ts"; -import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; /* * A viewer which allows overlaying a crosshair (takes numbers which could be the values from a react useState hook) @@ -114,7 +113,6 @@ function VideoBoxWithOverlay(props: { const canvasRef = React.useRef(null); const videoBoxRef = React.useRef(null); const { width, height } = useContainerDimensions(videoBoxRef); - const beamCenterQuery = useContext(BeamCenterContext); useEffect(() => { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); @@ -141,7 +139,6 @@ function VideoBoxWithOverlay(props: { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; props.onCoordClick(x, y); - beamCenterQuery.refetch(); } } }} From fd7aba5c080425581e594c94e37f3f3a0163a56a Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Fri, 27 Feb 2026 15:26:54 +0000 Subject: [PATCH 17/18] Added refetch to canvas click --- src/components/OavVideoStream.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index c9eff7c..c9ff937 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -1,5 +1,5 @@ import { Box } from "@mui/material"; -import React, { useEffect } from "react"; +import React, { useContext, useEffect } from "react"; import { oavStreamUrl, useContainerDimensions } from "./OavVideoStreamHelper"; import { PvComponent } from "#/pv/PvComponent.tsx"; import { PvDescription, PvItem } from "#/pv/types.ts"; @@ -8,6 +8,7 @@ import { parseNumericPv, pvIntArrayToString, } from "#/pv/util.ts"; +import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; /* * A viewer which allows overlaying a crosshair (takes numbers which could be the values from a react useState hook) @@ -113,6 +114,7 @@ function VideoBoxWithOverlay(props: { const canvasRef = React.useRef(null); const videoBoxRef = React.useRef(null); const { width, height } = useContainerDimensions(videoBoxRef); + const beamCenterQuery = useContext(BeamCenterContext); useEffect(() => { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); @@ -139,6 +141,7 @@ function VideoBoxWithOverlay(props: { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; props.onCoordClick(x, y); + beamCenterQuery.refetch(); } } }} From 94fccef4f6a6690300157d4d3f9161b945f4d99a Mon Sep 17 00:00:00 2001 From: Tamoor Shahid Date: Mon, 2 Mar 2026 16:11:23 +0000 Subject: [PATCH 18/18] Refactor of context --- src/components/OavVideoStream.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index c9ff937..c9eff7c 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -1,5 +1,5 @@ import { Box } from "@mui/material"; -import React, { useContext, useEffect } from "react"; +import React, { useEffect } from "react"; import { oavStreamUrl, useContainerDimensions } from "./OavVideoStreamHelper"; import { PvComponent } from "#/pv/PvComponent.tsx"; import { PvDescription, PvItem } from "#/pv/types.ts"; @@ -8,7 +8,6 @@ import { parseNumericPv, pvIntArrayToString, } from "#/pv/util.ts"; -import { BeamCenterContext } from "#/context/BeamCenterContext.ts"; /* * A viewer which allows overlaying a crosshair (takes numbers which could be the values from a react useState hook) @@ -114,7 +113,6 @@ function VideoBoxWithOverlay(props: { const canvasRef = React.useRef(null); const videoBoxRef = React.useRef(null); const { width, height } = useContainerDimensions(videoBoxRef); - const beamCenterQuery = useContext(BeamCenterContext); useEffect(() => { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); @@ -141,7 +139,6 @@ function VideoBoxWithOverlay(props: { const rect = canvas.getBoundingClientRect(); const [x, y] = [e.clientX - rect.left, e.clientY - rect.top]; props.onCoordClick(x, y); - beamCenterQuery.refetch(); } } }}