diff --git a/index.html b/index.html index fd0bb43..3bf866f 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,10 @@ - + - + - MAV | BayiQ + BayIQ
diff --git a/package.json b/package.json index cb4c39e..430eff4 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,17 @@ "@tanstack/react-router": "^1.136.18", "@tanstack/react-router-devtools": "^1.136.18", "clsx": "^2.1.1", + "formik": "^2.4.9", + "konva": "^10.0.11", + "rc-slider": "^11.1.9", "react": "^19.2.0", - "react-dom": "^19.2.0" + "react-dom": "^19.2.0", + "react-konva": "^19.2.0", + "react-modal": "^3.16.3", + "react-tabs": "^6.1.0", + "react-use-websocket": "3.0.0", + "sonner": "^2.0.7", + "use-debounce": "^10.0.6" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -29,6 +38,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/react-modal": "^3.16.3", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.22", "eslint": "^9.39.1", diff --git a/public/MAV-Blue.svg b/public/MAV-Blue.svg new file mode 100644 index 0000000..99dc9b2 --- /dev/null +++ b/public/MAV-Blue.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/config/wsconfig.ts b/src/app/config/wsconfig.ts index 74c8421..d6fa33d 100644 --- a/src/app/config/wsconfig.ts +++ b/src/app/config/wsconfig.ts @@ -1,5 +1,5 @@ export const wsConfig = { - infoBar: "ws://100.115.148.59/websocket-infobar", + infoBar: "ws://100.115.125.56/websocket-infobar", }; export type SocketKey = keyof typeof wsConfig; diff --git a/src/app/context/CameraFeedContext.ts b/src/app/context/CameraFeedContext.ts new file mode 100644 index 0000000..0373a14 --- /dev/null +++ b/src/app/context/CameraFeedContext.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; +import type { CameraFeedAction, CameraFeedState } from "../../types/types"; + +type CameraFeedContextType = { + state: CameraFeedState; + // check and refactor + dispatch: (state: CameraFeedAction) => void; +}; + +export const CameraFeedContext = createContext(null); + +export const useCameraFeedContext = () => { + const ctx = useContext(CameraFeedContext); + if (!ctx) throw new Error("useCameraFeedContext must be used inside "); + return ctx; +}; diff --git a/src/app/context/WebSocketContext.ts b/src/app/context/WebSocketContext.ts new file mode 100644 index 0000000..65799c0 --- /dev/null +++ b/src/app/context/WebSocketContext.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import { ReadyState } from "react-use-websocket"; +import type { InfoBarData } from "../../types/types"; + +type InfoSocketState = { + data: InfoBarData | null; + readyState: ReadyState; + sendJson: (msg: unknown) => void; +}; + +export type WebSocketConextValue = { + info: InfoSocketState; +}; + +export const WebsocketContext = createContext(null); + +const useWebSocketContext = () => { + const ctx = useContext(WebsocketContext); + if (!ctx) throw new Error("useWebSocketContext must be used inside "); + return ctx; +}; + +export const useInfoSocket = () => useWebSocketContext().info; diff --git a/src/app/providers/AppProviders.tsx b/src/app/providers/AppProviders.tsx index a07b51a..90672e7 100644 --- a/src/app/providers/AppProviders.tsx +++ b/src/app/providers/AppProviders.tsx @@ -1,7 +1,14 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { queryClient } from "../config/queryClient"; import type { PropsWithChildren } from "react"; +import { QueryProvider } from "./QueryProviders"; +import { WebSocketProvider } from "./WebSocketProvider"; +import { CameraFeedProvider } from "./CameraFeedProvider"; export const AppProviders = ({ children }: PropsWithChildren) => { - return {children}; + return ( + + + {children} + + + ); }; diff --git a/src/app/providers/CameraFeedProvider.tsx b/src/app/providers/CameraFeedProvider.tsx new file mode 100644 index 0000000..8e818e2 --- /dev/null +++ b/src/app/providers/CameraFeedProvider.tsx @@ -0,0 +1,75 @@ +import { useEffect, useReducer, type ReactNode } from "react"; +import { CameraFeedContext } from "../context/CameraFeedContext"; +import { initialState, reducer } from "../reducers/cameraFeedReducer"; +import { useBlackBoard } from "../../hooks/useBlackBoard"; +import type { CameraFeedState } from "../../types/types"; +import { useCameraZoom } from "../../features/cameras/hooks/useCameraZoom"; + +export const CameraFeedProvider = ({ children }: { children: ReactNode }) => { + const { blackboardMutation } = useBlackBoard(); + const { cameraZoomQuery: cameraZoomQueryA } = useCameraZoom("A"); + const { cameraZoomQuery: cameraZoomQueryB } = useCameraZoom("B"); + const { cameraZoomQuery: cameraZoomQueryC } = useCameraZoom("C"); + + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + const fetchBlackBoardData = async () => { + const result = await blackboardMutation.mutateAsync({ + operation: "VIEW", + path: "cameraFeed", + }); + if (!result?.result || typeof result.result === "string") return; + const cameraFeedData: CameraFeedState = result.result; + const recontructedState = { + ...cameraFeedData, + paintedCells: { + A: new Map(cameraFeedData.paintedCells.A), + B: new Map(cameraFeedData.paintedCells.B), + C: new Map(cameraFeedData.paintedCells.C), + }, + }; + dispatch({ type: "SET_CAMERA_FEED_DATA", cameraState: recontructedState }); + }; + fetchBlackBoardData(); + }, []); + + useEffect(() => { + const fetchZoomLevels = async () => { + const [resultA, resultB, resultC] = await Promise.all([ + cameraZoomQueryA.refetch(), + cameraZoomQueryB.refetch(), + cameraZoomQueryC.refetch(), + ]); + + console.log(resultA?.data); + const zoomLevelAnumber = parseFloat(resultA.data?.propPhysCurrent?.value); + const zoomLevelBnumber = parseFloat(resultB.data?.propPhysCurrent?.value); + const zoomLevelCnumber = parseFloat(resultC.data?.propPhysCurrent?.value); + + if (resultA.data) { + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: "A", zoomLevel: zoomLevelAnumber }, + }); + } + + if (resultB.data) { + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: "B", zoomLevel: zoomLevelBnumber }, + }); + } + + if (resultC.data) { + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: "C", zoomLevel: zoomLevelCnumber }, + }); + } + }; + fetchZoomLevels(); + }, []); + + return {children}; +}; diff --git a/src/app/providers/WebSocketProvider.tsx b/src/app/providers/WebSocketProvider.tsx new file mode 100644 index 0000000..42ff194 --- /dev/null +++ b/src/app/providers/WebSocketProvider.tsx @@ -0,0 +1,38 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { WebsocketContext, type WebSocketConextValue } from "../context/WebSocketContext"; +import useWebSocket from "react-use-websocket"; +import { wsConfig } from "../config/wsconfig"; +import type { InfoBarData } from "../../types/types"; + +type WebSocketProviderProps = { + children: ReactNode; +}; + +export const WebSocketProvider = ({ children }: WebSocketProviderProps) => { + const [systemData, setSystemData] = useState(null); + const infoSocket = useWebSocket(wsConfig.infoBar, { share: true, shouldReconnect: () => true }); + + useEffect(() => { + async function parseData() { + if (infoSocket.lastMessage) { + const text = await infoSocket.lastMessage.data.text(); + const data = JSON.parse(text); + setSystemData(data); + } + } + parseData(); + }, [infoSocket.lastMessage]); + + const value = useMemo( + () => ({ + info: { + data: systemData, + readyState: infoSocket.readyState, + sendJson: infoSocket.sendJsonMessage, + }, + }), + [infoSocket.readyState, infoSocket.sendJsonMessage, systemData], + ); + + return {children}; +}; diff --git a/src/app/reducers/cameraFeedReducer.ts b/src/app/reducers/cameraFeedReducer.ts new file mode 100644 index 0000000..898435b --- /dev/null +++ b/src/app/reducers/cameraFeedReducer.ts @@ -0,0 +1,125 @@ +import type { CameraFeedAction, CameraFeedState, PaintedCell } from "../../types/types"; + +export const initialState: CameraFeedState = { + cameraFeedID: "A", + paintedCells: { + A: new Map(), + B: new Map(), + C: new Map(), + }, + regionsByCamera: { + A: [ + { name: "Bay 1", brushColour: "#ff0000" }, + { name: "Bay 2", brushColour: "#00ff00" }, + { name: "Bay 3", brushColour: "#0400ff" }, + { name: "Bay 4", brushColour: "#ffff00" }, + { name: "Bay 5", brushColour: "#fc35db" }, + ], + B: [ + { name: "Bay 1", brushColour: "#ff0000" }, + { name: "Bay 2", brushColour: "#00ff00" }, + { name: "Bay 3", brushColour: "#0400ff" }, + { name: "Bay 4", brushColour: "#ffff00" }, + { name: "Bay 5", brushColour: "#fc35db" }, + ], + C: [ + { name: "Bay 1", brushColour: "#ff0000" }, + { name: "Bay 2", brushColour: "#00ff00" }, + { name: "Bay 3", brushColour: "#0400ff" }, + { name: "Bay 4", brushColour: "#ffff00" }, + { name: "Bay 5", brushColour: "#fc35db" }, + ], + }, + + selectedRegionIndex: 0, + modeByCamera: { + A: "painter", + B: "painter", + C: "painter", + }, + zoomLevel: { + A: 1, + B: 1, + C: 1, + }, +}; + +export function reducer(state: CameraFeedState, action: CameraFeedAction) { + switch (action.type) { + case "SET_CAMERA_FEED": + return { + ...state, + cameraFeedID: action.payload, + }; + case "CHANGE_MODE": + return { + ...state, + modeByCamera: { + ...state.modeByCamera, + [action.payload.cameraFeedID]: action.payload.mode, + }, + }; + case "SET_SELECTED_REGION_INDEX": + return { + ...state, + selectedRegionIndex: action.payload, + }; + case "SET_SELECTED_REGION_COLOUR": + return { + ...state, + regionsByCamera: { + ...state.regionsByCamera, + [action.payload.cameraFeedID]: state.regionsByCamera[action.payload.cameraFeedID].map((region) => + region.name === action.payload.regionName ? { ...region, brushColour: action.payload.newColour } : region, + ), + }, + }; + case "ADD_NEW_REGION": + return { + ...state, + regionsByCamera: { + ...state.regionsByCamera, + [action.payload.cameraFeedID]: [ + ...state.regionsByCamera[action.payload.cameraFeedID], + { name: action.payload.regionName, brushColour: action.payload.brushColour }, + ], + }, + }; + case "REMOVE_REGION": + return { + ...state, + regionsByCamera: { + ...state.regionsByCamera, + [action.payload.cameraFeedID]: state.regionsByCamera[action.payload.cameraFeedID].filter( + (region) => region.name !== action.payload.regionName, + ), + }, + }; + case "RESET_PAINTED_CELLS": + return { + ...state, + paintedCells: { + ...state.paintedCells, + [state.cameraFeedID]: new Map(), + }, + }; + case "SET_CAMERA_FEED_DATA": + return { + ...action.cameraState, + }; + case "RESET_CAMERA_FEED": + return { + ...initialState, + }; + case "SET_ZOOM_LEVEL": + return { + ...state, + zoomLevel: { + ...state.zoomLevel, + [action.payload.cameraFeedID]: action.payload.zoomLevel, + }, + }; + default: + return state; + } +} diff --git a/src/features/cameras/components/CameraGrid.tsx b/src/features/cameras/components/CameraGrid.tsx new file mode 100644 index 0000000..4b1e627 --- /dev/null +++ b/src/features/cameras/components/CameraGrid.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; + +import VideoFeedGridPainter from "./Video/VideoFeedGridPainter"; +import CameraSettings from "./CameraSettings/CameraSettings"; + +import PlatePatch from "./PlatePatch/SightingPatch"; +import ResetAllModal from "./CameraSettings/resetAllModal/ResetAllModal"; + +const CameraGrid = () => { + const [tabIndex, setTabIndex] = useState(0); + const [isResetModalOpen, setIsResetModalOpen] = useState(false); + + return ( + <> +
+
+
+ +
+
+ +
+
+ setIsResetModalOpen(false)} + setIsResetModalOpen={setIsResetModalOpen} + /> +
+ setIsResetModalOpen(false)} /> + + ); +}; + +export default CameraGrid; diff --git a/src/features/cameras/components/CameraSettings/CameraPanel.tsx b/src/features/cameras/components/CameraSettings/CameraPanel.tsx new file mode 100644 index 0000000..d1899bc --- /dev/null +++ b/src/features/cameras/components/CameraSettings/CameraPanel.tsx @@ -0,0 +1,64 @@ +import { Tabs, Tab, TabList, TabPanel } from "react-tabs"; +import { useEffect } from "react"; +import { useCameraFeedContext } from "../../../../app/context/CameraFeedContext"; +import RegionSelector from "./RegionSelector"; +import CameraControls from "./cameraControls/CameraControls"; + +type CameraPanelProps = { + tabIndex: number; + isResetAllModalOpen: boolean; + handleClose: () => void; + setIsResetModalOpen: React.Dispatch>; +}; + +const CameraPanel = ({ tabIndex, isResetAllModalOpen, handleClose, setIsResetModalOpen }: CameraPanelProps) => { + const { state, dispatch } = useCameraFeedContext(); + const cameraFeedID = state.cameraFeedID; + const regions = state.regionsByCamera[cameraFeedID]; + + const selectedRegionIndex = state.selectedRegionIndex; + const mode = state.modeByCamera[cameraFeedID]; + + useEffect(() => { + const mapIndextoCameraId = () => { + switch (tabIndex) { + case 0: + return "A"; + case 1: + return "B"; + case 2: + return "C"; + default: + return "A"; + } + }; + + const cameraId = mapIndextoCameraId(); + dispatch({ type: "SET_CAMERA_FEED", payload: cameraId }); + }, [dispatch, tabIndex]); + + return ( + + + Target Detection + Camera Controls + + + + + + + + + ); +}; + +export default CameraPanel; diff --git a/src/features/cameras/components/CameraSettings/CameraSettings.tsx b/src/features/cameras/components/CameraSettings/CameraSettings.tsx new file mode 100644 index 0000000..55242ba --- /dev/null +++ b/src/features/cameras/components/CameraSettings/CameraSettings.tsx @@ -0,0 +1,62 @@ +import Card from "../../../../ui/Card"; +import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; +import "react-tabs/style/react-tabs.css"; +import CameraPanel from "./CameraPanel"; + +type CameraSettingsProps = { + setTabIndex: (tabIndex: number) => void; + tabIndex: number; + isResetAllModalOpen: boolean; + handleClose: () => void; + setIsResetModalOpen: React.Dispatch>; +}; + +const CameraSettings = ({ + tabIndex, + setTabIndex, + isResetAllModalOpen, + handleClose, + setIsResetModalOpen, +}: CameraSettingsProps) => { + return ( + + setTabIndex(index)} + > + + Camera A + Camera B + Camera C + + + + + + + + + + + + + ); +}; + +export default CameraSettings; diff --git a/src/features/cameras/components/CameraSettings/ColourPicker.tsx b/src/features/cameras/components/CameraSettings/ColourPicker.tsx new file mode 100644 index 0000000..621ae5b --- /dev/null +++ b/src/features/cameras/components/CameraSettings/ColourPicker.tsx @@ -0,0 +1,20 @@ +type ColourPickerProps = { + colour: string; + setColour: (colour: string) => void; +}; + +const ColourPicker = ({ colour, setColour }: ColourPickerProps) => { + return ( + setColour(e.target.value)} + className="h-8 w-8 p-0 rounded-md border border-slate-500 cursor-pointer" + /> + ); +}; + +export default ColourPicker; diff --git a/src/features/cameras/components/CameraSettings/RegionSelector.tsx b/src/features/cameras/components/CameraSettings/RegionSelector.tsx new file mode 100644 index 0000000..f23722d --- /dev/null +++ b/src/features/cameras/components/CameraSettings/RegionSelector.tsx @@ -0,0 +1,278 @@ +import type { ColourData, PaintedCell, Region } from "../../../../types/types"; +import ColourPicker from "./ColourPicker"; +import { useCameraFeedContext } from "../../../../app/context/CameraFeedContext"; +import { useColourDectection } from "../../hooks/useColourDetection"; +import { useBlackBoard } from "../../../../hooks/useBlackBoard"; +import { toast } from "sonner"; + +type RegionSelectorProps = { + regions: Region[]; + selectedRegionIndex: number; + mode: string; + cameraFeedID: "A" | "B" | "C"; + isResetAllModalOpen: boolean; + handleClose: () => void; + setIsResetModalOpen: React.Dispatch>; +}; + +const RegionSelector = ({ + regions, + selectedRegionIndex, + mode, + cameraFeedID, + isResetAllModalOpen, + + setIsResetModalOpen, +}: RegionSelectorProps) => { + const { colourMutation } = useColourDectection(); + const { state, dispatch } = useCameraFeedContext(); + const { blackboardMutation } = useBlackBoard(); + const paintedCells = state.paintedCells[cameraFeedID]; + + const handleChange = (e: { target: { value: string } }) => { + dispatch({ type: "CHANGE_MODE", payload: { cameraFeedID: cameraFeedID, mode: e.target.value } }); + }; + + const handleResetRegion = () => { + dispatch({ + type: "RESET_PAINTED_CELLS", + payload: { cameraFeedID: cameraFeedID, paintedCells: new Map() }, + }); + }; + + const handleModeChange = (newMode: string) => { + dispatch({ type: "CHANGE_MODE", payload: { cameraFeedID: cameraFeedID, mode: newMode } }); + }; + + const handleRegionSelect = (index: number) => { + dispatch({ type: "SET_SELECTED_REGION_INDEX", payload: index }); + }; + + const handleRegionColourChange = (index: number, newColour: string) => { + const regionName = regions[index].name; + + dispatch({ + type: "SET_SELECTED_REGION_COLOUR", + payload: { cameraFeedID: cameraFeedID, regionName: regionName, newColour: newColour }, + }); + }; + + const handleAddRegionClick = () => { + const regionName = `Bay ${regions.length + 1}`; + dispatch({ + type: "ADD_NEW_REGION", + payload: { cameraFeedID: cameraFeedID, regionName: regionName, brushColour: "#ffffff" }, + }); + }; + + const handleRemoveClick = () => { + dispatch({ + type: "REMOVE_REGION", + payload: { cameraFeedID: cameraFeedID, regionName: regions[selectedRegionIndex].name }, + }); + }; + + const openResetModal = () => { + if (isResetAllModalOpen) return; + setIsResetModalOpen(true); + }; + + const handleSaveclick = () => { + const regions: ColourData[] = []; + const test = Array.from(paintedCells.entries()); + const region1 = test.filter(([, cell]) => cell.region.name === "Bay 1"); + const region2 = test.filter(([, cell]) => cell.region.name === "Bay 2"); + const region3 = test.filter(([, cell]) => cell.region.name === "Bay 3"); + const region4 = test.filter(([, cell]) => cell.region.name === "Bay 4"); + const region5 = test.filter(([, cell]) => cell.region.name === "Bay 5"); + const region1Data = { + id: 1, + cells: region1.map(([key]) => [parseInt(key.split("-")[1]), parseInt(key.split("-")[0])]), + }; + const region2Data = { + id: 2, + cells: region2.map(([key]) => [parseInt(key.split("-")[1]), parseInt(key.split("-")[0])]), + }; + const region3Data = { + id: 3, + cells: region3.map(([key]) => [parseInt(key.split("-")[1]), parseInt(key.split("-")[0])]), + }; + const region4Data = { + id: 4, + cells: region4.map(([key]) => [parseInt(key.split("-")[1]), parseInt(key.split("-")[0])]), + }; + const region5Data = { + id: 5, + cells: region5.map(([key]) => [parseInt(key.split("-")[1]), parseInt(key.split("-")[0])]), + }; + if (region1Data.cells.length > 0) { + regions.push(region1Data); + } + if (region2Data.cells.length > 0) { + regions.push(region2Data); + } + if (region3Data.cells.length > 0) { + regions.push(region3Data); + } + if (region4Data.cells.length > 0) { + regions.push(region4Data); + } + if (region5Data.cells.length > 0) { + regions.push(region5Data); + } + + colourMutation.mutate({ cameraFeedID, regions: regions }); + + // Convert Map to plain object for blackboard + const serializableState = { + ...state, + paintedCells: { + A: Array.from(state.paintedCells.A.entries()), + B: Array.from(state.paintedCells.B.entries()), + C: Array.from(state.paintedCells.C.entries()), + }, + }; + blackboardMutation.mutate({ operation: "INSERT", path: `cameraFeed`, value: serializableState }); + toast.success("Region data saved successfully!"); + }; + + return ( +
+
+
+

Tools

+
+ + + +
+
+ +
+

Bay Select

+ <> + {regions?.map((region, idx) => { + const isSelected = selectedRegionIndex === idx; + const inputId = `region-${idx}`; + return ( + + ); + })} + +
+ + +
+
+
+ +
+

Actions

+
+
+ + +
+ + +
+
+
+ ); +}; + +export default RegionSelector; diff --git a/src/features/cameras/components/CameraSettings/cameraControls/CameraControls.tsx b/src/features/cameras/components/CameraSettings/cameraControls/CameraControls.tsx new file mode 100644 index 0000000..dcfb408 --- /dev/null +++ b/src/features/cameras/components/CameraSettings/cameraControls/CameraControls.tsx @@ -0,0 +1,42 @@ +import { useCameraFeedContext } from "../../../../../app/context/CameraFeedContext"; +import SliderComponent from "../../../../../ui/SliderComponent"; +import { useCameraZoom } from "../../../hooks/useCameraZoom"; +import { useDebouncedCallback } from "use-debounce"; + +type CameraControlsProps = { + cameraFeedID: "A" | "B" | "C"; +}; + +const CameraControls = ({ cameraFeedID }: CameraControlsProps) => { + const { state, dispatch } = useCameraFeedContext(); + const { cameraZoomMutation } = useCameraZoom(cameraFeedID); + + const zoomLevel = state.zoomLevel ? state.zoomLevel[cameraFeedID] : 1; + const debouncedMutation = useDebouncedCallback(async (value) => { + await cameraZoomMutation.mutateAsync({ + cameraFeedID, + zoomLevel: value as number, + }); + }, 1000); + + const handleChange = (value: number | number[]) => { + const newZoom = value as number; + dispatch({ + type: "SET_ZOOM_LEVEL", + payload: { cameraFeedID: cameraFeedID, zoomLevel: value as number }, + }); + debouncedMutation(newZoom); + }; + + return ( +
+

Camera {cameraFeedID}

+
+ + +
+
+ ); +}; + +export default CameraControls; diff --git a/src/features/cameras/components/CameraSettings/resetAllModal/ResetAllModal.tsx b/src/features/cameras/components/CameraSettings/resetAllModal/ResetAllModal.tsx new file mode 100644 index 0000000..2966ab1 --- /dev/null +++ b/src/features/cameras/components/CameraSettings/resetAllModal/ResetAllModal.tsx @@ -0,0 +1,55 @@ +import { toast } from "sonner"; +import { useCameraFeedContext } from "../../../../../app/context/CameraFeedContext"; +import { useBlackBoard } from "../../../../../hooks/useBlackBoard"; +import ModalComponent from "../../../../../ui/ModalComponent"; + +type ResetAllModalProps = { + isResetAllModalOpen: boolean; + handleClose: () => void; +}; + +const ResetAllModal = ({ isResetAllModalOpen, handleClose }: ResetAllModalProps) => { + const { state, dispatch } = useCameraFeedContext(); + const { blackboardMutation } = useBlackBoard(); + + const handleResetAll = async () => { + dispatch({ type: "RESET_CAMERA_FEED" }); + + handleClose(); + const result = await blackboardMutation.mutateAsync({ + operation: "INSERT", + path: `cameraFeed`, + value: state, + }); + // Need endpoint to reset all target detection painted cells + if (result?.reason === "OK") { + toast.success("All camera settings have been reset to default values."); + } + }; + return ( + +
+

Reset All Camera Settings

+

+ Are you sure you want to reset all camera settings to their default values? This action cannot be undone. +

+
+ + +
+
+
+ ); +}; + +export default ResetAllModal; diff --git a/src/features/cameras/components/PlatePatch/SightingEntryTable.tsx b/src/features/cameras/components/PlatePatch/SightingEntryTable.tsx new file mode 100644 index 0000000..b85e795 --- /dev/null +++ b/src/features/cameras/components/PlatePatch/SightingEntryTable.tsx @@ -0,0 +1,74 @@ +import { useCameraFeedContext } from "../../../../app/context/CameraFeedContext"; +import type { DecodeReading } from "../../../../types/types"; +import { useSightingEntryAndExit } from "../../hooks/useSightingEntryAndExit"; + +const SightingEntryTable = () => { + const { state } = useCameraFeedContext(); + const cameraFeedID = state.cameraFeedID; + const { entryQuery } = useSightingEntryAndExit(cameraFeedID); + + const isLoading = entryQuery?.isFetching; + const readings = entryQuery?.data?.decodes; + + if (isLoading) return Loading Sighting data…; + return ( +
+ {/* Desktop Table */} +
+ + + + + + + + + + + + {readings?.map((reading: DecodeReading) => ( + + + + + + + + ))} + +
VRMBay IDSeen CountFirst SeenLast Seen
{reading?.vrm}{reading?.laneID}{reading?.seenCount}{reading?.firstSeenTimeHumane}{reading?.lastSeenTimeHumane}
+
+ + {/* Mobile Cards */} +
+ {readings?.map((reading: DecodeReading) => ( +
+
+ {reading?.vrm} + Bay {reading?.laneID} +
+
+ Seen Count: + {reading?.seenCount} +
+
+
+ First Seen: + {reading?.firstSeenTimeHumane} +
+
+ Last Seen: + {reading?.lastSeenTimeHumane} +
+
+
+ ))} +
+
+ ); +}; + +export default SightingEntryTable; diff --git a/src/features/cameras/components/PlatePatch/SightingExitTable.tsx b/src/features/cameras/components/PlatePatch/SightingExitTable.tsx new file mode 100644 index 0000000..3ba01f9 --- /dev/null +++ b/src/features/cameras/components/PlatePatch/SightingExitTable.tsx @@ -0,0 +1,74 @@ +import { useCameraFeedContext } from "../../../../app/context/CameraFeedContext"; +import type { DecodeReading } from "../../../../types/types"; +import { useSightingEntryAndExit } from "../../hooks/useSightingEntryAndExit"; + +const SightingExitTable = () => { + const { state } = useCameraFeedContext(); + const cameraFeedID = state.cameraFeedID; + const { exitQuery } = useSightingEntryAndExit(cameraFeedID); + + const isLoading = exitQuery?.isFetching; + const readings = exitQuery?.data?.decodes; + + if (isLoading) return Loading Sighting data…; + return ( +
+ {/* Desktop Table */} +
+ + + + + + + + + + + + {readings?.map((reading: DecodeReading) => ( + + + + + + + + ))} + +
VRMBay IDSeen CountFirst SeenLast Seen
{reading?.vrm}{reading?.laneID}{reading?.seenCount}{reading?.firstSeenTimeHumane}{reading?.lastSeenTimeHumane}
+
+ + {/* Mobile Cards */} +
+ {readings?.map((reading: DecodeReading) => ( +
+
+ {reading?.vrm} + Bay {reading?.laneID} +
+
+ Seen Count: + {reading?.seenCount} +
+
+
+ First Seen: + {reading?.firstSeenTimeHumane} +
+
+ Last Seen: + {reading?.lastSeenTimeHumane} +
+
+
+ ))} +
+
+ ); +}; + +export default SightingExitTable; diff --git a/src/features/cameras/components/PlatePatch/SightingPatch.tsx b/src/features/cameras/components/PlatePatch/SightingPatch.tsx new file mode 100644 index 0000000..d598eb8 --- /dev/null +++ b/src/features/cameras/components/PlatePatch/SightingPatch.tsx @@ -0,0 +1,27 @@ +import { Tab, Tabs, TabList, TabPanel } from "react-tabs"; +import Card from "../../../../ui/Card"; +import CardHeader from "../../../../ui/CardHeader"; +import SightingEntryTable from "./SightingEntryTable"; +import SightingExitTable from "./SightingExitTable"; + +const PlatePatch = () => { + return ( + + + + + Entry Sightings + Exit Sightings + + + + + + + + + + ); +}; + +export default PlatePatch; diff --git a/src/features/cameras/components/Video/VideoFeedGridPainter.tsx b/src/features/cameras/components/Video/VideoFeedGridPainter.tsx new file mode 100644 index 0000000..31d812e --- /dev/null +++ b/src/features/cameras/components/Video/VideoFeedGridPainter.tsx @@ -0,0 +1,208 @@ +import { useEffect, useRef, useState, type RefObject } from "react"; +import { Stage, Layer, Image, Shape } from "react-konva"; +import type { KonvaEventObject } from "konva/lib/Node"; +import { useCreateVideoSnapshot } from "../../hooks/useGetvideoSnapshots"; + +import { useCameraFeedContext } from "../../../../app/context/CameraFeedContext"; + +const BACKEND_WIDTH = 640; +const BACKEND_HEIGHT = 360; +const BACKEND_CELL_SIZE = 16; + +const rows = 22.5; +const cols = 40; +const gap = 0; + +const VideoFeedGridPainter = () => { + const { state } = useCameraFeedContext(); + const cameraFeedID = state.cameraFeedID; + const paintedCells = state?.paintedCells?.[cameraFeedID]; + const regions = state.regionsByCamera[cameraFeedID]; + const selectedRegionIndex = state.selectedRegionIndex; + const mode = state.modeByCamera[cameraFeedID]; + const { latestBitmapRef, isloading } = useCreateVideoSnapshot(); + const [stageSize, setStageSize] = useState({ width: BACKEND_WIDTH, height: BACKEND_HEIGHT }); + const isDrawingRef = useRef(false); + const [scale, setScale] = useState(1); + const [position, setPosition] = useState({ x: 0, y: 0 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stageRef = useRef(null); + + const currentScale = stageSize.width / BACKEND_WIDTH; + const size = BACKEND_CELL_SIZE * currentScale; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const paintLayerRef = useRef(null); + + const draw = (bmp: RefObject): ImageBitmap | null => { + if (!bmp || !bmp.current) { + return null; + } + const image = bmp.current; + return image; + }; + + const image = draw(latestBitmapRef); + + const paintCell = (x: number, y: number) => { + const col = Math.floor(x / (size + gap)); + const row = Math.floor(y / (size + gap)); + + if (row < 0 || row >= rows || col < 0 || col >= cols) return; + + const activeRegion = regions[selectedRegionIndex]; + if (!activeRegion) return; + + const key = `${row}-${col}`; + const currentColour = regions[selectedRegionIndex].brushColour; + + const map = paintedCells; + const existing = map.get(key); + + if (mode === "eraser") { + if (map.has(key)) { + map.delete(key); + paintLayerRef.current?.batchDraw(); + } + return; + } + + if (existing && existing.colour === currentColour) return; + + map.set(key, { colour: currentColour, region: activeRegion }); + + paintLayerRef.current?.batchDraw(); + }; + + const handleStageMouseDown = (e: KonvaEventObject) => { + if (!regions[selectedRegionIndex] || mode === "zoom") return; + isDrawingRef.current = true; + const pos = e.target.getStage()?.getPointerPosition(); + if (pos) paintCell(pos.x, pos.y); + }; + + const handleStageMouseMove = (e: KonvaEventObject) => { + if (!isDrawingRef.current || mode === "zoom") return; + if (!regions[selectedRegionIndex]) return; + const pos = e.target.getStage()?.getPointerPosition(); + if (pos) paintCell(pos.x, pos.y); + }; + + const handleStageMouseUp = () => { + isDrawingRef.current = false; + }; + + const handleMouseEnter = () => { + if (mode !== "zoom") return; + setScale(2); + }; + const handleMouseLeave = () => { + document.body.style.cursor = "default"; + setScale(1); + setPosition({ x: 0, y: 0 }); + }; + const handleMouseMove = (e: KonvaEventObject) => { + if (scale === 1) return; + + const stage = e.target.getStage(); + if (!stage) return; + + const pointerPosition = stage.getPointerPosition(); + if (!pointerPosition) return; + + const newX = stageSize.width / 2 - pointerPosition.x * scale; + const newY = stageSize.height / 2 - pointerPosition.y * scale; + + const maxX = 0; + const minX = stageSize.width - stageSize.width * scale; + const maxY = 0; + const minY = stageSize.height - stageSize.height * scale; + + setPosition({ + x: Math.max(minX, Math.min(maxX, newX)), + y: Math.max(minY, Math.min(maxY, newY)), + }); + }; + + useEffect(() => { + const handleResize = () => { + const width = window.innerWidth; + + const aspectRatio = BACKEND_WIDTH / BACKEND_HEIGHT; + if (width < 768) { + const newWidth = width * 0.8; + const newHeight = newWidth / aspectRatio; + setStageSize({ width: newWidth, height: newHeight }); + } else { + const newWidth = width * 0.6; + const newHeight = newWidth / aspectRatio; + setStageSize({ width: newWidth, height: newHeight }); + } + }; + + handleResize(); + window.addEventListener("resize", handleResize); + + return () => window.removeEventListener("resize", handleResize); + }, []); + + if (image === null || isloading) return Loading Video feed…; + return ( +
+ + + + + + + {mode === "painter" || mode === "eraser" ? ( + { + const cells = paintedCells; + if (!cells || cells.size === 0 || !paintLayerRef.current) return; + cells?.forEach((cell, key) => { + const [rowStr, colStr] = key.split("-"); + const row = Number(rowStr); + const col = Number(colStr); + + const x = col * (size + gap); + const y = row * (size + gap); + + ctx.beginPath(); + ctx.rect(x, y, size, size); + ctx.fillStyle = cell.colour; + ctx.fill(); + }); + + ctx.fillStrokeShape(shape); + }} + width={stageSize.width} + height={stageSize.height} + /> + ) : null} + + +
+ ); +}; + +export default VideoFeedGridPainter; diff --git a/src/features/cameras/hooks/useCameraZoom.ts b/src/features/cameras/hooks/useCameraZoom.ts new file mode 100644 index 0000000..9235f8e --- /dev/null +++ b/src/features/cameras/hooks/useCameraZoom.ts @@ -0,0 +1,48 @@ +import { useQuery, useMutation } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; +import type { CameraZoomConfig } from "../../../types/types"; + +const fetchZoomLevel = async (cameraFeedID: string) => { + const response = await fetch(`${CAMBASE}/api/fetch-config?id=Camera${cameraFeedID}-onvif-controller`); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); +}; + +const postZoomLevel = async (zoomConfig: CameraZoomConfig) => { + const fields = [ + { property: "propPhysCurrent", value: zoomConfig.zoomLevel }, + { property: "propCameraHost", value: "192.168.0.101" }, + { property: "propCameraPort", value: 80 }, + { property: "propCameraUsername", value: "administrator" }, + { property: "propCameraPassword", value: "MAV12345" }, + ]; + const zoomPayload = { + id: `Camera${zoomConfig.cameraFeedID}-onvif-controller`, + fields, + }; + console.log(zoomPayload); + const response = await fetch(`${CAMBASE}/api/update-config`, { + method: "POST", + body: JSON.stringify(zoomPayload), + }); + + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); +}; + +export const useCameraZoom = (cameraFeedID: "A" | "B" | "C") => { + const cameraZoomQuery = useQuery({ + queryKey: ["cameraZoom", cameraFeedID], + queryFn: () => fetchZoomLevel(cameraFeedID), + }); + + const cameraZoomMutation = useMutation({ + mutationKey: ["postCameraZoom"], + mutationFn: (zoomConfig: CameraZoomConfig) => postZoomLevel(zoomConfig), + }); + return { cameraZoomQuery, cameraZoomMutation }; +}; diff --git a/src/features/cameras/hooks/useColourDetection.ts b/src/features/cameras/hooks/useColourDetection.ts new file mode 100644 index 0000000..bed6311 --- /dev/null +++ b/src/features/cameras/hooks/useColourDetection.ts @@ -0,0 +1,24 @@ +import { useMutation } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; +import type { ColourDetectionPayload } from "../../../types/types"; + +const sendColourDetectionData = async (colourData: ColourDetectionPayload) => { + const regions = { + regions: colourData.regions, + }; + const response = await fetch(`${CAMBASE}/TargetDetectionColour${colourData.cameraFeedID}-region-update`, { + method: "POST", + body: JSON.stringify(regions), + }); + if (!response.ok) throw new Error("Cannot send data to colour detection endpoint"); + return response.json(); +}; + +export const useColourDectection = () => { + const colourMutation = useMutation({ + mutationKey: ["colour detection"], + mutationFn: (colourData: ColourDetectionPayload) => sendColourDetectionData(colourData), + }); + + return { colourMutation }; +}; diff --git a/src/features/cameras/hooks/useGetVideoFeed.ts b/src/features/cameras/hooks/useGetVideoFeed.ts new file mode 100644 index 0000000..44b2796 --- /dev/null +++ b/src/features/cameras/hooks/useGetVideoFeed.ts @@ -0,0 +1,42 @@ +import { useQuery } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; + +const targetDectionFeed = async (cameraFeedID: "A" | "B" | "C" | null) => { + const response = await fetch(`${CAMBASE}/TargetDetectionColour${cameraFeedID}-preview`, { + signal: AbortSignal.timeout(300000), + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`Cannot reach endpoint (${response.status})`); + } + return response.blob(); +}; + +const getVideoFeed = async (cameraFeedID: "A" | "B" | "C" | null) => { + const response = await fetch(`${CAMBASE}/Camera${cameraFeedID}-preview`, { + signal: AbortSignal.timeout(300000), + cache: "no-store", + }); + if (!response.ok) { + throw new Error(`Cannot reach endpoint (${response.status})`); + } + return response.blob(); +}; + +export const useGetVideoFeed = (cameraFeedID: "A" | "B" | "C" | null, mode: string) => { + const targetDetectionQuery = useQuery({ + queryKey: ["getfeed", cameraFeedID], + queryFn: () => targetDectionFeed(cameraFeedID), + refetchInterval: 500, + enabled: mode !== "zoom", + }); + + const videoFeedQuery = useQuery({ + queryKey: ["videoQuery", cameraFeedID, mode], + queryFn: () => getVideoFeed(cameraFeedID), + refetchInterval: 500, + enabled: mode === "zoom", + }); + + return { targetDetectionQuery, videoFeedQuery }; +}; diff --git a/src/features/cameras/hooks/useGetvideoSnapshots.ts b/src/features/cameras/hooks/useGetvideoSnapshots.ts new file mode 100644 index 0000000..da70292 --- /dev/null +++ b/src/features/cameras/hooks/useGetvideoSnapshots.ts @@ -0,0 +1,38 @@ +import { useEffect, useRef } from "react"; +import { useGetVideoFeed } from "./useGetVideoFeed"; +import { useCameraFeedContext } from "../../../app/context/CameraFeedContext"; + +export const useCreateVideoSnapshot = () => { + const { state } = useCameraFeedContext(); + const cameraFeedID = state?.cameraFeedID; + const mode = state.modeByCamera[cameraFeedID]; + const latestBitmapRef = useRef(null); + const { targetDetectionQuery, videoFeedQuery } = useGetVideoFeed(cameraFeedID, mode); + + let snapShot = targetDetectionQuery?.data; + const isloading = targetDetectionQuery.isPending; + + const videoSnapShot = videoFeedQuery?.data; + const isVideoLoading = videoFeedQuery.isPending; + + if (isVideoLoading === false && videoSnapShot && mode === "zoom") { + snapShot = videoSnapShot; + } + + useEffect(() => { + async function createBitmap() { + if (!snapShot) return; + + try { + const bitmap = await createImageBitmap(snapShot); + if (!bitmap) return; + latestBitmapRef.current = bitmap; + } catch (error) { + console.log(error); + } + } + createBitmap(); + }, [snapShot]); + + return { latestBitmapRef, isloading }; +}; diff --git a/src/features/cameras/hooks/useSightingEntryAndExit.ts b/src/features/cameras/hooks/useSightingEntryAndExit.ts new file mode 100644 index 0000000..88860b3 --- /dev/null +++ b/src/features/cameras/hooks/useSightingEntryAndExit.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; + +const fetchEntrySightings = async (cameraFeedID: string) => { + const response = await fetch(`${CAMBASE}/EntrySightingCreator${cameraFeedID}-list-proto-sightings`); + if (!response.ok) throw new Error("Cannot reach sighing entry endpoint"); + return response.json(); +}; + +const fetchExitSightings = async (cameraFeedID: string) => { + const response = await fetch(`${CAMBASE}/ExitSightingCreator${cameraFeedID}-list-proto-sightings`); + if (!response.ok) throw new Error("Cannot reach sighing exit endpoint"); + return response.json(); +}; + +export const useSightingEntryAndExit = (cameraFeedID: string) => { + const entryQuery = useQuery({ + queryKey: ["Entry Sightings", cameraFeedID], + queryFn: () => fetchEntrySightings(cameraFeedID), + }); + + const exitQuery = useQuery({ + queryKey: ["Exit Sightings", cameraFeedID], + queryFn: () => fetchExitSightings(cameraFeedID), + }); + + return { entryQuery, exitQuery }; +}; diff --git a/src/features/dashboard/components/CameraStatus.tsx b/src/features/dashboard/components/CameraStatus.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/dashboard/components/DashboardGrid.tsx b/src/features/dashboard/components/DashboardGrid.tsx index 8cf481c..c5c5635 100644 --- a/src/features/dashboard/components/DashboardGrid.tsx +++ b/src/features/dashboard/components/DashboardGrid.tsx @@ -1,9 +1,56 @@ -import SystemStatusCard from "./SystemStatusCard"; +import type { SystemHealthStatus } from "../../../types/types"; +import { useGetSystemHealth } from "../hooks/useGetSystemHealth"; +import CameraStatus from "./cameraStatus/CameraStatus"; +import SystemHealthCard from "./systemHealth/SystemHealthCard"; +import SystemStatusCard from "./systemStatus/SystemStatusCard"; const DashboardGrid = () => { + const { query } = useGetSystemHealth(); + const startTime = query?.data?.StartTimeHumane; + const uptime = query?.data?.UptimeHumane; + const statuses: SystemHealthStatus[] = query?.data?.Status; + const isLoading = query?.isLoading; + const isError = query?.isError; + const dateUpdatedAt = query?.dataUpdatedAt; + const refetch = query?.refetch; + + const statusCategories = statuses?.reduce>( + (acc, cur) => { + if (cur?.groupID === "ChannelA") acc?.channelA?.push(cur); + if (cur?.groupID === "ChannelB") acc?.channelB?.push(cur); + if (cur?.groupID === "ChannelC") acc?.channelC?.push(cur); + if (cur?.groupID === "Default") acc?.default?.push(cur); + return acc; + }, + { + channelA: [], + channelB: [], + channelC: [], + default: [], + }, + ); + + const categoryA = statusCategories?.channelA ?? []; + const categoryB = statusCategories?.channelB ?? []; + const categoryC = statusCategories?.channelC ?? []; + return ( -
+
+ +
+ + + +
); }; diff --git a/src/features/dashboard/components/SystemStatusCard.tsx b/src/features/dashboard/components/SystemStatusCard.tsx deleted file mode 100644 index b887e7e..0000000 --- a/src/features/dashboard/components/SystemStatusCard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useInfoSocket } from "../../../hooks/useInfoWebSocket"; -import Card from "../../../ui/Card"; -import CardHeader from "../../../ui/CardHeader"; - -const SystemStatusCard = () => { - const { stats } = useInfoSocket(); - - return ( - - - {stats ? ( - <> -
UTC: {stats["system-clock-utc"]}
- Local: {stats["system-clock-local"]} - CPU: {stats["memory-cpu-status"]} - Threads: {stats["thread-count"]} - - ) : ( - Loading system status… - )} -
-
- ); -}; - -export default SystemStatusCard; diff --git a/src/features/dashboard/components/cameraStatus/CameraStatus.tsx b/src/features/dashboard/components/cameraStatus/CameraStatus.tsx new file mode 100644 index 0000000..bfbdfb5 --- /dev/null +++ b/src/features/dashboard/components/cameraStatus/CameraStatus.tsx @@ -0,0 +1,50 @@ +import type { SystemHealthStatus } from "../../../../types/types"; +import Card from "../../../../ui/Card"; +import StatusIndicators from "../../../../ui/StatusIndicators"; +import { capitalize } from "../../../../utils/utils"; +import CameraStatusGridItem from "./CameraStatusGridItem"; + +type CameraStatusProps = { + title: string; + category: SystemHealthStatus[]; + isError?: boolean; +}; + +const CameraStatus = ({ title, category, isError }: CameraStatusProps) => { + const isAllGood = category && category.length > 0 && category.every((status) => status.tags.includes("RUNNING")); + // check if some are down + // check if all are down + //check if offline + return ( + +
+

+ {isError ? ( + + ) : isAllGood ? ( + + ) : ( + + )} + {capitalize(title)} +

+ {isError ? ( +

Error loading camera health.

+ ) : isAllGood ? ( +

All systems running

+ ) : ( +

Some systems down

+ )} +
+ {category && category?.length <= 0 ? ( +

Loading Camera health...

+ ) : ( +
+ +
+ )} +
+ ); +}; + +export default CameraStatus; diff --git a/src/features/dashboard/components/cameraStatus/CameraStatusGridItem.tsx b/src/features/dashboard/components/cameraStatus/CameraStatusGridItem.tsx new file mode 100644 index 0000000..a75ae3d --- /dev/null +++ b/src/features/dashboard/components/cameraStatus/CameraStatusGridItem.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import type { SystemHealthStatus } from "../../../../types/types"; +import { capitalize } from "../../../../utils/utils"; +import SystemHealthModal from "../systemHealth/systemHealthModal/SystemHealthModal"; + +type CameraStatusGridItemProps = { + title: string; + statusCategory: SystemHealthStatus[]; +}; + +const CameraStatusGridItem = ({ title, statusCategory }: CameraStatusGridItemProps) => { + const [isOpen, setIsOpen] = useState(false); + const isAllGood = statusCategory?.every((status) => status.tags.includes("RUNNING")); + + const handleClick = () => { + setIsOpen(false); + }; + return ( + <> +
setIsOpen(true)} + > +

{capitalize(title)}

+

{isAllGood ? "Click to view module status" : "Some systems down"}

+
+ + + ); +}; + +export default CameraStatusGridItem; diff --git a/src/features/dashboard/components/statusGridItem/StatusGridItem.tsx b/src/features/dashboard/components/statusGridItem/StatusGridItem.tsx new file mode 100644 index 0000000..9b3b833 --- /dev/null +++ b/src/features/dashboard/components/statusGridItem/StatusGridItem.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import type { SystemHealthStatus } from "../../../../types/types"; +import StatusIndicators from "../../../../ui/StatusIndicators"; +import { capitalize } from "../../../../utils/utils"; +import SystemHealthModal from "../systemHealth/systemHealthModal/SystemHealthModal"; + +type StatusGridItemProps = { + title: string; + statusCategory: SystemHealthStatus[]; +}; + +const StatusGridItem = ({ title, statusCategory }: StatusGridItemProps) => { + const [isOpen, setIsOpen] = useState(false); + const isAllGood = + statusCategory && statusCategory.length > 0 && statusCategory.every((status) => status.tags.includes("RUNNING")); + + const handleClick = () => { + setIsOpen(false); + }; + + return ( + <> +
setIsOpen(true)} + > +

+ {isAllGood ? : } + {capitalize(title)} +

+

{isAllGood ? "All systems running" : "Some systems down"}

+
+ + + ); +}; + +export default StatusGridItem; diff --git a/src/features/dashboard/components/systemHealth/SystemHealth.tsx b/src/features/dashboard/components/systemHealth/SystemHealth.tsx new file mode 100644 index 0000000..37f62db --- /dev/null +++ b/src/features/dashboard/components/systemHealth/SystemHealth.tsx @@ -0,0 +1,60 @@ +import type { SystemHealthStatus } from "../../../../types/types"; +import StatusGridItem from "../statusGridItem/StatusGridItem"; + +type SystemHealthProps = { + startTime: string; + uptime: string; + statuses: SystemHealthStatus[]; + isLoading: boolean; + isError: boolean; + dateUpdatedAt?: number; +}; + +const SystemHealth = ({ startTime, uptime, statuses, isLoading, isError, dateUpdatedAt }: SystemHealthProps) => { + const updatedDate = dateUpdatedAt ? new Date(dateUpdatedAt).toLocaleString() : null; + + const statusCategories = statuses?.reduce>( + (acc, cur) => { + if (cur?.groupID === "ChannelA") acc?.channelA?.push(cur); + if (cur?.groupID === "ChannelB") acc?.channelB?.push(cur); + if (cur?.groupID === "ChannelC") acc?.channelC?.push(cur); + if (cur?.groupID === "Default") acc?.default?.push(cur); + return acc; + }, + { + channelA: [], + channelB: [], + channelC: [], + default: [], + }, + ); + + const categoryDefault = statusCategories?.default ?? []; + + if (isError) { + return Error loading system health.; + } + if (isLoading) { + return Loading system health…; + } + return ( +
+
+
+

Start Time

{startTime} +
+
+

Up Time

{uptime} +
+
+
+ +
+
+ {`Last refeshed ${updatedDate}`} +
+
+ ); +}; + +export default SystemHealth; diff --git a/src/features/dashboard/components/systemHealth/SystemHealthCard.tsx b/src/features/dashboard/components/systemHealth/SystemHealthCard.tsx new file mode 100644 index 0000000..22cc9a0 --- /dev/null +++ b/src/features/dashboard/components/systemHealth/SystemHealthCard.tsx @@ -0,0 +1,42 @@ +import { faArrowsRotate } from "@fortawesome/free-solid-svg-icons"; +import Card from "../../../../ui/Card"; +import CardHeader from "../../../../ui/CardHeader"; + +import SystemHealth from "./SystemHealth"; +import type { SystemHealthStatus } from "../../../../types/types"; + +type SystemOverviewProps = { + startTime: string; + uptime: string; + statuses: SystemHealthStatus[]; + isLoading: boolean; + isError: boolean; + dateUpdatedAt: number; + refetch: () => void; +}; + +const SystemHealthCard = ({ + startTime, + uptime, + statuses, + isLoading, + isError, + dateUpdatedAt, + refetch, +}: SystemOverviewProps) => { + return ( + + + + + ); +}; + +export default SystemHealthCard; diff --git a/src/features/dashboard/components/systemHealth/systemHealthModal/SystemHealthModal.tsx b/src/features/dashboard/components/systemHealth/systemHealthModal/SystemHealthModal.tsx new file mode 100644 index 0000000..eeebe0d --- /dev/null +++ b/src/features/dashboard/components/systemHealth/systemHealthModal/SystemHealthModal.tsx @@ -0,0 +1,48 @@ +import type { SystemHealthStatus } from "../../../../../types/types"; +import Badge from "../../../../../ui/Badge"; +import ModalComponent from "../../../../../ui/ModalComponent"; +import StatusIndicators from "../../../../../ui/StatusIndicators"; +import { capitalize } from "../../../../../utils/utils"; + +type SystemHealthModalProps = { + isSystemHealthModalOpen: boolean; + handleClose: () => void; + statusCategory: SystemHealthStatus[]; + title: string; + isAllGood: boolean; +}; + +const SystemHealthModal = ({ + isSystemHealthModalOpen, + handleClose, + statusCategory, + title, + isAllGood, +}: SystemHealthModalProps) => { + return ( + +
+
+

+ {isAllGood ? : } + {capitalize(title)} +

+

{isAllGood ? "All systems running" : "Some systems down"}

+
+ +
+ {statusCategory?.map((status: SystemHealthStatus) => ( +
+ {status.id} +
+ ))} +
+
+
+ ); +}; + +export default SystemHealthModal; diff --git a/src/features/dashboard/components/systemStatus/StatusItems/DownloadLogButton.tsx b/src/features/dashboard/components/systemStatus/StatusItems/DownloadLogButton.tsx new file mode 100644 index 0000000..33e51be --- /dev/null +++ b/src/features/dashboard/components/systemStatus/StatusItems/DownloadLogButton.tsx @@ -0,0 +1,50 @@ +import { faDownload } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useDownloadLogFiles } from "../../../hooks/useDownloadLogFiles"; +import { toast } from "sonner"; + +const DownloadLogButton = () => { + const { downloadLogFilesQuery } = useDownloadLogFiles(); + const isLoading = downloadLogFilesQuery?.isFetching; + + const handleDownloadClick = async () => { + try { + const blob = await downloadLogFilesQuery?.refetch().then((res) => res.data); + if (!blob) { + throw new Error("No log file data received"); + } + const url = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement("a"); + if (!link) { + throw new Error("Failed to create download link"); + } else { + link.href = url; + link.setAttribute("download", "FlexiAI-0.log"); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + window.URL.revokeObjectURL(url); + } + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + toast.error(errorMessage); + } + }; + + return ( + + ); +}; + +export default DownloadLogButton; diff --git a/src/features/dashboard/components/systemStatus/StatusItems/StatusItemCPU.tsx b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemCPU.tsx new file mode 100644 index 0000000..3ae97f6 --- /dev/null +++ b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemCPU.tsx @@ -0,0 +1,23 @@ +import { faHardDrive } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type StatusItemProps = { + statusInfoItem: string; + description: string; +}; + +const StatusItemCPU = ({ statusInfoItem, description }: StatusItemProps) => { + return ( +
+
+ + + +

{statusInfoItem}

+
+

{description}

+
+ ); +}; + +export default StatusItemCPU; diff --git a/src/features/dashboard/components/systemStatus/StatusItems/StatusItemLocal.tsx b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemLocal.tsx new file mode 100644 index 0000000..f99cf40 --- /dev/null +++ b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemLocal.tsx @@ -0,0 +1,31 @@ +import { faClock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type StatusItemProps = { + statusInfoItem: string; + description: string; +}; + +const StatusItemLocal = ({ statusInfoItem, description }: StatusItemProps) => { + const humanReadable = (string: string) => { + if (description.toLowerCase().includes("local")) { + const text = string.slice(0, statusInfoItem.length - 5); + return text; + } + }; + + return ( +
+
+ + + +

{description.toLowerCase().includes("local") && humanReadable(statusInfoItem)}

+
+ +

{description}

+
+ ); +}; + +export default StatusItemLocal; diff --git a/src/features/dashboard/components/systemStatus/StatusItems/StatusItemThreads.tsx b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemThreads.tsx new file mode 100644 index 0000000..69ce8c8 --- /dev/null +++ b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemThreads.tsx @@ -0,0 +1,24 @@ +import { faMicrochip } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type StatusItemProps = { + statusInfoItem: string; + description: string; +}; + +const StatusItemThreads = ({ statusInfoItem, description }: StatusItemProps) => { + return ( +
+
+ + + +

{statusInfoItem}

+
+ +

{description}

+
+ ); +}; + +export default StatusItemThreads; diff --git a/src/features/dashboard/components/systemStatus/StatusItems/StatusItemUTC.tsx b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemUTC.tsx new file mode 100644 index 0000000..b4adef8 --- /dev/null +++ b/src/features/dashboard/components/systemStatus/StatusItems/StatusItemUTC.tsx @@ -0,0 +1,32 @@ +import { faClock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type StatusItemProps = { + statusInfoItem: string; + description: string; +}; + +const StatusItemUTC = ({ statusInfoItem, description }: StatusItemProps) => { + const humanReadable = (string: string) => { + if (description.includes("UTC")) { + const text = string.slice(0, statusInfoItem.length - 3); + return text; + } + }; + + return ( +
+
+ + + + +

{description.toLowerCase().includes("utc") && humanReadable(statusInfoItem)}

+
+ +

{description}

+
+ ); +}; + +export default StatusItemUTC; diff --git a/src/features/dashboard/components/systemStatus/StatusItems/StatusReads.tsx b/src/features/dashboard/components/systemStatus/StatusItems/StatusReads.tsx new file mode 100644 index 0000000..287c18c --- /dev/null +++ b/src/features/dashboard/components/systemStatus/StatusItems/StatusReads.tsx @@ -0,0 +1,48 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChartSimple } from "@fortawesome/free-solid-svg-icons"; + +type StatusReadsProps = { + reads: { + totalPending: number; + totalActive: number; + totalSent: number; + totalReceived: number; + totalLost: number; + sanityCheck: boolean; + sanityCheckFormula: string; + }; + isReadsLoading?: boolean; +}; + +const StatusReads = ({ reads, isReadsLoading }: StatusReadsProps) => { + const totalPending = reads?.totalPending ?? 0; + const totalActive = reads?.totalActive ?? 0; + const totalSent = reads?.totalSent ?? 0; + const totalLost = reads?.totalLost ?? 0; + const totalReceived = reads?.totalReceived ?? 0; + + if (isReadsLoading) { + return

Loading reads…

; + } + return ( +
+
+ + + + +

Reads

+
+ +
+ Pending: {totalPending} | Active:{" "} + {totalActive} | Lost: {totalLost} +
+ Sent / Received: {totalSent} |{" "} + {totalReceived} +
+
+ ); +}; + +export default StatusReads; diff --git a/src/features/dashboard/components/systemStatus/SystemStatusCard.tsx b/src/features/dashboard/components/systemStatus/SystemStatusCard.tsx new file mode 100644 index 0000000..6260c0c --- /dev/null +++ b/src/features/dashboard/components/systemStatus/SystemStatusCard.tsx @@ -0,0 +1,49 @@ +import { useEffect } from "react"; +import { useInfoSocket } from "../../../../app/context/WebSocketContext"; +import Card from "../../../../ui/Card"; +import CardHeader from "../../../../ui/CardHeader"; +import DownloadLogButton from "./StatusItems/DownloadLogButton"; +import StatusItemLocal from "./StatusItems/StatusItemLocal"; +import StatusItemUTC from "./StatusItems/StatusItemUTC"; +import StatusReads from "./StatusItems/StatusReads"; +import { useGetStore } from "../../hooks/useGetStore"; + +const SystemStatusCard = () => { + const { data: stats } = useInfoSocket(); + const { storeQuery } = useGetStore(); + + const reads = storeQuery?.data; + const isReadsLoading = storeQuery?.isFetching; + const isError = storeQuery?.isError || !storeQuery?.data; + + useEffect(() => { + storeQuery.refetch(); + }, [reads]); + + if (isError) { + return ( + + + Error loading system status. + + ); + } + return ( + + + {stats ? ( +
+ + + + +
+ ) : ( + Loading system status… + )} +
+
+ ); +}; + +export default SystemStatusCard; diff --git a/src/features/dashboard/hooks/useDownloadLogFiles.ts b/src/features/dashboard/hooks/useDownloadLogFiles.ts new file mode 100644 index 0000000..75bdc15 --- /dev/null +++ b/src/features/dashboard/hooks/useDownloadLogFiles.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; + +const getDownloadLogFiles = async () => { + const response = await fetch(`${CAMBASE}/LogView/download?filename=FlexiAI-0.log`); + if (!response.ok) { + throw new Error("Failed to download log files"); + } + return response.blob(); +}; + +export const useDownloadLogFiles = () => { + const downloadLogFilesQuery = useQuery({ + queryKey: ["downloadLogFiles"], + queryFn: getDownloadLogFiles, + enabled: false, + }); + + return { downloadLogFilesQuery }; +}; diff --git a/src/features/dashboard/hooks/useGetStore.ts b/src/features/dashboard/hooks/useGetStore.ts new file mode 100644 index 0000000..887176f --- /dev/null +++ b/src/features/dashboard/hooks/useGetStore.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; + +const getStoreData = async () => { + const response = await fetch(`${CAMBASE}/Store0/diagnostics-json`); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + return response.json(); +}; + +export const useGetStore = () => { + const storeQuery = useQuery({ + queryKey: ["storeData"], + queryFn: getStoreData, + // refetchInterval: 10 * 60 * 1000, + }); + return { storeQuery }; +}; diff --git a/src/features/dashboard/hooks/useGetSystemHealth.ts b/src/features/dashboard/hooks/useGetSystemHealth.ts new file mode 100644 index 0000000..130370f --- /dev/null +++ b/src/features/dashboard/hooks/useGetSystemHealth.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { CAMBASE } from "../../../utils/config"; + +const fetchData = async () => { + const response = await fetch(`${CAMBASE}/api/system-health`); + if (!response.ok) throw new Error("Cannot get System overview"); + return response.json(); +}; + +export const useGetSystemHealth = () => { + const query = useQuery({ + queryKey: ["fetchSystemData"], + queryFn: fetchData, + refetchInterval: 300000, + }); + return { query }; +}; diff --git a/src/features/output/components/BearerTypeCard.tsx b/src/features/output/components/BearerTypeCard.tsx new file mode 100644 index 0000000..f7144e2 --- /dev/null +++ b/src/features/output/components/BearerTypeCard.tsx @@ -0,0 +1,14 @@ +import Card from "../../../ui/Card"; +import CardHeader from "../../../ui/CardHeader"; +import BearerTypeFields from "./BearerTypeFields"; + +const BearerTypeCard = () => { + return ( + + + + + ); +}; + +export default BearerTypeCard; diff --git a/src/features/output/components/BearerTypeFields.tsx b/src/features/output/components/BearerTypeFields.tsx new file mode 100644 index 0000000..36f8e99 --- /dev/null +++ b/src/features/output/components/BearerTypeFields.tsx @@ -0,0 +1,34 @@ +import { Field, useFormikContext } from "formik"; +import type { FormTypes } from "../../../types/types"; + +const BearerTypeFields = () => { + useFormikContext(); + return ( +
+ + + + + + + +
+ ); +}; + +export default BearerTypeFields; diff --git a/src/features/output/components/ChannelCard.tsx b/src/features/output/components/ChannelCard.tsx new file mode 100644 index 0000000..cf440fd --- /dev/null +++ b/src/features/output/components/ChannelCard.tsx @@ -0,0 +1,32 @@ +import { useFormikContext } from "formik"; +import Card from "../../../ui/Card"; +import CardHeader from "../../../ui/CardHeader"; +import ChannelFields from "./ChannelFields"; +import type { FormTypes } from "../../../types/types"; +import { useGetBearerConfig } from "../hooks/useBearer"; + +const ChannelCard = () => { + const { values, errors, touched, setFieldValue } = useFormikContext(); + const { bearerQuery } = useGetBearerConfig(values?.format?.toLowerCase() || "json"); + const outputData = bearerQuery?.data; + return ( + + + + + + ); +}; + +export default ChannelCard; diff --git a/src/features/output/components/ChannelFields.tsx b/src/features/output/components/ChannelFields.tsx new file mode 100644 index 0000000..2b14bb9 --- /dev/null +++ b/src/features/output/components/ChannelFields.tsx @@ -0,0 +1,324 @@ +import { Field, FieldArray } from "formik"; +import type { FormTypes, InitialValuesFormErrors, OutputDataResponse } from "../../../types/types"; +import { useEffect, useMemo } from "react"; +import { useOptionalConstants } from "../hooks/useOptionalConstants"; + +type ChannelFieldsProps = { + values: FormTypes; + errors: InitialValuesFormErrors; + touched: { + connectTimeoutSeconds?: boolean | undefined; + readTimeoutSeconds?: boolean | undefined; + }; + outputData?: OutputDataResponse; + onSetFieldValue: (field: string, value: string, shouldValidate?: boolean | undefined) => void; +}; + +const ChannelFields = ({ errors, touched, values, outputData, onSetFieldValue }: ChannelFieldsProps) => { + const { optionalConstantsQuery } = useOptionalConstants(outputData?.id?.split("-")[1] || ""); + const optionalConstants = optionalConstantsQuery?.data; + + const channelFieldsObject = useMemo(() => { + return { + connectTimeoutSeconds: outputData?.propConnectTimeoutSeconds?.value || "5", + readTimeoutSeconds: outputData?.propReadTimeoutSeconds?.value || "15", + backOfficeURL: outputData?.propBackofficeURL?.value || "", + username: outputData?.propUsername?.value || "", + password: outputData?.propPassword?.value || "", + SCID: optionalConstants?.propSourceIdentifier?.value || "", + timestampSource: optionalConstants?.propTimeZoneType?.value || "UTC", + GPSFormat: optionalConstants?.propGpsFormat?.value || "Minutes", + FFID: optionalConstants?.propFeedIdentifier?.value || "", + }; + }, [ + optionalConstants?.propFeedIdentifier?.value, + optionalConstants?.propGpsFormat?.value, + optionalConstants?.propSourceIdentifier?.value, + optionalConstants?.propTimeZoneType?.value, + outputData?.propBackofficeURL?.value, + outputData?.propConnectTimeoutSeconds?.value, + outputData?.propPassword?.value, + outputData?.propReadTimeoutSeconds?.value, + outputData?.propUsername?.value, + ]); + + useEffect(() => { + for (const [key, value] of Object.entries(channelFieldsObject)) { + onSetFieldValue(key, value); + } + }, [channelFieldsObject, onSetFieldValue, outputData]); + + return ( +
+ {values.format.toLowerCase() !== "ftp" ? ( + <> +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + +
+
+ + + + + + + +
+ {values.format.toLowerCase() === "utmc" && ( + <> +
+

{values.format} Constants

+
+ +
+ + +
+
+ + + + + +
+
+ + + + + +
+ + )} + {values.format?.toLowerCase() === "bof2" && ( + <> +
+
+

{values.format} Constants

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

{values.format} Lane ID Config

+
+
+ + +
+
+ + +
+
+ + )} +
+

Custom Fields

+
+
+ + {(arrayHelpers) => ( + <> + {values?.customFields?.map((_, index) => { + // if (!field.value) return null; + return ( +
+ + +
+ ); + })} +
+ + {values?.customFields && values?.customFields?.length > 0 && ( + + )} +
+ + )} +
+
+ + ) : ( + <> + )} +
+ ); +}; + +export default ChannelFields; diff --git a/src/features/output/components/OSDFieldToggle.tsx b/src/features/output/components/OSDFieldToggle.tsx new file mode 100644 index 0000000..dadb66f --- /dev/null +++ b/src/features/output/components/OSDFieldToggle.tsx @@ -0,0 +1,27 @@ +import { Field } from "formik"; + +type OSDFieldToggleProps = { + value: string; + label: string; +}; + +const OSDFieldToggle = ({ value, label }: OSDFieldToggleProps) => { + const spacesWords = (label: string) => { + if (label.includes("VRM")) return label.replace("VRM", " VRM"); + return label.replace(/([A-Z])/g, " $1").trim(); + }; + + return ( +