diff --git a/app/Footer.test.tsx b/app/Footer.test.tsx new file mode 100644 index 0000000..c69e572 --- /dev/null +++ b/app/Footer.test.tsx @@ -0,0 +1,7 @@ +import { render } from "@testing-library/react"; +import Footer from "@/app/Footer"; + +it("renders footer unchanged", () => { + const { container } = render( ); } diff --git a/app/Navbar.test.tsx b/app/Navbar.test.tsx new file mode 100644 index 0000000..5804386 --- /dev/null +++ b/app/Navbar.test.tsx @@ -0,0 +1,7 @@ +import { render } from "@testing-library/react"; +import NavBar from "@/app/NavBar"; + +it("renders navbar unchanged", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); +}); diff --git a/app/__snapshots__/Footer.test.tsx.snap b/app/__snapshots__/Footer.test.tsx.snap new file mode 100644 index 0000000..fa69ddb --- /dev/null +++ b/app/__snapshots__/Footer.test.tsx.snap @@ -0,0 +1,59 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders footer unchanged 1`] = ` +
+ +
+`; diff --git a/app/__snapshots__/Navbar.test.tsx.snap b/app/__snapshots__/Navbar.test.tsx.snap new file mode 100644 index 0000000..ccab8c3 --- /dev/null +++ b/app/__snapshots__/Navbar.test.tsx.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders navbar unchanged 1`] = ` +
+ +
+`; diff --git a/app/__snapshots__/page.test.tsx.snap b/app/__snapshots__/page.test.tsx.snap new file mode 100644 index 0000000..e14de33 --- /dev/null +++ b/app/__snapshots__/page.test.tsx.snap @@ -0,0 +1,241 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders main page unchanged 1`] = ` +
+
+
+
+
+
+

+ Experiment Controls +

+ +

+ Web Dashboard +

+ +
+ +
+
+
+
+
+ + + + + +

+ Quick Links +

+

+ View instrument scientist quick links (requires login). +

+ + + + + +
+
+ + + + + +

+ Instrument Journals +

+

+ View recent instrument journal entries (requires login). +

+ + + + + +
+
+ + + + + +

+ Beam status & MCR News +

+

+ View current beam status & recent MCR news updates. +

