Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -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"
VITE_CONFIG_SOCKET="https://daq-config.diamond.ac.uk"
VITE_OAV_STREAM_SOCKET_DEV="http://bl24i-di-serv-01.diamond.ac.uk:8080"
12 changes: 11 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;"]
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions oav-stream.conf.template
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 12 additions & 7 deletions src/blueapi/BlueapiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type RunPlanButtonProps = {
sx?: object;
tooltipSx?: object;
typographySx?: object;
onSuccess?: () => void | Promise<void>; // Optional callback after plan succeeds
};

export function RunPlanButton(props: RunPlanButtonProps) {
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions src/components/JungFrau/CollectDarksPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function CollectDarksPanel() {
<Typography
variant="h2"
sx={{
color: theme.palette.primary.contrastText,
color: theme.palette.text.primary,
fontSize: 24,
fontWeight: "fontWeightBold",
}}
Expand All @@ -62,7 +62,7 @@ export function CollectDarksPanel() {
<Typography
variant="body1"
sx={{
color: theme.palette.primary.contrastText,
color: theme.palette.text.primary,
fontSize: 20,
fontWeight: "fontWeightBold",
}}
Expand Down Expand Up @@ -99,7 +99,7 @@ export function CollectDarksPanel() {
<Typography
variant="body1"
sx={{
color: theme.palette.primary.contrastText,
color: theme.palette.text.primary,
fontSize: 20,
fontWeight: "fontWeightBold",
}}
Expand Down
2 changes: 1 addition & 1 deletion src/components/JungFrau/CollectionSetupJf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function CollectionSetupJf() {
<Typography
variant="h2"
sx={{
color: theme.palette.primary.contrastText,
color: theme.palette.text.primary,
fontSize: 24,
fontWeight: "fontWeightBold",
}}
Expand Down
16 changes: 8 additions & 8 deletions src/components/OavVideoStream.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 (
<Box sx={{ padding: 0 }}>
Expand Down Expand Up @@ -116,8 +118,6 @@ function VideoBoxWithOverlay(props: {
drawCanvas(canvasRef, props.crosshairX, props.crosshairY);
}, [props.crosshairX, props.crosshairY, width, height]);

console.info();

return (
<Box position={"relative"} padding={0} ref={videoBoxRef}>
<img
Expand Down
20 changes: 20 additions & 0 deletions src/components/OavVideoStreamHelper.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
18 changes: 18 additions & 0 deletions src/components/OavVideoStreamHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
};
2 changes: 1 addition & 1 deletion src/config_server/configServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ export function useConfigCall(
return await response.text();
};
return useQuery(queryKey ?? "ConfigCall", fetchCall, {
refetchInterval: pollRateMillis ?? 500,
refetchInterval: pollRateMillis ?? false,
});
}
11 changes: 11 additions & 0 deletions src/context/beamcenter/BeamCenterContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { createContext } from "react";

type BeamCenterQueryResult = {
data: string | null | undefined;
refetch: () => void;
};

export const BeamCenterContext = createContext<BeamCenterQueryResult>({
data: null,
refetch: () => {},
});
80 changes: 80 additions & 0 deletions src/context/beamcenter/BeamCenterProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div data-testid="context-value">{value.data}</div>
<button data-testid="refetch-button" onClick={() => value.refetch()}>
Refetch
</button>
</>
);
};

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<string, unknown>,
),
);

it("calls useConfigCall with the correct endpoint", () => {
render(
<BeamCenterProvider>
<TestConsumer />
</BeamCenterProvider>,
);

expect(useConfigCall).toHaveBeenCalledWith(
"/dls_sw/i24/software/daq_configuration/domain/display.configuration",
);
});

it("provides the data to consumers via context", () => {
render(
<BeamCenterProvider>
<TestConsumer />
</BeamCenterProvider>,
);

expect(screen.getByTestId("context-value")).toHaveTextContent(
"mock config text",
);
});

it("passes refetch function through context and it can be called", () => {
render(
<BeamCenterProvider>
<TestConsumer />
</BeamCenterProvider>,
);

fireEvent.click(screen.getByTestId("refetch-button"));
expect(mockRefetch).toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions src/context/beamcenter/BeamCenterProvider.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<BeamCenterContext.Provider value={beamCenterQuery}>
{children}
</BeamCenterContext.Provider>
);
};
Loading
Loading