+ + + + + +
+
+
+
+
+
+
+
+
+`; diff --git a/app/commonVars.ts b/app/commonVars.ts index 75f3dd2..ef4535e 100644 --- a/app/commonVars.ts +++ b/app/commonVars.ts @@ -2,6 +2,7 @@ import { IfcPVWSRequest, PVWSRequestType } from "@/app/types"; export const instListPV = "CS:INSTLIST"; export const socketURL = + /* c8 ignore next */ process.env.NEXT_PUBLIC_WS_URL || "ws://localhost:8080/pvws/pv"; export const instListSubscription: IfcPVWSRequest = { diff --git a/app/components/CheckToggle.tsx b/app/components/CheckToggle.tsx index 219c1d6..8cc6063 100644 --- a/app/components/CheckToggle.tsx +++ b/app/components/CheckToggle.tsx @@ -4,10 +4,12 @@ export default function CheckToggle({ checked, setChecked, text, + textColour = "text-gray-900", }: { checked: boolean; setChecked: Dispatch>; text: string; + textColour?: string; }) { return (
@@ -21,7 +23,7 @@ export default function CheckToggle({ className="sr-only peer" />
- {text} + {text}
); diff --git a/app/components/Groups.test.tsx b/app/components/Groups.test.tsx new file mode 100644 index 0000000..745be83 --- /dev/null +++ b/app/components/Groups.test.tsx @@ -0,0 +1,41 @@ +import { render } from "@testing-library/react"; +import Groups from "@/app/components/Groups"; +import { IfcGroup } from "@/app/types"; + +it("renders groups correctly with hidden and non hidden groups", () => { + const shownGroupWithTwoBlocks: IfcGroup = { + name: "group1", + blocks: [ + { + pvaddress: "A:SHOWN:BLOCK", + human_readable_name: "aShownBlock", + value: 2.344, + visible: true, + }, + { + pvaddress: "ADIFF:SHOWN:BLOCK", + human_readable_name: "aDifferentShownBlock", + value: 3.14, + visible: true, + }, + ], + }; + const hiddenGroupWithOneBlock: IfcGroup = { + name: "group2", + blocks: [ + { + pvaddress: "A:HIDDEN:BLOCK", + human_readable_name: "aHiddenBlock", + visible: false, + }, + ], + }; + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); +}); diff --git a/app/components/Groups.tsx b/app/components/Groups.tsx index 389dc22..7f034d8 100644 --- a/app/components/Groups.tsx +++ b/app/components/Groups.tsx @@ -12,10 +12,6 @@ export default function Groups({ instName: string; showHiddenBlocks: boolean; }) { - if (!groupsMap) { - return

Loading...

; - } - return (
{groupsMap.map((group) => { diff --git a/app/components/InstrumentData.tsx b/app/components/InstrumentData.tsx new file mode 100644 index 0000000..fa5d289 --- /dev/null +++ b/app/components/InstrumentData.tsx @@ -0,0 +1,210 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { + IfcPVWSMessage, + IfcPVWSRequest, + instList, + PVWSRequestType, +} from "@/app/types"; +import { findPVInDashboard, Instrument } from "@/app/components/Instrument"; +import useWebSocket from "react-use-websocket"; +import { instListPV, instListSubscription, socketURL } from "@/app/commonVars"; +import { + dehex_and_decompress, + instListFromBytes, +} from "@/app/components/dehex_and_decompress"; +import { findPVByAddress } from "@/app/components/PVutils"; +import TopBar from "@/app/components/TopBar"; +import CheckToggle from "@/app/components/CheckToggle"; +import Groups from "@/app/components/Groups"; +import { + CSSB, + getGroupsWithBlocksFromConfigOutput, + RC_ENABLE, + RC_INRANGE, + SP_RBV, + toPrecision, +} from "@/app/components/InstrumentPage"; + +let lastUpdate: string = ""; + +export function InstrumentData({ instrumentName }: { instrumentName: string }) { + const [showHiddenBlocks, setShowHiddenBlocks] = useState(false); + const CONFIG_DETAILS = "CS:BLOCKSERVER:GET_CURR_CONFIG_DETAILS"; + const [instlist, setInstlist] = useState(null); + const [currentInstrument, setCurrentInstrument] = useState( + null, + ); + + const instName = instrumentName; + + useEffect(() => { + if (instName != null) { + document.title = instName.toUpperCase() + " | IBEX Web Dashboard"; + } + }, [instName]); + + const { + sendJsonMessage, + lastJsonMessage, + }: { + sendJsonMessage: (a: IfcPVWSRequest) => void; + lastJsonMessage: IfcPVWSMessage; + } = useWebSocket(socketURL, { + shouldReconnect: (closeEvent) => true, + }); + + useEffect(() => { + // This is an initial useEffect to subscribe to lots of PVs including the instlist. + sendJsonMessage(instListSubscription); + + if (instName == "" || instName == null || instlist == null) { + return; + } + + let prefix = ""; + + for (const item of instlist) { + if (item.name == instName.toUpperCase()) { + prefix = item.pvPrefix; + } + } + if (!prefix) { + // not on the instlist, or the instlist is not available. Try to guess it's a developer machine + prefix = "TE:" + instName.toUpperCase() + ":"; + } + + if (!currentInstrument) { + let instrument = new Instrument(prefix); + setCurrentInstrument(instrument); + + sendJsonMessage({ + type: PVWSRequestType.subscribe, + pvs: [`${prefix}${CONFIG_DETAILS}`], + }); + + // subscribe to dashboard and run info PVs + for (const pv of instrument.runInfoPVs.concat( + instrument.dashboard.flat(3), + )) { + sendJsonMessage({ + type: PVWSRequestType.subscribe, + pvs: [pv.pvaddress], + }); + } + } + }, [instlist, instName, sendJsonMessage, currentInstrument]); + + useEffect(() => { + // This gets run whenever there is a PV update ie. when lastJsonMessage changes. + if (!lastJsonMessage) { + return; + } + const updatedPV: IfcPVWSMessage = lastJsonMessage; + const updatedPVName: string = updatedPV.pv; + const updatedPVbytes: string | null | undefined = updatedPV.b64byt; + + if (updatedPVName == instListPV && updatedPVbytes != null) { + setInstlist(instListFromBytes(updatedPVbytes)); + } + + if (!currentInstrument) { + return; + } + + if ( + updatedPVName == `${currentInstrument.prefix}${CONFIG_DETAILS}` && + updatedPVbytes != null + ) { + // config change, reset instrument groups + if (updatedPVbytes == lastUpdate) { + // config hasnt actually changed so do nothing + return; + } + lastUpdate = updatedPVbytes; + const res = dehex_and_decompress(atob(updatedPVbytes)); + currentInstrument.groups = getGroupsWithBlocksFromConfigOutput( + JSON.parse(res), + sendJsonMessage, + currentInstrument.prefix, + ); + } else { + let pvVal; + if (updatedPV.text != null) { + // PV has string value + pvVal = updatedPV.text; + } else if (updatedPVbytes != null) { + // PV value is base64 encoded + pvVal = atob(updatedPVbytes); + } else if (updatedPV.value != null) { + // PV value is a number + pvVal = updatedPV.value; + } else { + return; + } + + if (findPVInDashboard(currentInstrument.dashboard, updatedPVName)) { + // This is a dashboard block update. + findPVInDashboard(currentInstrument.dashboard, updatedPVName)!.value = + pvVal; + } else if (findPVByAddress(currentInstrument.runInfoPVs, updatedPVName)) { + // This is a run information PV + findPVByAddress(currentInstrument.runInfoPVs, updatedPVName)!.value = + pvVal; + } else { + // This is a block - check if in groups + for (const group of currentInstrument.groups) { + for (const block of group.blocks) { + let block_full_pv_name = + currentInstrument.prefix + CSSB + block.human_readable_name; + if (updatedPVName == block_full_pv_name) { + let prec = updatedPV.precision; + + if (prec != null && prec > 0 && !block.precision) { + // this is likely the first update, and contains precision information which is not repeated on a normal value update - store this in the block for later truncation (see below) + block.precision = prec; + } + // if a block has precision truncate it here + block.value = toPrecision(block, pvVal); + if (updatedPV.seconds) block.updateSeconds = updatedPV.seconds; + + if (updatedPV.units) block.units = updatedPV.units; + if (updatedPV.severity) block.severity = updatedPV.severity; + } else if (updatedPVName == block_full_pv_name + RC_INRANGE) { + block.runcontrol_inrange = updatedPV.value == 1; + } else if (updatedPVName == block_full_pv_name + RC_ENABLE) { + block.runcontrol_enabled = updatedPV.value == 1; + } else if (updatedPVName == block_full_pv_name + SP_RBV) { + block.sp_value = toPrecision(block, pvVal); + } + } + } + } + } + }, [lastJsonMessage, currentInstrument, sendJsonMessage]); + + if (!instName || !currentInstrument) { + return

Loading...

; + } + return ( +
+ +
+
+ +
+ ); +} diff --git a/app/components/InstrumentPage.tsx b/app/components/InstrumentPage.tsx index 9e9f7ae..6f2cc27 100644 --- a/app/components/InstrumentPage.tsx +++ b/app/components/InstrumentPage.tsx @@ -1,32 +1,16 @@ "use client"; -import React, { useEffect, useState } from "react"; -import TopBar from "./TopBar"; -import Groups from "./Groups"; -import useWebSocket from "react-use-websocket"; -import { - dehex_and_decompress, - instListFromBytes, -} from "./dehex_and_decompress"; -import { findPVInDashboard, Instrument } from "./Instrument"; +import React from "react"; import { useSearchParams } from "next/navigation"; import { ConfigOutput, ConfigOutputBlock, IfcBlock, IfcGroup, - IfcPVWSMessage, IfcPVWSRequest, - instList, PVWSRequestType, } from "@/app/types"; -import { - ExponentialOnThresholdFormat, - findPVByAddress, -} from "@/app/components/PVutils"; -import CheckToggle from "@/app/components/CheckToggle"; -import { instListPV, instListSubscription, socketURL } from "@/app/commonVars"; - -let lastUpdate: string = ""; +import { ExponentialOnThresholdFormat } from "@/app/components/PVutils"; +import { InstrumentData } from "@/app/components/InstrumentData"; export default function InstrumentPage() { const searchParams = useSearchParams(); @@ -103,184 +87,3 @@ export function toPrecision( ? ExponentialOnThresholdFormat(pvVal, block.precision) : pvVal; } - -function InstrumentData({ instrumentName }: { instrumentName: string }) { - const [showHiddenBlocks, setShowHiddenBlocks] = useState(false); - const CONFIG_DETAILS = "CS:BLOCKSERVER:GET_CURR_CONFIG_DETAILS"; - const [instlist, setInstlist] = useState(null); - const [currentInstrument, setCurrentInstrument] = useState( - null, - ); - - const instName = instrumentName; - - useEffect(() => { - if (instName != null) { - document.title = instName.toUpperCase() + " | IBEX Web Dashboard"; - } - }, [instName]); - - const { - sendJsonMessage, - lastJsonMessage, - }: { - sendJsonMessage: (a: IfcPVWSRequest) => void; - lastJsonMessage: IfcPVWSMessage; - } = useWebSocket(socketURL, { - shouldReconnect: (closeEvent) => true, - }); - - useEffect(() => { - // This is an initial useEffect to subscribe to lots of PVs including the instlist. - sendJsonMessage(instListSubscription); - - if (instName == "" || instName == null || instlist == null) { - return; - } - - let prefix = ""; - - for (const item of instlist) { - if (item.name == instName.toUpperCase()) { - prefix = item.pvPrefix; - } - } - if (!prefix) { - // not on the instlist, or the instlist is not available. Try to guess it's a developer machine - prefix = "TE:" + instName.toUpperCase() + ":"; - } - - if (!currentInstrument) { - let instrument = new Instrument(prefix); - setCurrentInstrument(instrument); - - sendJsonMessage({ - type: PVWSRequestType.subscribe, - pvs: [`${prefix}${CONFIG_DETAILS}`], - }); - - // subscribe to dashboard and run info PVs - for (const pv of instrument.runInfoPVs.concat( - instrument.dashboard.flat(3), - )) { - sendJsonMessage({ - type: PVWSRequestType.subscribe, - pvs: [pv.pvaddress], - }); - } - } - }, [instlist, instName, sendJsonMessage, currentInstrument]); - - useEffect(() => { - // This gets run whenever there is a PV update ie. when lastJsonMessage changes. - if (!lastJsonMessage) { - return; - } - const updatedPV: IfcPVWSMessage = lastJsonMessage; - const updatedPVName: string = updatedPV.pv; - const updatedPVbytes: string | null | undefined = updatedPV.b64byt; - - if (updatedPVName == instListPV && updatedPVbytes != null) { - setInstlist(instListFromBytes(updatedPVbytes)); - } - - if (!currentInstrument) { - return; - } - - if ( - updatedPVName == `${currentInstrument.prefix}${CONFIG_DETAILS}` && - updatedPVbytes != null - ) { - // config change, reset instrument groups - if (updatedPVbytes == lastUpdate) { - // config hasnt actually changed so do nothing - return; - } - lastUpdate = updatedPVbytes; - const res = dehex_and_decompress(atob(updatedPVbytes)); - currentInstrument.groups = getGroupsWithBlocksFromConfigOutput( - JSON.parse(res), - sendJsonMessage, - currentInstrument.prefix, - ); - } else { - let pvVal; - if (updatedPV.text != null) { - // PV has string value - pvVal = updatedPV.text; - } else if (updatedPVbytes != null) { - // PV value is base64 encoded - pvVal = atob(updatedPVbytes); - } else if (updatedPV.value != null) { - // PV value is a number - pvVal = updatedPV.value; - } else { - return; - } - - if (findPVInDashboard(currentInstrument.dashboard, updatedPVName)) { - // This is a dashboard block update. - findPVInDashboard(currentInstrument.dashboard, updatedPVName)!.value = - pvVal; - } else if (findPVByAddress(currentInstrument.runInfoPVs, updatedPVName)) { - // This is a run information PV - findPVByAddress(currentInstrument.runInfoPVs, updatedPVName)!.value = - pvVal; - } else { - // This is a block - check if in groups - for (const group of currentInstrument.groups) { - for (const block of group.blocks) { - let block_full_pv_name = - currentInstrument.prefix + CSSB + block.human_readable_name; - if (updatedPVName == block_full_pv_name) { - let prec = updatedPV.precision; - - if (prec != null && prec > 0 && !block.precision) { - // this is likely the first update, and contains precision information which is not repeated on a normal value update - store this in the block for later truncation (see below) - block.precision = prec; - } - // if a block has precision truncate it here - block.value = toPrecision(block, pvVal); - if (updatedPV.seconds) block.updateSeconds = updatedPV.seconds; - - if (updatedPV.units) block.units = updatedPV.units; - if (updatedPV.severity) block.severity = updatedPV.severity; - } else if (updatedPVName == block_full_pv_name + RC_INRANGE) { - block.runcontrol_inrange = updatedPV.value == 1; - } else if (updatedPVName == block_full_pv_name + RC_ENABLE) { - block.runcontrol_enabled = updatedPV.value == 1; - } else if (updatedPVName == block_full_pv_name + SP_RBV) { - block.sp_value = toPrecision(block, pvVal); - } - } - } - } - } - }, [lastJsonMessage, currentInstrument, sendJsonMessage]); - - if (!instName || !currentInstrument) { - return

Loading...

; - } - return ( -
- -
-
- -
- ); -} diff --git a/app/components/InstrumentWallCard.test.tsx b/app/components/InstrumentWallCard.test.tsx new file mode 100644 index 0000000..9095e05 --- /dev/null +++ b/app/components/InstrumentWallCard.test.tsx @@ -0,0 +1,20 @@ +import { IfcInstrumentStatus } from "@/app/types"; +import { render } from "@testing-library/react"; +import InstrumentWallCard from "@/app/components/InstrumentWallCard"; + +it("renders instrumentwallcard unchanged", () => { + const instrument: IfcInstrumentStatus = { + name: "Instrument", + runstate: "RUNNING", + }; + const { container } = render(); + expect(container).toMatchSnapshot(); +}); + +it("renders instrumentwallcard unchanged when runstate is unknown", () => { + const instrument: IfcInstrumentStatus = { + name: "Instrument1", + }; + const { container } = render(); + expect(container).toMatchSnapshot(); +}); diff --git a/app/wall/components/InstrumentWallCard.tsx b/app/components/InstrumentWallCard.tsx similarity index 78% rename from app/wall/components/InstrumentWallCard.tsx rename to app/components/InstrumentWallCard.tsx index f7de5e4..e46ce32 100644 --- a/app/wall/components/InstrumentWallCard.tsx +++ b/app/components/InstrumentWallCard.tsx @@ -1,12 +1,9 @@ import Link from "next/link"; -import { - getForegroundColour, - getStatusColour, -} from "../../components/getRunstateColours"; +import { getForegroundColour, getStatusColour } from "./getRunstateColours"; import { IfcInstrumentStatus } from "@/app/types"; -export default function WallCard({ +export default function InstrumentWallCard({ instrument, }: { instrument: IfcInstrumentStatus; @@ -27,9 +24,7 @@ export default function WallCard({ {instrument.name} - - {instrument.runstate ? instrument.runstate : "UNKNOWN"} - + {instrument.runstate || "UNKNOWN"}
diff --git a/app/components/InstrumentsDisplay.test.ts b/app/components/InstrumentsDisplay.test.ts new file mode 100644 index 0000000..4a93beb --- /dev/null +++ b/app/components/InstrumentsDisplay.test.ts @@ -0,0 +1,66 @@ +import { createInstrumentGroups } from "@/app/components/InstrumentsDisplay"; +import { targetStation } from "@/app/types"; + +test("createInstrumentGroups adds two instruments from different target stations to the same science group", () => { + const instrument1Name = "INST1"; + const instrument2Name = "INST2"; + const commonScienceGroup = "MOLSPEC"; + const instrument1 = { + name: instrument1Name, + scienceGroups: [commonScienceGroup], + }; + const instrument2 = { + name: instrument2Name, + scienceGroups: [commonScienceGroup], + }; + const targetStations: Array = [ + { targetStation: "TS0", instruments: [instrument1] }, + { targetStation: "TS3", instruments: [instrument2] }, + ]; + const result = createInstrumentGroups(targetStations); + + expect(result.get(commonScienceGroup)!.sort()).toStrictEqual( + [instrument1, instrument2].sort(), + ); +}); + +test("createInstrumentGroups ignores instrument without any groups", () => { + const instrument1Name = "INST1"; + const commonScienceGroup = "MOLSPEC"; + const instrument1 = { + name: instrument1Name, + scienceGroups: [commonScienceGroup], + }; + const instrument2 = { name: "someinstrumentwithnogroups", scienceGroups: [] }; + const targetStations: Array = [ + { targetStation: "TS0", instruments: [instrument1] }, + { targetStation: "TS3", instruments: [instrument2] }, + ]; + const result = createInstrumentGroups(targetStations); + + expect(result.get(commonScienceGroup)!.sort()).toStrictEqual( + [instrument1].sort(), + ); +}); + +test("createInstrumentGroups ignores instrument which is a support machine", () => { + const instrument1Name = "INST1"; + const commonScienceGroup = "MOLSPEC"; + const instrument1 = { + name: instrument1Name, + scienceGroups: [commonScienceGroup], + }; + const instrument2 = { + name: "someinstrumentwithnogroups", + scienceGroups: ["SUPPORT"], + }; + const targetStations: Array = [ + { targetStation: "TS0", instruments: [instrument1] }, + { targetStation: "TS3", instruments: [instrument2] }, + ]; + const result = createInstrumentGroups(targetStations); + + expect(result.get(commonScienceGroup)!.sort()).toStrictEqual( + [instrument1].sort(), + ); +}); diff --git a/app/components/InstrumentsDisplay.tsx b/app/components/InstrumentsDisplay.tsx new file mode 100644 index 0000000..d30af51 --- /dev/null +++ b/app/components/InstrumentsDisplay.tsx @@ -0,0 +1,207 @@ +"use client"; +import { useEffect, useState } from "react"; +import { + IfcInstrumentStatus, + IfcPVWSMessage, + IfcPVWSRequest, + targetStation, +} from "@/app/types"; +import useWebSocket from "react-use-websocket"; +import { instListPV, instListSubscription, socketURL } from "@/app/commonVars"; +import { instListFromBytes } from "@/app/components/dehex_and_decompress"; +import { + updateInstrumentRunstate, + updateInstrumentRunstatePV, +} from "@/app/wall/utils"; +import TargetStation from "@/app/components/TargetStation"; +import ScienceGroup from "@/app/components/ScienceGroup"; + +// Ignore support machines for the instruments page. +const instrumentsExcludeList = ["SUPPORT"]; + +export function createInstrumentGroups( + targetStations: Array, +): Map> { + let newInstrumentGroups: Map> = new Map(); + for (const targetStation of targetStations) { + for (const inst of targetStation.instruments) { + if (inst.scienceGroups) { + for (const group of inst.scienceGroups) { + if (!instrumentsExcludeList.includes(group)) { + if (!newInstrumentGroups.has(group)) { + // This is a new science group so create a new entry + newInstrumentGroups.set(group, []); + } + newInstrumentGroups.get(group)!.push(inst); + } + } + } + } + } + return newInstrumentGroups; +} + +/* c8 ignore start */ +export default function InstrumentsDisplay({ + sortByGroups = false, +}: { + sortByGroups?: boolean; +}) { + const runstatePV = "DAE:RUNSTATE_STR"; + + const [data, setData] = useState>([ + { + targetStation: "Target Station 1", + instruments: [ + { name: "ALF" }, + { name: "CRISP" }, + { name: "EMMA-A" }, + { name: "EMU" }, + { name: "ENGINX" }, + { name: "GEM" }, + { + name: "HIFI-CRYOMAG", + }, + { name: "HRPD" }, + { name: "INES" }, + { name: "IRIS" }, + { name: "LOQ" }, + { name: "MAPS" }, + { name: "MARI" }, + { name: "MERLIN" }, + { name: "MUONFE" }, + { name: "MUSR" }, + { name: "OSIRIS" }, + { name: "PEARL" }, + { name: "POLARIS" }, + { name: "RIKENFE" }, + { name: "SANDALS" }, + { name: "SCIDEMO" }, + { name: "SURF" }, + { name: "SXD" }, + { name: "TOSCA" }, + { name: "VESUVIO" }, + ], + }, + { + targetStation: "Target Station 2", + instruments: [ + { name: "CHIPIR" }, + { name: "IMAT" }, + { name: "INTER" }, + { name: "LARMOR" }, + { name: "LET" }, + { name: "NIMROD" }, + { name: "OFFSPEC" }, + { name: "POLREF" }, + { name: "SANS2D" }, + { name: "WISH" }, + { name: "ZOOM" }, + ], + }, + { + targetStation: "Miscellaneous", + instruments: [ + { name: "ARGUS" }, + { name: "CHRONUS" }, + { + name: "CRYOLAB_R80", + }, + { name: "DCLAB" }, + { name: "DEMO" }, + { name: "DETMON" }, + { + name: "ENGINX_SETUP", + }, + { name: "HIFI" }, + { + name: "HRPD_SETUP", + }, + { + name: "IBEXGUITEST", + }, + { + name: "IRIS_SETUP", + }, + { name: "MOTION" }, + { + name: "PEARL_SETUP", + }, + { name: "SELAB" }, + { name: "SOFTMAT" }, + { + name: "WISH_SETUP", + }, + ], + }, + ]); + + const { + sendJsonMessage, + lastJsonMessage, + }: { + sendJsonMessage: (a: IfcPVWSRequest) => void; + lastJsonMessage: IfcPVWSMessage; + } = useWebSocket(socketURL, { + shouldReconnect: (closeEvent) => true, + }); + + useEffect(() => { + // On page load, subscribe to the instrument list as it's required to get each instrument's PV prefix. + sendJsonMessage(instListSubscription); + }, [sendJsonMessage]); + + useEffect(() => { + // This is a PV update, it could be either the instlist or an instrument's runstate that has changed + if (!lastJsonMessage) { + return; + } + + const updatedPV: IfcPVWSMessage = lastJsonMessage; + const updatedPVName: string = updatedPV.pv; + const updatedPVbytes: string | null | undefined = updatedPV.b64byt; + let updatedPVvalue: string | null | undefined = updatedPV.text; + + if (updatedPVName == instListPV && updatedPVbytes != null) { + const instListDict = instListFromBytes(updatedPVbytes); + for (const instrument of instListDict) { + setData((prev) => { + return updateInstrumentRunstatePV( + prev, + instrument, + runstatePV, + sendJsonMessage, + ); + }); + } + } else if (updatedPVvalue) { + setData((prev) => { + return updateInstrumentRunstate(prev, updatedPVName, updatedPVvalue); + }); + } + }, [lastJsonMessage, sendJsonMessage]); + + return ( +
+ {sortByGroups && + Array.from(createInstrumentGroups(data).entries()) + .sort((a, b) => b[1].length - a[1].length) // Sort to display the biggest group first + .map(([name, instruments]) => { + return ( + + ); + })} + {!sortByGroups && + data.map((targetStation) => { + return ( + + ); + })} +
+ ); +} +/* c8 ignore end */ diff --git a/app/wall/components/JenkinsJobsIframe.tsx b/app/components/JenkinsJobsIframe.tsx similarity index 100% rename from app/wall/components/JenkinsJobsIframe.tsx rename to app/components/JenkinsJobsIframe.tsx diff --git a/app/components/ScienceGroup.test.tsx b/app/components/ScienceGroup.test.tsx new file mode 100644 index 0000000..767e74c --- /dev/null +++ b/app/components/ScienceGroup.test.tsx @@ -0,0 +1,18 @@ +import { render } from "@testing-library/react"; +import ScienceGroup from "@/app/components/ScienceGroup"; + +it("renders sciencegroup unchanged", () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); +}); diff --git a/app/components/ScienceGroup.tsx b/app/components/ScienceGroup.tsx new file mode 100644 index 0000000..0a0501e --- /dev/null +++ b/app/components/ScienceGroup.tsx @@ -0,0 +1,33 @@ +import { IfcInstrumentStatus } from "@/app/types"; +import InstrumentWallCard from "@/app/components/InstrumentWallCard"; + +export default function ScienceGroup({ + name, + instruments, +}: { + name: string; + instruments: Array; +}) { + return ( +
+

+ {name} +

+
+ {instruments + .sort((a, b) => a.name.localeCompare(b.name)) + .map((instrument: IfcInstrumentStatus) => { + return ( + + ); + })} +
+
+ ); +} diff --git a/app/wall/components/ShowHideBeamInfo.tsx b/app/components/ShowHideBeamInfo.tsx similarity index 98% rename from app/wall/components/ShowHideBeamInfo.tsx rename to app/components/ShowHideBeamInfo.tsx index c728de2..4dd5602 100644 --- a/app/wall/components/ShowHideBeamInfo.tsx +++ b/app/components/ShowHideBeamInfo.tsx @@ -1,3 +1,4 @@ +"use client"; import Image from "next/image"; import { useEffect, useState } from "react"; diff --git a/app/components/TargetStation.test.tsx b/app/components/TargetStation.test.tsx new file mode 100644 index 0000000..d6dd3b9 --- /dev/null +++ b/app/components/TargetStation.test.tsx @@ -0,0 +1,18 @@ +import { render } from "@testing-library/react"; +import TargetStation from "@/app/components/TargetStation"; + +it("renders targetstation unchanged", () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); +}); diff --git a/app/wall/components/InstrumentGroup.tsx b/app/components/TargetStation.tsx similarity index 72% rename from app/wall/components/InstrumentGroup.tsx rename to app/components/TargetStation.tsx index b9b5908..c4185ca 100644 --- a/app/wall/components/InstrumentGroup.tsx +++ b/app/components/TargetStation.tsx @@ -2,20 +2,20 @@ import InstrumentWallCard from "./InstrumentWallCard"; import { IfcInstrumentStatus } from "@/app/types"; -export default function InstrumentGroup({ - groupName, - data, +export default function TargetStation({ + name, + instruments, }: { - groupName: string; - data: Array; + name: string; + instruments: Array; }) { return (

- {groupName} + {name}

- {data.map((instrument) => ( + {instruments.map((instrument) => ( ))}
diff --git a/app/components/__snapshots__/Groups.test.tsx.snap b/app/components/__snapshots__/Groups.test.tsx.snap new file mode 100644 index 0000000..7b9f060 --- /dev/null +++ b/app/components/__snapshots__/Groups.test.tsx.snap @@ -0,0 +1,170 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders groups correctly with hidden and non hidden groups 1`] = ` +
+
+
+

+ group1 +

+ + + + + + + + + + + + + + + + + + + +
+ Block + + Value + +
+ + aShownBlock + + + +
+ + 2.344 + + + + + +
+
+
+ + + + + +
+ + aDifferentShownBlock + + + +
+ + 3.14 + + + + + +
+
+
+ + + + + +
+
+
+
+`; diff --git a/app/components/__snapshots__/InstrumentWallCard.test.tsx.snap b/app/components/__snapshots__/InstrumentWallCard.test.tsx.snap new file mode 100644 index 0000000..b883226 --- /dev/null +++ b/app/components/__snapshots__/InstrumentWallCard.test.tsx.snap @@ -0,0 +1,61 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders instrumentwallcard unchanged 1`] = ` + +`; + +exports[`renders instrumentwallcard unchanged when runstate is unknown 1`] = ` + +`; diff --git a/app/components/__snapshots__/ScienceGroup.test.tsx.snap b/app/components/__snapshots__/ScienceGroup.test.tsx.snap new file mode 100644 index 0000000..6b65b49 --- /dev/null +++ b/app/components/__snapshots__/ScienceGroup.test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders sciencegroup unchanged 1`] = ` + +`; diff --git a/app/components/__snapshots__/TargetStation.test.tsx.snap b/app/components/__snapshots__/TargetStation.test.tsx.snap new file mode 100644 index 0000000..c30da19 --- /dev/null +++ b/app/components/__snapshots__/TargetStation.test.tsx.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders targetstation unchanged 1`] = ` + +`; diff --git a/app/components/getRunstateColours.test.ts b/app/components/getRunstateColours.test.ts index 6bad73d..82f1f5c 100644 --- a/app/components/getRunstateColours.test.ts +++ b/app/components/getRunstateColours.test.ts @@ -4,7 +4,7 @@ import { } from "@/app/components/getRunstateColours"; test("getForegroundColor when runstate requires white text returns white text", () => { - const runstate = "WAITING"; + const runstate = "RESUMING"; const result = getForegroundColour(runstate); expect(result).toBe("text-white"); }); diff --git a/app/components/getRunstateColours.ts b/app/components/getRunstateColours.ts index 4b071c7..d5303dc 100644 --- a/app/components/getRunstateColours.ts +++ b/app/components/getRunstateColours.ts @@ -18,12 +18,10 @@ export function getForegroundColour(status: string): string { "PROCESSING", "VETOING", "SETUP", + "WAITING", ]; return blackTextRunstates.includes(status) ? "text-black" : "text-white"; } export function getStatusColour(status: string): string { - if (!statusColourLookup.has(status) || status == "UNKNOWN") { - return "bg-[#F08080]"; - } - return statusColourLookup.get(status)!; + return statusColourLookup.get(status) || "bg-[#F08080]"; } diff --git a/app/instruments/page.tsx b/app/instruments/page.tsx index a08832c..1182cd5 100644 --- a/app/instruments/page.tsx +++ b/app/instruments/page.tsx @@ -1,91 +1,11 @@ -"use client"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { IfcPVWSMessage, IfcPVWSRequest } from "@/app/types"; -import { instListFromBytes } from "@/app/components/dehex_and_decompress"; -import useWebSocket from "react-use-websocket"; -import { instListPV, instListSubscription, socketURL } from "@/app/commonVars"; -import createInstrumentGroupsFromInstlist from "@/app/instruments/utils"; +import InstrumentsDisplay from "@/app/components/InstrumentsDisplay"; export default function Instruments() { - const [instrumentGroups, setInstrumentGroups] = useState< - Map> - >(new Map()); - - const { - sendJsonMessage, - lastJsonMessage, - }: { - sendJsonMessage: (a: IfcPVWSRequest) => void; - lastJsonMessage: IfcPVWSMessage; - } = useWebSocket(socketURL, { - shouldReconnect: (closeEvent) => true, - }); - - useEffect(() => { - // On page load, subscribe to the instrument list as it's required to get each instrument. - sendJsonMessage(instListSubscription); - }, [sendJsonMessage]); - - useEffect(() => { - // Instlist has changed - if (!lastJsonMessage) { - return; - } - - const updatedPV: IfcPVWSMessage = lastJsonMessage; - const updatedPVbytes: string | null | undefined = updatedPV.b64byt; - - if (updatedPV.pv == instListPV && updatedPVbytes != null) { - const newInstrumentGroups = createInstrumentGroupsFromInstlist( - instListFromBytes(updatedPVbytes), - ); - setInstrumentGroups(newInstrumentGroups); - } - }, [lastJsonMessage]); - - if (!instrumentGroups.size) { - return

Loading...

; - } - return ( -
-
-
-
- {Array.from(instrumentGroups.entries()).map(([group, insts]) => { - return ( -
-

- {group} -

-
- {insts.sort().map((instrument: string) => { - return ( - -
-

- {instrument} -

-
- - ); - })} -
-
- ); - })} -
-
-
-
+ +
); } diff --git a/app/instruments/utils.test.tsx b/app/instruments/utils.test.tsx deleted file mode 100644 index c98d023..0000000 --- a/app/instruments/utils.test.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import createInstrumentGroupsFromInstlist from "@/app/instruments/utils"; -import { instList } from "@/app/types"; - -test("createInstrumentGroupsFromInstlist adds an instrument to a group if it has one", () => { - const instName = "ANINST"; - const groups = ["GROUP1"]; - const instList: instList = [ - { - name: instName, - hostName: "blah", - groups: groups, - isScheduled: true, - seci: false, - pvPrefix: "SOME:PREFIX", - }, - ]; - const result = createInstrumentGroupsFromInstlist(instList); - expect(result.get(groups[0])).toEqual([instName]); -}); - -test("createInstrumentGroupsFromInstlist ignores instrument if it has no group", () => { - const instName = "ANINST"; - const groups: Array = []; - const instList: instList = [ - { - name: instName, - hostName: "blah", - groups: groups, - isScheduled: true, - seci: false, - pvPrefix: "SOME:PREFIX", - }, - ]; - const result = createInstrumentGroupsFromInstlist(instList); - expect(result.size).toEqual(0); -}); - -test("createInstrumentGroupsFromInstlist adds to a group if it already exists", () => { - const instName1 = "BOB"; - const instName2 = "ALICE"; - const groups: Array = ["GROUP1"]; - const instList: instList = [ - { - name: instName1, - hostName: "blah", - groups: groups, - isScheduled: true, - seci: false, - pvPrefix: "SOME:PREFIX", - }, - { - name: instName2, - hostName: "blah", - groups: groups, - isScheduled: true, - seci: false, - pvPrefix: "SOME:PREFIX", - }, - ]; - const result = createInstrumentGroupsFromInstlist(instList); - expect(result.get(groups[0])).toEqual([instName1, instName2]); -}); diff --git a/app/instruments/utils.ts b/app/instruments/utils.ts deleted file mode 100644 index c796c93..0000000 --- a/app/instruments/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { instList } from "@/app/types"; - -export default function createInstrumentGroupsFromInstlist( - jsonInstList: instList, -): Map> { - let newInstrumentGroups: Map> = new Map(); - for (let inst of jsonInstList) { - for (let group of inst.groups) { - if (!newInstrumentGroups.has(group)) { - newInstrumentGroups.set(group, []); - } - newInstrumentGroups.get(group)!.push(inst.name); - } - } - return newInstrumentGroups; -} diff --git a/app/page.test.tsx b/app/page.test.tsx new file mode 100644 index 0000000..e9ed649 --- /dev/null +++ b/app/page.test.tsx @@ -0,0 +1,7 @@ +import { render } from "@testing-library/react"; +import Home from "@/app/page"; + +it("renders main page unchanged", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); +}); diff --git a/app/page.tsx b/app/page.tsx index 77ef66f..a050570 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,3 @@ -"use client"; import { Inter } from "next/font/google"; import Link from "next/link"; diff --git a/app/types.ts b/app/types.ts index e952287..2090a45 100644 --- a/app/types.ts +++ b/app/types.ts @@ -82,6 +82,7 @@ export interface IfcInstrumentStatus { name: string; // Name of the instrument runstate?: string; // Runstate runstatePV?: string; // Runstate PV address + scienceGroups?: Array; } // Column[Row[labelPV, valuePV]] diff --git a/app/wall/page.tsx b/app/wall/page.tsx index cd2875d..8735db8 100644 --- a/app/wall/page.tsx +++ b/app/wall/page.tsx @@ -1,152 +1,8 @@ -"use client"; -import { useEffect, useState } from "react"; -import useWebSocket from "react-use-websocket"; -import { instListFromBytes } from "../components/dehex_and_decompress"; -import InstrumentGroup from "./components/InstrumentGroup"; -import ShowHideBeamInfo from "./components/ShowHideBeamInfo"; -import JenkinsJobIframe from "./components/JenkinsJobsIframe"; -import { IfcPVWSMessage, IfcPVWSRequest, targetStation } from "@/app/types"; -import { instListPV, instListSubscription, socketURL } from "@/app/commonVars"; -import { - updateInstrumentRunstate, - updateInstrumentRunstatePV, -} from "@/app/wall/utils"; +import ShowHideBeamInfo from "../components/ShowHideBeamInfo"; +import JenkinsJobIframe from "../components/JenkinsJobsIframe"; +import InstrumentsDisplay from "@/app/components/InstrumentsDisplay"; export default function WallDisplay() { - const runstatePV = "DAE:RUNSTATE_STR"; - - const [data, setData] = useState>([ - { - targetStation: "Target Station 1", - instruments: [ - { name: "ALF" }, - { name: "CRISP" }, - { name: "EMMA-A" }, - { name: "EMU" }, - { name: "ENGINX" }, - { name: "GEM" }, - { - name: "HIFI-CRYOMAG", - }, - { name: "HRPD" }, - { name: "INES" }, - { name: "IRIS" }, - { name: "LOQ" }, - { name: "MAPS" }, - { name: "MARI" }, - { name: "MERLIN" }, - { name: "MUONFE" }, - { name: "MUSR" }, - { name: "OSIRIS" }, - { name: "PEARL" }, - { name: "POLARIS" }, - { name: "RIKENFE" }, - { name: "SANDALS" }, - { name: "SCIDEMO" }, - { name: "SURF" }, - { name: "SXD" }, - { name: "TOSCA" }, - { name: "VESUVIO" }, - ], - }, - { - targetStation: "Target Station 2", - instruments: [ - { name: "CHIPIR" }, - { name: "IMAT" }, - { name: "INTER" }, - { name: "LARMOR" }, - { name: "LET" }, - { name: "NIMROD" }, - { name: "OFFSPEC" }, - { name: "POLREF" }, - { name: "SANS2D" }, - { name: "WISH" }, - { name: "ZOOM" }, - ], - }, - { - targetStation: "Miscellaneous", - instruments: [ - { name: "ARGUS" }, - { name: "CHRONUS" }, - { - name: "CRYOLAB_R80", - }, - { name: "DCLAB" }, - { name: "DEMO" }, - { name: "DETMON" }, - { - name: "ENGINX_SETUP", - }, - { name: "HIFI" }, - { - name: "HRPD_SETUP", - }, - { - name: "IBEXGUITEST", - }, - { - name: "IRIS_SETUP", - }, - { name: "MOTION" }, - { - name: "PEARL_SETUP", - }, - { name: "SELAB" }, - { name: "SOFTMAT" }, - { - name: "WISH_SETUP", - }, - ], - }, - ]); - - const { - sendJsonMessage, - lastJsonMessage, - }: { - sendJsonMessage: (a: IfcPVWSRequest) => void; - lastJsonMessage: IfcPVWSMessage; - } = useWebSocket(socketURL, { - shouldReconnect: (closeEvent) => true, - }); - - useEffect(() => { - // On page load, subscribe to the instrument list as it's required to get each instrument's PV prefix. - sendJsonMessage(instListSubscription); - }, [sendJsonMessage]); - - useEffect(() => { - // This is a PV update, it could be either the instlist or an instrument's runstate that has changed - if (!lastJsonMessage) { - return; - } - - const updatedPV: IfcPVWSMessage = lastJsonMessage; - const updatedPVName: string = updatedPV.pv; - const updatedPVbytes: string | null | undefined = updatedPV.b64byt; - let updatedPVvalue: string | null | undefined = updatedPV.text; - - if (updatedPVName == instListPV && updatedPVbytes != null) { - const instListDict = instListFromBytes(updatedPVbytes); - for (const instrument of instListDict) { - setData((prev) => { - return updateInstrumentRunstatePV( - prev, - instrument, - runstatePV, - sendJsonMessage, - ); - }); - } - } else if (updatedPVvalue) { - setData((prev) => { - return updateInstrumentRunstate(prev, updatedPVName, updatedPVvalue); - }); - } - }, [lastJsonMessage, sendJsonMessage]); - return (
Instrument Status: - - {data.map((targetStation) => { - return ( - - ); - })} +
diff --git a/app/wall/utils.ts b/app/wall/utils.ts index b6baa73..1bc083e 100644 --- a/app/wall/utils.ts +++ b/app/wall/utils.ts @@ -47,6 +47,7 @@ export function updateInstrumentRunstatePV( ); if (foundInstrument) { foundInstrument.runstatePV = instListEntry.pvPrefix + runstatePV; + foundInstrument.scienceGroups = instListEntry.groups; // Subscribe to the instrument's runstate PV sendJsonMessage({ type: PVWSRequestType.subscribe, diff --git a/jest.config.ts b/jest.config.ts index 7577895..b544bb9 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -14,9 +14,11 @@ const config: Config = { collectCoverage: true, collectCoverageFrom: [ "app/**/*.{ts,tsx}", - "!**/*types.ts", - "!**/*commonVars.ts", "!**/*layout.tsx", + "!app/_app.tsx", + "!app/components/JenkinsJobsIframe.tsx", // relies on an external image + "!app/components/ShowHideBeamInfo.tsx", // relies on an external image + "!app/components/InstrumentData.tsx", // relies on websocket ], };