diff --git a/package.json b/package.json index 164cdcfd..fa448484 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "@mui/icons-material": "^5.10.14", "@mui/material": "^5.16.4", "@mui/styles": "^6.1.4", + "@mui/system": "^7.3.9", "@mui/x-data-grid": "^7.11.1", "@mui/x-date-pickers": "^7.20.0", "@reduxjs/toolkit": "^2.2.7", @@ -76,6 +77,7 @@ "html-webpack-tags-plugin": "^3.0.2", "https-browserify": "^1.0.0", "idb": "^7.1.1", + "jsqr": "^1.4.0", "leaflet": "^1.9.4", "next": "^14.2.5", "node-polyfill-webpack-plugin": "^2.0.1", @@ -85,6 +87,7 @@ "osh-js": "^3.1.5", "patch-package": "^6.5.0", "path-browserify": "^1.0.1", + "qr-scanner": "^1.4.2", "react": "^18.3.1", "react-dom": "^18.3.1", "react-draggable": "^4.4.5", diff --git a/patches/osh-js+3.1.5.patch b/patches/osh-js+3.1.5.patch new file mode 100644 index 00000000..ba577116 --- /dev/null +++ b/patches/osh-js+3.1.5.patch @@ -0,0 +1,55 @@ +diff --git a/node_modules/osh-js/core/parsers/JsonDataParser.js b/node_modules/osh-js/core/parsers/JsonDataParser.js +index 8bb9f66..0c26f5c 100644 +--- a/node_modules/osh-js/core/parsers/JsonDataParser.js ++++ b/node_modules/osh-js/core/parsers/JsonDataParser.js +@@ -58,10 +58,13 @@ var JsonDataParser = /** @class */ (function (_super) { + jsonData = input; + } + } ++ if (!jsonData) return []; + if (Array.isArray(jsonData)) { + for (var _i = 0, jsonData_1 = jsonData; _i < jsonData_1.length; _i++) { + var d = jsonData_1[_i]; +- d['timestamp'] = new Date(d[this.getTimeField()]).getTime() + this.props.timeShift; ++ if (d) { ++ d['timestamp'] = new Date(d[this.getTimeField()]).getTime() + this.props.timeShift; ++ } + } + return jsonData; + } +diff --git a/node_modules/osh-js/source/core/mqtt/MqttProvider.js b/node_modules/osh-js/source/core/mqtt/MqttProvider.js +index fb0b3a8..75a9ba2 100644 +--- a/node_modules/osh-js/source/core/mqtt/MqttProvider.js ++++ b/node_modules/osh-js/source/core/mqtt/MqttProvider.js +@@ -166,10 +166,10 @@ class MqttProvider { + this.client.on('message', this.onMessage.bind(this)); + + this.client.on('offline', e => { +- throw new Error(`The server ${that.endpoint} seems offline`); ++ console.warn(`The server ${that.endpoint} seems offline`); + }); + this.client.on('error', e => { +- throw new Error(error); ++ console.error(e); + }); + } + } +diff --git a/node_modules/osh-js/source/core/parsers/JsonDataParser.js b/node_modules/osh-js/source/core/parsers/JsonDataParser.js +index 67eb795..57490eb 100644 +--- a/node_modules/osh-js/source/core/parsers/JsonDataParser.js ++++ b/node_modules/osh-js/source/core/parsers/JsonDataParser.js +@@ -38,9 +38,13 @@ class JsonDataParser extends GenericParser { + } + } + ++ if (!jsonData) return []; ++ + if(Array.isArray(jsonData)) { + for(let d of jsonData) { +- d['timestamp'] = new Date(d[this.getTimeField()]).getTime() + this.props.timeShift; ++ if (d) { ++ d['timestamp'] = new Date(d[this.getTimeField()]).getTime() + this.props.timeShift; ++ } + } + return jsonData; + } else { diff --git a/public/config/spectroscopy-info.json b/public/config/spectroscopy-info.json new file mode 100644 index 00000000..72ea0fec --- /dev/null +++ b/public/config/spectroscopy-info.json @@ -0,0 +1,9 @@ +{ + "api_version": "1.0", + "endpoint": "/buckets/adjudication", + "allowed_detectors": ["GR-100", "NS-500", "GN-Scan"], + "calibration_factors": { + "gamma": 1.0, + "neutron": 1.0 + } +} diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..4e457319 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,16 @@ +{ + "name": "OSCAR Viewer", + "short_name": "OSCAR", + "description": "Open Source Central Alarm Station Viewer", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { + "src": "/opensensorhub.png", + "sizes": "192x192", + "type": "image/png" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 00000000..7af3d658 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2024. Botts Innovative Research, Inc. + * All Rights Reserved + */ + +const CACHE_NAME = 'oscar-v1'; +const OFFLINE_URL = '/offline.html'; + +const DENY_LIST = [ + '/sensorhub/sos', + '/sensorhub/sps', + '/api/auth', + '/setup' +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll([ + OFFLINE_URL, + '/favicon.ico', + '/opensensorhub.png' + ]); + }) + ); +}); + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // Security: Never cache sensitive telemetry or auth routes + if (DENY_LIST.some(path => url.pathname.startsWith(path))) { + return; + } + + event.respondWith( + caches.match(event.request).then((response) => { + return response || fetch(event.request).catch(() => { + if (event.request.mode === 'navigate') { + return caches.match(OFFLINE_URL); + } + }); + }) + ); +}); + +self.addEventListener('push', (event) => { + const data = event.data ? event.data.json() : {}; + // Use the push payload for localization if available; otherwise fallback to the server-sent strings. + const title = data.title; + const options = { + body: data.body, + icon: '/opensensorhub.png', + badge: '/favicon.ico', + data: { + url: data.url || '/' + } + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + clients.openWindow(event.notification.data.url) + ); +}); diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index d3a2b8ae..09ccbefb 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -40,7 +40,7 @@ export default function DashboardPage() { laneDSMap.set(laneid, new LaneDSColl()); - lane.datastreams.forEach((ds, idx) => { + lane.datastreams?.forEach((ds, idx) => { let rtDS = lane.datasourcesRealtime?.[idx]; @@ -55,19 +55,19 @@ export default function DashboardPage() { let laneDSColl = laneDSMap.get(laneid); if(isGammaDataStream(ds)) - laneDSColl.addDS('gammaRT', rtDS); + laneDSColl?.addDS('gammaRT', rtDS); if(isNeutronDataStream(ds)) - laneDSColl.addDS('neutronRT', rtDS); + laneDSColl?.addDS('neutronRT', rtDS); if(isTamperDataStream(ds)) - laneDSColl.addDS('tamperRT', rtDS); + laneDSColl?.addDS('tamperRT', rtDS); if(isConnectionDataStream(ds)) - laneDSColl.addDS('connectionRT', rtDS); + laneDSColl?.addDS('connectionRT', rtDS); if(isThresholdDataStream(ds)) - laneDSColl.addDS('gammaTrshldRT', rtDS); + laneDSColl?.addDS('gammaTrshldRT', rtDS); }); newStatusList.push({ diff --git a/src/app/_components/AlarmAudio.tsx b/src/app/_components/AlarmAudio.tsx index e07138ca..67f437c5 100644 --- a/src/app/_components/AlarmAudio.tsx +++ b/src/app/_components/AlarmAudio.tsx @@ -5,6 +5,7 @@ import { useDispatch, useSelector } from "react-redux"; import Box from "@mui/material/Box"; import {Alert} from "@mui/material"; import { selectAlarmAudioVolume } from "@/lib/state/OSCARClientSlice"; +import { useLanguage } from '@/contexts/LanguageContext'; let alarmAudio: HTMLAudioElement | null = null; @@ -17,6 +18,7 @@ export function getAlarmAudio() { } export default function AlarmAudio() { + const { t } = useLanguage(); const dispatch = useDispatch(); const savedVolume = useSelector(selectAlarmAudioVolume); const triggerAlarm = useSelector((state: RootState) => selectTriggeredAlarm(state)); @@ -61,7 +63,7 @@ export default function AlarmAudio() { {soundLocked && ( - Click anywhere to enable alarm sound + {t('clickAnywhereToEnableAlarmSound')} )} diff --git a/src/app/_components/LanguageSelector.tsx b/src/app/_components/LanguageSelector.tsx index f98af022..e16110e7 100644 --- a/src/app/_components/LanguageSelector.tsx +++ b/src/app/_components/LanguageSelector.tsx @@ -1,15 +1,41 @@ "use client"; -import React, { useState } from 'react'; -import { Select, MenuItem, FormControl, InputLabel, SelectChangeEvent } from '@mui/material'; +import React from 'react'; +import { Select, MenuItem, FormControl, SelectChangeEvent } from '@mui/material'; import { useLanguage } from '@/contexts/LanguageContext'; import LanguageIcon from '@mui/icons-material/Language'; +const languages = [ + { code: 'en', name: 'English' }, + { code: 'es', name: 'Español' }, + { code: 'fr', name: 'Français' }, + { code: 'ar', name: 'العربية' }, + { code: 'ru', name: 'Русский' }, + { code: 'zh-CN', name: '简体中文' }, + { code: 'ja', name: '日本語' }, + { code: 'ko', name: '한국어' }, + { code: 'ar-JO', name: 'العربية (الأردن)' }, + { code: 'lv', name: 'Latviešu' }, + { code: 'et', name: 'Eesti' }, + { code: 'pt', name: 'Português' }, + { code: 'de', name: 'Deutsch' }, + { code: 'th', name: 'ไทย' }, + { code: 'hi', name: 'हिन्दी' }, + { code: 'bn', name: 'বাংলা' }, + { code: 'pa-PK', name: 'پنجابی' }, + { code: 'vi', name: 'Tiếng Việt' }, + { code: 'yue', name: '粵語' }, + { code: 'tr', name: 'Türkçe' }, + { code: 'id', name: 'Bahasa Indonesia' }, + { code: 'ur', name: 'اردو' }, + { code: 'it', name: 'Italiano' } +]; + export default function LanguageSelector() { const { language, setLanguage } = useLanguage(); const handleChange = (event: SelectChangeEvent) => { - setLanguage(event.target.value as 'en' | 'es' | 'fr'); + setLanguage(event.target.value as any); }; return ( @@ -19,18 +45,28 @@ export default function LanguageSelector() { value={language} onChange={handleChange} displayEmpty - inputProps={{ 'aria-label': 'Without label' }} - startAdornment={} + inputProps={{ 'aria-label': 'Language' }} + startAdornment={} renderValue={(selected) => { - if (selected === 'en') return 'English'; - if (selected === 'es') return 'Español'; - if (selected === 'fr') return 'Français'; - return selected; + const lang = languages.find(l => l.code === selected); + return lang ? lang.name : selected; + }} + sx={{ + color: 'inherit', + '& .MuiSelect-select': { + paddingTop: '4px', + paddingBottom: '4px', + }, + '&:before': { borderBottomColor: 'rgba(255, 255, 255, 0.7)' }, + '&:after': { borderBottomColor: 'white' }, + '& .MuiSvgIcon-root': { color: 'inherit' } }} > - English - Español - Français + {languages.map((lang) => ( + + {lang.name} + + ))} ); diff --git a/src/app/_components/Navbar.tsx b/src/app/_components/Navbar.tsx index 86e5eaec..5aa328fd 100644 --- a/src/app/_components/Navbar.tsx +++ b/src/app/_components/Navbar.tsx @@ -201,7 +201,7 @@ export default function Navbar({children}: { children: React.ReactNode }) { {savedVolume === 0 ? : } @@ -256,7 +256,7 @@ export default function Navbar({children}: { children: React.ReactNode }) { ([]); const [adjudicationCode, setAdjudicationCode] = useState(AdjudicationCodes.codes[0]); const [isotope, setIsotope] = useState([]); const [secondaryInspection, setSecondaryInspection] = useState(''); + const [qrData, setQrData] = useState(''); const [vehicleId, setVehicleId] = useState(""); const [feedback, setFeedback] = useState(""); @@ -71,14 +76,14 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { if (!props.event.occupancyObsId) { try { const currentLane = props.event.laneId; - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); if (!currLaneEntry) { console.error("Lane entry not found:", currentLane); return; } - const ds = currLaneEntry.datastreams.find( + const ds = currLaneEntry.datastreams?.find( (ds: any) => ds.properties.id === props.event.dataStreamId ); @@ -95,7 +100,7 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { const occupancyObservation = await query.nextPage(); if (!occupancyObservation || occupancyObservation.length === 0) { - setAdjSnackMsg('Cannot find observation to adjudicate. Please try again.'); + setAdjSnackMsg(t('cannotFindObservation')); setColorStatus('error'); setOpenSnack(true); return; @@ -106,7 +111,7 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { } catch (err) { console.error(err); - setAdjSnackMsg('Error loading observation.'); + setAdjSnackMsg(t('errorLoadingObservation')); setColorStatus('error'); setOpenSnack(true); } @@ -153,6 +158,14 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { setAdjData(tAdjData); } + const handleQrData = (data: string) => { + setQrData(data); + let tAdjData = adjData; + tAdjData.feedback += ` [WebID QR: ${data}]`; + setFeedback(tAdjData.feedback); + setAdjData(tAdjData); + } + const handleInspectionSelect = (value: string) => { let tAdjData = adjData; tAdjData.secondaryInspectionStatus = value; @@ -188,7 +201,7 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { const sendAdjudicationData = async () => { if(adjData.adjudicationCode === null || !adjData.adjudicationCode || adjData.adjudicationCode === AdjudicationCodes.codes[0]){ - setAdjSnackMsg("Please selected a valid adjudication code before submitting."); + setAdjSnackMsg(t('selectValidCode')); setColorStatus('error'); setOpenSnack(true) return; @@ -208,17 +221,22 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { // send to server const currentLane = props.event.laneId; - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); await submitAdjudication(currLaneEntry, tempAdjData) } const submitAdjudication = async(currLaneEntry: any, tempAdjData: any) => { try{ + if (!currLaneEntry) { + console.error("Lane entry not found"); + return; + } - let ds = currLaneEntry.datastreams.find((ds: any) => ds.properties.id == props.event.dataStreamId); + let ds = currLaneEntry.datastreams?.find((ds: any) => ds.properties.id == props.event.dataStreamId); - let streams = currLaneEntry.controlStreams.length > 0 ? currLaneEntry.controlStreams : await currLaneEntry.parentNode.fetchNodeControlStreams(); + let streams = currLaneEntry.controlStreams?.length > 0 ? currLaneEntry.controlStreams : await currLaneEntry.parentNode?.fetchNodeControlStreams(); + if (!streams) return; let adjControlStream = streams.find((stream: typeof ControlStream) => isAdjudicationControlStream(stream)); if (!adjControlStream){ @@ -234,7 +252,7 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { const occupancyObservation = await query.nextPage(); if (!occupancyObservation) { - setAdjSnackMsg('Cannot find observation to adjudicate. Please try again.'); + setAdjSnackMsg(t('cannotFindObservation')); setColorStatus('error') setOpenSnack(true); return; @@ -264,7 +282,7 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { ); if (!response.ok) { - setAdjSnackMsg('Adjudication failed to submit.') + setAdjSnackMsg(t('adjudicationFail')) setColorStatus('error') return; } @@ -274,11 +292,11 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { dispatch(setSelectedEvent(props.event)); dispatch(setAdjudicatedEventId(props.event.id)); - setAdjSnackMsg('Adjudication successful for Occupancy ID: ' + props.event.occupancyCount); + setAdjSnackMsg(t('adjudicationSuccess') + props.event.occupancyCount); setColorStatus('success') }catch(error){ - setAdjSnackMsg('Adjudication failed to submit.') + setAdjSnackMsg(t('adjudicationFail')) setColorStatus('error') }finally{ setShouldFetchLogs(true); @@ -301,7 +319,7 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { - Adjudication + {t('adjudicationTitle')} - - Adjudication Report Form - - - - + + + + {t('adjudicationReportForm')} + + + + + - - - - + + + - + + + + + + + + + + + + + + + + + + { uploadedFiles.length > 0 && ( @@ -406,7 +434,7 @@ export default function AdjudicationDetail(props: { event: EventTableData }) { color: "secondary.main" }} > - Upload Files + {t('uploadFiles')} - Submit + {t('submit')} ); -} \ No newline at end of file +} diff --git a/src/app/_components/adjudication/AdjudicationLog.tsx b/src/app/_components/adjudication/AdjudicationLog.tsx index 9baea707..654545db 100644 --- a/src/app/_components/adjudication/AdjudicationLog.tsx +++ b/src/app/_components/adjudication/AdjudicationLog.tsx @@ -5,13 +5,14 @@ import AdjudicationData from "@/lib/data/oscar/adjudication/Adjudication"; import {EventTableData} from "@/lib/data/oscar/TableHelpers"; import {DataSourceContext} from "@/app/contexts/DataSourceContext"; import {DataGrid, GridColDef} from "@mui/x-data-grid"; -import { Stack, Typography} from "@mui/material"; +import { Box, Stack, Typography} from "@mui/material"; import {LaneMapEntry} from "@/lib/data/oscar/LaneCollection"; import {isAdjudicationControlStream} from "@/lib/data/oscar/Utilities"; import ControlStream from "osh-js/source/core/consysapi/controlstream/ControlStream"; import {AdjudicationCodes} from "@/lib/data/oscar/adjudication/models/AdjudicationConstants"; import ControlStreamFilter from "osh-js/source/core/consysapi/controlstream/ControlStreamFilter"; import { Dialog, DialogTitle, DialogContent } from "@mui/material"; +import { useLanguage } from '@/contexts/LanguageContext'; @@ -20,6 +21,7 @@ export default function AdjudicationLog(props: { shouldFetch: boolean; onFetch: () => void; }) { + const { t } = useLanguage(); const locale = navigator.language || 'en-US'; const laneMapRef = useContext(DataSourceContext).laneMapRef; @@ -35,19 +37,19 @@ export default function AdjudicationLog(props: { const logColumns: GridColDef[] = [ { field: 'occupancyCount', - headerName: 'Occupancy ID', + headerName: t('occupancyId'), width: 175, type: 'string', }, { field: 'username', - headerName: 'User', + headerName: t('user'), width: 150, type: 'string', }, { field: 'time', - headerName: 'Timestamp', + headerName: t('timestamp'), width: 200, type: 'string', valueFormatter: (params) => (new Date(params)).toLocaleString(locale, { @@ -61,7 +63,7 @@ export default function AdjudicationLog(props: { }, { field: 'feedback', - headerName: 'Feedback', + headerName: t('feedback'), width: 250, type: 'string', renderCell: (params) => { @@ -80,7 +82,7 @@ export default function AdjudicationLog(props: { style={{ color: "#1976d2", border: "none", background: "none", cursor: "pointer" }} onClick={() => setFeedbackDialog({ open: true, text: fullText })} > - Read more + {t('readMore')} )} @@ -89,12 +91,12 @@ export default function AdjudicationLog(props: { }, { field: 'secondaryInspectionStatus', - headerName: 'Secondary Inspection Status', + headerName: t('secondaryInspectionStatus'), width: 200 }, { field: 'adjudicationCode', - headerName: 'Adjudication Code', + headerName: t('adjudicationCode'), width: 400, valueGetter: (value, row) => { return row.adjudicationCode.label @@ -102,7 +104,7 @@ export default function AdjudicationLog(props: { }, { field: 'isotopes', - headerName: 'Isotopes', + headerName: t('isotopes'), width: 200, valueGetter: (value) => { if (value === "") return "Unknown"; @@ -111,13 +113,13 @@ export default function AdjudicationLog(props: { }, { field: 'filePaths', - headerName: 'FilePaths', + headerName: t('filePaths'), width: 200, type: 'string' }, { field: 'vehicleId', - headerName: 'Vehicle ID', + headerName: t('vehicleId'), width: 150, valueGetter: (value) => { if (value === "") return "Unknown"; @@ -127,9 +129,10 @@ export default function AdjudicationLog(props: { ]; async function getControlStream(){ const currentLane = props.event.laneId; - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); + if (!currLaneEntry) return; - let streams = currLaneEntry.controlStreams.length > 0 ? currLaneEntry.controlStreams : await currLaneEntry.parentNode.fetchNodeControlStreams(); + let streams = currLaneEntry.controlStreams?.length > 0 ? currLaneEntry.controlStreams : await currLaneEntry.parentNode?.fetchNodeControlStreams(); if(!streams) return; @@ -198,28 +201,30 @@ export default function AdjudicationLog(props: { <> - Logged Adjudications + {t('loggedAdjudications')} - + + }} + pageSizeOptions={[5, 10, 25, 50, 100]} + disableRowSelectionOnClick={true} + /> + setFeedbackDialog({ open: false, text: "" })} maxWidth="sm" fullWidth > - Feedback + {t('feedback')} {feedbackDialog.text} diff --git a/src/app/_components/adjudication/AdjudicationSelect.tsx b/src/app/_components/adjudication/AdjudicationSelect.tsx index db3e7e0d..d7e138e4 100644 --- a/src/app/_components/adjudication/AdjudicationSelect.tsx +++ b/src/app/_components/adjudication/AdjudicationSelect.tsx @@ -4,6 +4,8 @@ import {FormControl, InputLabel, ListSubheader, MenuItem, Select, SelectChangeEv import {useEffect, useState} from 'react'; import {AdjudicationCode, AdjudicationCodes} from "@/lib/data/oscar/adjudication/models/AdjudicationConstants"; import {IAdjudicationData} from "@/lib/data/oscar/adjudication/Adjudication"; +import {useLanguage} from "@/contexts/LanguageContext"; + export const colorCodes = { real: {color: "error.dark"}, @@ -16,10 +18,12 @@ export default function AdjudicationSelect(props: { onSelect: (value: AdjudicationCode) => void, // Return selected value adjCode: AdjudicationCode }) { + const { t } = useLanguage(); // const [adjudicated, setAdjudicated] = useState(AdjudicationCodes.codes[0]); // Adjudication selected value const [style, setStyle] = useState(colorCodes.other.color); // Adjudicated button style based on selected value const handleChangeAdjCode = (event: SelectChangeEvent) => { + // Value in Select is English label (unique) let value: AdjudicationCode = AdjudicationCodes.getCodeObjByLabel(event.target.value); // setAdjudicated(value); // Set local adjudicated state props.onSelect(value); // Return selected value to parent component @@ -38,11 +42,11 @@ export default function AdjudicationSelect(props: { return ( - Adjudicate + {t('adjudicated')} diff --git a/src/app/_components/adjudication/DetectorResponseFunction.tsx b/src/app/_components/adjudication/DetectorResponseFunction.tsx new file mode 100644 index 00000000..ddc34fc8 --- /dev/null +++ b/src/app/_components/adjudication/DetectorResponseFunction.tsx @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024. Botts Innovative Research, Inc. + * All Rights Reserved + */ + +"use client"; + +import React, { useEffect, useState } from 'react'; +import { useLanguage } from '@/contexts/LanguageContext'; + +export interface DetectorInfo { + api_version: string; + endpoint: string; + allowed_detectors: string[]; +} + +export function DetectorResponseFunction() { + const { t } = useLanguage(); + const [detectorInfo, setDetectorInfo] = useState(null); + + useEffect(() => { + // Secure Egress Mitigation: Use local config instead of external Sandia API + fetch('/config/spectroscopy-info.json') + .then(res => res.json()) + .then(data => setDetectorInfo(data)) + .catch(err => console.error(t('spectroscopicDataInvalid'), err)); + }, [t]); + + return ( +
+ {detectorInfo ? ( +
+ Endpoint: {detectorInfo.endpoint} +
+ ) : ( +
{t('loading')}
+ )} +
+ ); +} diff --git a/src/app/_components/adjudication/IsotopeSelect.tsx b/src/app/_components/adjudication/IsotopeSelect.tsx index 7778f1c9..c2b75606 100644 --- a/src/app/_components/adjudication/IsotopeSelect.tsx +++ b/src/app/_components/adjudication/IsotopeSelect.tsx @@ -13,6 +13,7 @@ import { Theme } from '@mui/material'; import {useEffect, useState} from 'react'; +import {useLanguage} from '@/contexts/LanguageContext'; const isotopeChoices=[ "Unknown", @@ -52,6 +53,7 @@ export default function IsotopeSelect(props: { onSelect: (value: string[]) => void, // Return selected value isotopeValue: string[] }) { + const { t } = useLanguage(); const [isotope, setIsotope] = useState([""]); const handleChange = (event: SelectChangeEvent) => { @@ -70,9 +72,9 @@ export default function IsotopeSelect(props: { return ( - Isotope + {t('isotope')} void }) { + const { t } = useLanguage(); + const [isScanning, setIsScanning] = useState(false); + const videoRef = useRef(null); + const scannerRef = useRef(null); + + const startScan = async () => { + setIsScanning(true); + if (videoRef.current) { + scannerRef.current = new QrScanner( + videoRef.current, + (result) => { + props.onDataFound(result.data); + stopScan(); + }, + { + highlightScanRegion: true, + highlightCodeOutline: true, + } + ); + await scannerRef.current.start(); + } + }; + + const stopScan = () => { + scannerRef.current?.stop(); + scannerRef.current?.destroy(); + scannerRef.current = null; + setIsScanning(false); + }; + + useEffect(() => { + return () => { + stopScan(); + }; + }, []); + + return ( + + + + {t('webIdQrAnalysis')} + + {isScanning ? ( + + {t('qrScanningStatus')} + + ) : ( + + + + )} + + ); +} diff --git a/src/app/_components/dashboard/LaneStatusItem.tsx b/src/app/_components/dashboard/LaneStatusItem.tsx index a68599b5..76fe4ba3 100644 --- a/src/app/_components/dashboard/LaneStatusItem.tsx +++ b/src/app/_components/dashboard/LaneStatusItem.tsx @@ -8,6 +8,7 @@ import FaultIcon from '@mui/icons-material/Error'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import OfflineIcon from '@mui/icons-material/ReportOff' import React from "react"; +import {useLanguage} from "@/contexts/LanguageContext"; export default function LaneStatusItem(props: { @@ -18,6 +19,7 @@ export default function LaneStatusItem(props: { isFault: boolean; }) { + const { t } = useLanguage(); return ( {props.name.length <= 15 ? props.name : (props.name.substr(0, 15)) }
{props.isFault && - + } {props.isTamper && - + } {/*{!props.isTamper && !props.isFault && props.isOnline && (*/} {props.isOnline ? ( - + ) : ( - + )} @@ -62,4 +64,4 @@ export default function LaneStatusItem(props: { ); -} \ No newline at end of file +} diff --git a/src/app/_components/event-details/DataRow.tsx b/src/app/_components/event-details/DataRow.tsx index 44265265..ea8ed23b 100644 --- a/src/app/_components/event-details/DataRow.tsx +++ b/src/app/_components/event-details/DataRow.tsx @@ -3,29 +3,34 @@ import {Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@mui/material"; import {styled, Theme} from "@mui/material/styles"; import {EventTableData} from "@/lib/data/oscar/TableHelpers"; +import { useLanguage } from '@/contexts/LanguageContext'; -const StatusTableCell = styled(TableCell)(({theme, status}: { theme: Theme, status: string }) => ({ +const StatusTableCell = styled(TableCell, { + shouldForwardProp: (prop) => prop !== 'status', +})<{ status: string }>(({ theme, status }) => ({ color: status === 'Gamma' ? theme.palette.error.contrastText : status === 'Neutron' ? theme.palette.info.contrastText : status === 'Gamma & Neutron' ? theme.palette.secondary.contrastText : 'inherit', backgroundColor: status === 'Gamma' ? theme.palette.error.main : status === 'Neutron' ? theme.palette.info.main : status === 'Gamma & Neutron' ? theme.palette.secondary.main : 'transparent', })); export default function DataRow({eventData}: {eventData: EventTableData}) { + const { t } = useLanguage(); + return ( - +
- Secondary Inspection - Lane ID - Occupancy ID - Start Time - End Time - Max Gamma - Max Neutron - Status - Adjudicated + {t('secondaryInspection')} + {t('laneId')} + {t('occupancyId')} + {t('startTime')} + {t('endTime')} + {t('maxGamma')} + {t('maxNeutron')} + {t('status')} + {t('adjudicated')} @@ -42,11 +47,11 @@ export default function DataRow({eventData}: {eventData: EventTableData}) { {eventData?.status || 'Unknown'} - {eventData?.adjudicatedIds.length > 0 ? "Yes" : "No"} + {eventData?.adjudicatedIds.length > 0 ? t('yes') : t('no')} ) : ( - No event data available + {t('noEventDataAvailable')} )} diff --git a/src/app/_components/event-details/MiscTable.tsx b/src/app/_components/event-details/MiscTable.tsx index 0892c244..07ebba79 100644 --- a/src/app/_components/event-details/MiscTable.tsx +++ b/src/app/_components/event-details/MiscTable.tsx @@ -9,9 +9,11 @@ import ObservationFilter from "osh-js/source/core/consysapi/observation/Observat import {selectEventData, selectSpeed, setSpeed} from "@/lib/state/EventDetailsSlice"; import {useAppDispatch} from "@/lib/state/Hooks"; import {isSpeedDataStream} from "@/lib/data/oscar/Utilities"; +import { useLanguage } from '@/contexts/LanguageContext'; export default function MiscTable({currentTime}: {currentTime: string}) { + const { t } = useLanguage(); const dispatch = useAppDispatch(); const savedSpeed = useSelector(selectSpeed) @@ -23,9 +25,11 @@ export default function MiscTable({currentTime}: {currentTime: string}) { const checkForSpeed = useCallback(async () => { if (eventData) { - let lme = laneMapRef.current.get(eventData.laneId); + let lme = laneMapRef.current?.get(eventData.laneId); + if (!lme) return; - let speedDS = lme.datastreams.find(ds => isSpeedDataStream(ds)); + let speedDS = lme.datastreams?.find((ds: any) => isSpeedDataStream(ds)); + if (!speedDS) return; let initialRes = await speedDS.searchObservations(new ObservationFilter({ resultTime: `${eventData?.startTime}/${eventData?.endTime}`}), 10000); @@ -52,18 +56,18 @@ export default function MiscTable({currentTime}: {currentTime: string}) { return ( -
+
- Max Gamma Count Rate (cps) + {t('maxGammaCountRate')} {eventData?.maxGamma} - Neutron Background Count Rate + {t('neutronBackgroundCountRate')} {eventData?.neutronBackground} - Max Neutron Count Rate (cps) + {t('maxNeutronCountRate')} {eventData?.maxNeutron} - Speed (kph) + {t('speedKph')} {speedVal} diff --git a/src/app/_components/event-preview/ChartTimeHighlight.tsx b/src/app/_components/event-preview/ChartTimeHighlight.tsx index 06175658..ddeeb967 100644 --- a/src/app/_components/event-preview/ChartTimeHighlight.tsx +++ b/src/app/_components/event-preview/ChartTimeHighlight.tsx @@ -17,6 +17,7 @@ import { createNSigmaCalcViewCurve, createThresholdViewCurve, createThreshSigmaViewCurve } from "@/app/utils/ChartUtils"; +import {useLanguage} from "@/contexts/LanguageContext"; type CurveLayers = { @@ -44,6 +45,7 @@ export class ChartInterceptProps { } export default function ChartTimeHighlight(props: ChartInterceptProps) { + const { t } = useLanguage(); const gammaChartViewRef = useRef(null); const nSigmaChartViewRef = useRef(null); diff --git a/src/app/_components/event-preview/EventPreview.tsx b/src/app/_components/event-preview/EventPreview.tsx index 4241aae4..873f893a 100644 --- a/src/app/_components/event-preview/EventPreview.tsx +++ b/src/app/_components/event-preview/EventPreview.tsx @@ -47,8 +47,10 @@ import ObservationFilter from "osh-js/source/core/consysapi/observation/Observat import ControlStream from "osh-js/source/core/consysapi/controlstream/ControlStream"; import {isAdjudicationControlStream} from "@/lib/data/oscar/Utilities"; import { EventTableData } from "@/lib/data/oscar/TableHelpers"; +import { useLanguage } from '@/contexts/LanguageContext'; export function EventPreview() { + const { t } = useLanguage(); const dispatch = useAppDispatch(); const router = useRouter(); const eventPreview = useSelector(selectEventPreview); @@ -131,7 +133,7 @@ export function EventPreview() { // send to server const currentLane = eventPreview.eventData.laneId; - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); await submitAdjudication(currLaneEntry, comboData) } @@ -141,8 +143,13 @@ export function EventPreview() { const submitAdjudication = async(currLaneEntry: any, comboData: any) => { try{ - let ds = currLaneEntry.datastreams.find((ds: any) => ds.properties.id == eventPreview.eventData.dataStreamId); - let streams = currLaneEntry.controlStreams.length > 0 ? currLaneEntry.controlStreams : await currLaneEntry.parentNode.fetchNodeControlStreams(); + if (!currLaneEntry) { + console.error("Lane entry not found"); + return; + } + let ds = currLaneEntry.datastreams?.find((ds: any) => ds.properties.id == eventPreview.eventData.dataStreamId); + let streams = currLaneEntry.controlStreams?.length > 0 ? currLaneEntry.controlStreams : await currLaneEntry.parentNode?.fetchNodeControlStreams(); + if (!streams) return; let adjControlStream = streams.find((stream: typeof ControlStream) => isAdjudicationControlStream(stream)); if (!adjControlStream){ @@ -266,7 +273,7 @@ export function EventPreview() { let currentLane = eventPreview.eventData.laneId; - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); if (!currLaneEntry) { console.error("LaneMapEntry not found for:", currentLane); return; @@ -325,11 +332,11 @@ export function EventPreview() { - Occupancy ID: {eventPreview.eventData.occupancyCount} + {t('occupancyId')}: {eventPreview.eventData.occupancyCount} @@ -347,7 +354,7 @@ export function EventPreview() { { datasourcesReady ? ( diff --git a/src/app/_components/event-preview/LaneVideoPlayback.tsx b/src/app/_components/event-preview/LaneVideoPlayback.tsx index 584dcd85..7080cc24 100644 --- a/src/app/_components/event-preview/LaneVideoPlayback.tsx +++ b/src/app/_components/event-preview/LaneVideoPlayback.tsx @@ -9,6 +9,7 @@ import NavigateBeforeIcon from "@mui/icons-material/NavigateBefore"; import NavigateNextIcon from "@mui/icons-material/NavigateNext"; import '../../../Styles.css'; import CircularProgress from "@mui/material/CircularProgress"; +import { useLanguage } from '@/contexts/LanguageContext'; export class LaneVideoPlaybackProps { @@ -24,6 +25,7 @@ export class LaneVideoPlaybackProps { } export default function LaneVideoPlayback({selectedNode, videos, modeType, startTime, endTime, isPlaying, syncTime, onVideoTimeUpdate, onSelectedVideoIdxChange}: LaneVideoPlaybackProps) { + const { t } = useLanguage(); const [videoHeight, setVideoHeight] = useState("320px"); const videoRefs = useRef([]); const [videoDuration, setVideoDuration] = useState(0); @@ -175,7 +177,7 @@ export default function LaneVideoPlayback({selectedNode, videos, modeType, start playsInline > - Your browser does not support the video tag. + {t('yourBrowserDoesNotSupportVideoTag')} ); })} @@ -192,7 +194,7 @@ export default function LaneVideoPlayback({selectedNode, videos, modeType, start ) : ( - No Video Available + {t('noVideoAvailable')} )} diff --git a/src/app/_components/event-table/EventTable.tsx b/src/app/_components/event-table/EventTable.tsx index 63d72bab..0077c3d3 100644 --- a/src/app/_components/event-table/EventTable.tsx +++ b/src/app/_components/event-table/EventTable.tsx @@ -30,7 +30,6 @@ import { useRouter } from "next/dist/client/components/navigation"; import { getObservations } from "@/app/utils/ChartUtils"; import { isOccupancyDataStream, isThresholdDataStream } from "@/lib/data/oscar/Utilities"; import { convertToMap, hashString } from "@/app/utils/Utils"; -import { OCCUPANCY_PILLAR_DEF } from "@/lib/data/Constants"; import ConSysApi from "osh-js/source/core/datasource/consysapi/ConSysApi.datasource"; import { selectNodes } from "@/lib/state/OSHSlice"; import { EventType } from "osh-js/source/core/event/EventType"; @@ -155,7 +154,21 @@ export default function EventTable({ minWidth: 125, flex: 1.2, type: 'singleSelect', - valueOptions: ['None', 'Gamma', 'Neutron', 'Gamma & Neutron'], + valueOptions: [ + { value: 'None', label: t('statusNone') }, + { value: 'Gamma', label: t('statusGamma') }, + { value: 'Neutron', label: t('statusNeutron') }, + { value: 'Gamma & Neutron', label: t('statusGammaNeutron') } + ], + valueFormatter: (params) => { + switch(params) { + case 'Gamma': return t('statusGamma'); + case 'Neutron': return t('statusNeutron'); + case 'Gamma & Neutron': return t('statusGammaNeutron'); + case 'None': return t('statusNone'); + default: return params; + } + }, filterOperators: getGridSingleSelectOperators().filter( (op) => ['is'].includes(op.value) // (op) => ['is', 'not'].includes(op.value) @@ -164,12 +177,15 @@ export default function EventTable({ { field: 'adjudicatedIds', headerName: t('adjudicated'), - valueFormatter: (params: any) => params.length > 0 ? "Yes" : "No", + valueFormatter: (params: any) => params.length > 0 ? t('yes') : t('no'), minWidth: 100, flex: 1, filterable: viewAdjudicated, type: 'singleSelect', - valueOptions: ['Yes', 'No'], + valueOptions: [ + { value: 'Yes', label: t('yes') }, + { value: 'No', label: t('no') } + ], filterOperators: getGridSingleSelectOperators().filter( (op) => ['is', 'equal'].includes(op.value) ) @@ -185,7 +201,7 @@ export default function EventTable({ } - label="Details" + label={t('details')} onClick={() => handleEventPreview()} showInMenu /> @@ -214,7 +230,7 @@ export default function EventTable({ return; - const occStreams = entry.datastreams.filter((ds: typeof DataStream) => isOccupancyDataStream(ds)); + const occStreams = entry.datastreams?.filter((ds: typeof DataStream) => isOccupancyDataStream(ds)) || []; for (const ds of occStreams) { datastreamIds.push(ds.properties.id); } @@ -224,7 +240,7 @@ export default function EventTable({ if (entry.parentNode.id !== node.id) return; - const occStreams = entry.datastreams.filter((ds: typeof DataStream) => isOccupancyDataStream(ds)); + const occStreams = entry.datastreams?.filter((ds: typeof DataStream) => isOccupancyDataStream(ds)) || []; for (const ds of occStreams) { datastreamIds.push(ds.properties.id); } @@ -352,7 +368,7 @@ export default function EventTable({ function findLaneByDataStreamId(laneMap: Map, datastreamId: string): LaneMapEntry | null { for (const entry of laneMap.values()) { - if (entry.datastreams.some(ds => ds.properties.id === datastreamId)) { + if (entry.datastreams?.some(ds => ds.properties.id === datastreamId)) { return entry; } } @@ -436,7 +452,7 @@ export default function EventTable({ const connectedSources: typeof ConSysApi[] = []; for (const entry of stableLaneMap.values()) { - const occStream: typeof DataStream = entry.findDataStreamByObsProperty(OCCUPANCY_PILLAR_DEF); + const occStream: typeof DataStream = entry.datastreams?.find((ds: typeof DataStream) => isOccupancyDataStream(ds)); if (!occStream) { continue; @@ -544,7 +560,7 @@ export default function EventTable({ async function getLatestGB(eventData: any) { for (const lane of laneMap.values()) { - let datastreams = lane.datastreams.filter((ds: any) => isThresholdDataStream(ds)); + let datastreams = lane.datastreams?.filter((ds: any) => isThresholdDataStream(ds)) || []; let gammaThreshDs = datastreams.find((ds: typeof DataStream) => ds.properties["system@id"] === eventData.rpmSystemId ); diff --git a/src/app/_components/lane-view/HLSVideoComponent.tsx b/src/app/_components/lane-view/HLSVideoComponent.tsx index ef0a56ff..732cadbb 100644 --- a/src/app/_components/lane-view/HLSVideoComponent.tsx +++ b/src/app/_components/lane-view/HLSVideoComponent.tsx @@ -24,16 +24,15 @@ export default function HLSVideoComponent({videoSource, selectedNode}: {videoSou const Hls = (await import('hls.js')).default; - const encoded = btoa(`${selectedNode.auth.username}:${selectedNode.auth.password}`); - - - const hlsjsConfig = { - xhrSetup: function (xhr: XMLHttpRequest, url: string) { + let hlsjsConfig: any = {}; + if (selectedNode.auth && selectedNode.auth?.username) { + const encoded = btoa(`${selectedNode.auth?.username}:${selectedNode.auth?.password}`); + hlsjsConfig.xhrSetup = function (xhr: XMLHttpRequest, url: string) { xhr.setRequestHeader("Authorization", `Basic ${encoded}`); xhr.setRequestHeader("Cache-Control", "no-cache"); xhr.withCredentials = true; - }, - }; + }; + } if (Hls.isSupported()) { diff --git a/src/app/_components/lane-view/LaneStatus.tsx b/src/app/_components/lane-view/LaneStatus.tsx index 170296e3..b12e16c3 100644 --- a/src/app/_components/lane-view/LaneStatus.tsx +++ b/src/app/_components/lane-view/LaneStatus.tsx @@ -77,7 +77,12 @@ export default function LaneStatus(props: LaneStatusProps) { const gammaDataStream = await dsAPI.getDataStreamById(gammaDataStreamId); const latestObservationQuery = await gammaDataStream.searchObservations(new ObservationFilter({resultTime: 'latest'}), 1); const latestObservationArray = await latestObservationQuery.nextPage(); - const latestObservation = latestObservationArray[0]; + const latestObservation = latestObservationArray?.[0]; + + if (!latestObservation || !latestObservation.result) { + console.warn("No latest observation found for lane status initialization"); + return; + } const initialLaneStatus: LaneStatusType = { id: -1, diff --git a/src/app/_components/lane-view/Media.tsx b/src/app/_components/lane-view/Media.tsx index 4735a2af..da8c20e3 100644 --- a/src/app/_components/lane-view/Media.tsx +++ b/src/app/_components/lane-view/Media.tsx @@ -40,11 +40,12 @@ export default function Media({datasources, currentLane}: {datasources: any, cur return; const startStream = async () => { - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); + if (!currLaneEntry) return; const response = await sendCommand(currLaneEntry.parentNode, currentStream.properties.id, generateHLSVideoCommandJSON(true)); - if (!response.ok) { + if (!response?.ok) { console.error("Failed to start stream"); return; } @@ -58,7 +59,8 @@ export default function Media({datasources, currentLane}: {datasources: any, cur } const stopPreviousStream = async () => { - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); + if (!currLaneEntry) return; const prevStream = videoStreams[currentPage - 1]; if (!prevStream) @@ -70,18 +72,36 @@ export default function Media({datasources, currentLane}: {datasources: any, cur stopPreviousStream().then(startStream); return () => { - sendCommand(laneMapRef.current.get(currentLane).parentNode, currentStream.properties.id, generateHLSVideoCommandJSON(false)); + const currLaneEntry = laneMapRef.current?.get(currentLane); + if (currLaneEntry) { + sendCommand(currLaneEntry.parentNode, currentStream.properties.id, generateHLSVideoCommandJSON(false)); + } } }, [currentPage, videoStreams]); const fetchVideoControlStreams = async () => { - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); + + if (!currLaneEntry) { + console.error("no current lane entry found"); + return; + } - let videoControlStreams = currLaneEntry.controlStreams.filter((stream: typeof ControlStream) => isHLSVideoControlStream(stream)); + let streams = currLaneEntry.controlStreams; + if (!streams || streams.length === 0) { + try { + streams = await currLaneEntry.parentNode?.fetchNodeControlStreams(); + } catch (error) { + console.error("Failed to fetch control streams", error); + streams = []; + } + } + + let videoControlStreams = streams.filter((stream: typeof ControlStream) => isHLSVideoControlStream(stream)); if (!videoControlStreams || videoControlStreams.length == 0){ console.error("no video control stream"); - throw new LiveVideoError("No video control stream available."); + return; } let uniqueVideoControlStreams = videoControlStreams.reduce((acc: typeof ControlStream[], stream: typeof ControlStream) => { @@ -176,7 +196,7 @@ export default function Media({datasources, currentLane}: {datasources: any, cur {videoSource && laneMapRef.current?.get(currentLane)?.parentNode && ( )} diff --git a/src/app/_components/lane-view/StatusTable.tsx b/src/app/_components/lane-view/StatusTable.tsx index 46633abf..c9328383 100644 --- a/src/app/_components/lane-view/StatusTable.tsx +++ b/src/app/_components/lane-view/StatusTable.tsx @@ -23,6 +23,7 @@ import DataStream from "osh-js/source/core/sweapi/datastream/DataStream"; import {ALARM_DEF, TAMPER_STATUS_DEF} from "@/lib/data/Constants"; import {EventType} from "osh-js/source/core/event/EventType"; import {convertToMap} from "@/app/utils/Utils"; +import {useLanguage} from "@/contexts/LanguageContext"; interface StatusTableProps { currentLane: string, @@ -30,6 +31,7 @@ interface StatusTableProps { } export default function StatusTable({currentLane, entry}: StatusTableProps){ + const { t } = useLanguage(); const locale = navigator.language || 'en-US'; const nodes = useSelector(selectNodes); @@ -47,14 +49,14 @@ export default function StatusTable({currentLane, entry}: StatusTableProps){ const columns: GridColDef[] = [ { field: 'laneId', - headerName: 'Lane ID', + headerName: t('laneId'), type: 'string', minWidth: 100, flex: 1, }, { field: 'timestamp', - headerName: 'Timestamp', + headerName: t('timestamp'), valueFormatter: (params) => (new Date(params)).toLocaleString(locale, { year: 'numeric', month: 'numeric', @@ -68,7 +70,7 @@ export default function StatusTable({currentLane, entry}: StatusTableProps){ }, { field: 'status', - headerName: 'Status', + headerName: t('status'), type: 'string', minWidth: 150, flex: 1, @@ -76,6 +78,7 @@ export default function StatusTable({currentLane, entry}: StatusTableProps){ ]; async function fetchTotalCount(node: INode, datastreamIds: string[]) { + if (!node) return 0; let endpoint = node.getConnectedSystemsEndpoint(false); const queryParams = new URLSearchParams({ // resultTime: `../${pageLoadedTime}`, I think it is safe to fetch count of all here @@ -119,7 +122,7 @@ export default function StatusTable({currentLane, entry}: StatusTableProps){ newEvent = new AlarmTableData(randomUUID(), currentLane, state, obs.samplingTime); } } else if (obs?.tamperStatus) { - newEvent = new AlarmTableData(randomUUID(), currentLane, "Tamper", obs.samplingtime); + newEvent = new AlarmTableData(randomUUID(), currentLane, "Tamper", obs.samplingTime); } } else { const result = obs?.properties?.result; @@ -137,14 +140,14 @@ export default function StatusTable({currentLane, entry}: StatusTableProps){ const getDatastreamIds = useCallback((node: INode) => { const datastreamIds: string[] = []; - if (!entry) return datastreamIds; + if (!entry || !node) return datastreamIds; if (entry.parentNode.id !== node.id) return datastreamIds; - const datastreams: typeof DataStream[] = entry.datastreams.filter( + const datastreams: typeof DataStream[] = entry.datastreams?.filter( (ds: any) => isGammaDataStream(ds) || isNeutronDataStream(ds) || isTamperDataStream(ds) - ); + ) || []; for (const ds of datastreams) { datastreamIds.push(ds.properties.id); diff --git a/src/app/_components/maps/MapComponent.tsx b/src/app/_components/maps/MapComponent.tsx index 84b63da9..cb377513 100644 --- a/src/app/_components/maps/MapComponent.tsx +++ b/src/app/_components/maps/MapComponent.tsx @@ -25,9 +25,10 @@ import {INode} from "@/lib/data/osh/Node"; import ObservationFilter from "osh-js/source/core/consysapi/observation/ObservationFilter"; import { convertToMap } from "@/app/utils/Utils"; import DataStreamFilter from "osh-js/source/core/consysapi/datastream/DataStreamFilter.js"; - +import { useLanguage } from '@/contexts/LanguageContext'; export default function MapComponent() { + const { t } = useLanguage(); const mapcontainer: string = "mapcontainer"; const laneMap = useSelector((state: RootState) => selectLaneMap(state)); const leafletViewRef = useRef(null); @@ -78,9 +79,10 @@ export default function MapComponent() { for (let [laneid, lane] of laneMapRef.current.entries()) { laneDSMap.set(laneid, new LaneDSColl()); - for (let ds of lane.datastreams) { + const datastreams = lane.datastreams || []; + for (let ds of datastreams) { - let idx: number = lane.datastreams.indexOf(ds); + let idx: number = datastreams.indexOf(ds); let rtDS = lane.datasourcesRealtime[idx]; let batchDS = lane.datasourcesBatch[idx]; let laneDSColl = laneDSMap.get(laneid); @@ -320,8 +322,8 @@ export default function MapComponent() { return ( `` ); } diff --git a/src/app/_components/national/NationalDatePicker.tsx b/src/app/_components/national/NationalDatePicker.tsx index aa59f799..67905b95 100644 --- a/src/app/_components/national/NationalDatePicker.tsx +++ b/src/app/_components/national/NationalDatePicker.tsx @@ -3,12 +3,14 @@ import {DateTimePicker, LocalizationProvider} from "@mui/x-date-pickers"; import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; import React, {useEffect, useState} from "react"; import dayjs, {Dayjs} from "dayjs"; +import { useLanguage } from '@/contexts/LanguageContext'; export default function NationalDatePicker({onCustomStartChange, onCustomEndChange }: { onCustomStartChange?: (value: string) => void, onCustomEndChange?: (value: string) => void, }){ + const { t } = useLanguage(); const [startTime, setStartTime] = useState(dayjs().subtract(1, 'year')); const [endTime, setEndTime] = useState(dayjs().add(1, 'hour')); @@ -38,12 +40,12 @@ export default function NationalDatePicker({onCustomStartChange, onCustomEndChan diff --git a/src/app/_components/national/NationalStatsTable.tsx b/src/app/_components/national/NationalStatsTable.tsx index fd8ff55e..62b1c1b7 100644 --- a/src/app/_components/national/NationalStatsTable.tsx +++ b/src/app/_components/national/NationalStatsTable.tsx @@ -5,9 +5,11 @@ import { NationalTableDataCollection} from "@/lib/data/oscar/TableHelpers"; import {DataGrid, GridColDef} from "@mui/x-data-grid"; import {Box} from "@mui/material"; import CustomToolbar from "@/app/_components/CustomToolbar"; +import { useLanguage } from '@/contexts/LanguageContext'; export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCounts: INationalTableData[]}){ + const { t } = useLanguage(); const natlTableRef = useRef(new NationalTableDataCollection()); useEffect(() => { @@ -21,14 +23,14 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou const columns: GridColDef[] = [ { field: 'site', - headerName: 'Node ID', + headerName: t('nodeId'), type: 'string', minWidth: 150, flex: 1, }, { field: 'numGammaAlarms', - headerName: 'G Alarm', + headerName: t('alarm.gamma'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, @@ -37,7 +39,7 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou }, { field: 'numNeutronAlarms', - headerName: 'N Alarm', + headerName: t('alarm.neutron'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, @@ -46,7 +48,7 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou }, { field: 'numGammaNeutronAlarms', - headerName: 'G-N Alarm', + headerName: t('alarm.gammaNeutron'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, @@ -55,7 +57,7 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou }, { field: 'numOccupancies', - headerName: 'Occupancies', + headerName: t('occupancies'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, @@ -64,7 +66,7 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou }, { field: 'numTampers', - headerName: 'Tamper', + headerName: t('tamper'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, @@ -73,7 +75,7 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou }, { field: 'numGammaFaults', - headerName: 'G Faults', + headerName: t('faults.gamma'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, @@ -82,7 +84,7 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou }, { field: 'numNeutronFaults', - headerName: 'N Faults', + headerName: t('faults.neutron'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, @@ -91,7 +93,7 @@ export default function StatTable(selectedTimeRangeCounts: {selectedTimeRangeCou }, { field: 'numFaults', - headerName: 'Faults', + headerName: t('faults'), valueFormatter: (value) => { return typeof value === 'number' ? value : 0; }, diff --git a/src/app/_components/national/TimeRangeSelector.tsx b/src/app/_components/national/TimeRangeSelector.tsx index e5bda7e7..d9e0ca8d 100644 --- a/src/app/_components/national/TimeRangeSelector.tsx +++ b/src/app/_components/national/TimeRangeSelector.tsx @@ -2,34 +2,36 @@ import {FormControl, InputLabel, MenuItem, Select, SelectChangeEvent} from '@mui/material'; import {useState} from 'react'; - -const timeRanges = [ - { - label: "All Time", - value: "allTime", - }, - { - label: "Monthly", - value: "monthly", - }, - { - label: "Weekly", - value: 'weekly' - }, - { - label: "Daily", - value: "daily" - }, - { - label: "Custom Range", - value: "custom" - } -] +import { useLanguage } from '@/contexts/LanguageContext'; export default function TimeRangeSelect(props: { onSelect: (value: string[] | string) => void, timeRange: string }) { + const { t } = useLanguage(); + + const timeRanges = [ + { + label: t('allTime'), + value: "allTime", + }, + { + label: t('monthly'), + value: "monthly", + }, + { + label: t('weekly'), + value: 'weekly' + }, + { + label: t('daily'), + value: "daily" + }, + { + label: t('customRange'), + value: "custom" + } + ] const handleChange = (event: SelectChangeEvent) => { const val = event.target.value; @@ -38,11 +40,11 @@ export default function TimeRangeSelect(props: { return ( - Time Range + {t('timeRange')} selectLaneMap(state)); @@ -52,11 +53,11 @@ export default function LaneSelect(props: { return ( - Lane Selector + {t('laneSelector')} (""); @@ -208,7 +210,7 @@ export default function ReportGeneratorView(){ - Generate A Report + {t('generateReport')} @@ -243,7 +245,7 @@ export default function ReportGeneratorView(){ onClick={handleGenerateReport} disabled={isGenerating || !selectedReportType || !selectedTimeRange || !selectedNode} > - {isGenerating ? 'Generating Report...' : 'Generate Report'} + {isGenerating ? t('generatingReport') : t('generateReportBtn')} @@ -252,7 +254,7 @@ export default function ReportGeneratorView(){ - Generated Report + {t('generatedReport')} {generatedURL ? ( diff --git a/src/app/_components/reportgen/ReportTypeSelector.tsx b/src/app/_components/reportgen/ReportTypeSelector.tsx index 7c3c9905..812ab392 100644 --- a/src/app/_components/reportgen/ReportTypeSelector.tsx +++ b/src/app/_components/reportgen/ReportTypeSelector.tsx @@ -2,31 +2,33 @@ import {FormControl, InputLabel, MenuItem, Select, SelectChangeEvent} from '@mui/material'; import {useState} from "react"; +import { useLanguage } from '@/contexts/LanguageContext'; -export const reportTypes = [ - { - label: "RDS Site Report", - value: "RDS_SITE", - }, - { - label: "Lane Report", - value: "LANE", - }, - { - label: "Adjudication Report", - value: "ADJUDICATION", - }, - { - label: "Event Report", - value: "EVENT", - } -] export default function ReportTypeSelect(props: { onSelect: (value: string[] | string) => void, report: string }) { + const { t } = useLanguage(); + const reportTypes = [ + { + label: t('rdsSiteReport'), + value: "RDS_SITE", + }, + { + label: t('laneReport'), + value: "LANE", + }, + { + label: t('adjudicationReport'), + value: "ADJUDICATION", + }, + { + label: t('eventReport'), + value: "EVENT", + } + ] const handleChange = (event: SelectChangeEvent) => { const val = event.target.value; @@ -35,11 +37,11 @@ export default function ReportTypeSelect(props: { return ( - Report Type + {t('reportType')} {isEditNode ? Editing Node: {editNode.id} : null} - - - + + + - - + - } label="Is Secure"/> + } label={t('isSecure')}/> + onClick={() => modeChangeCallback(false, null)}>{t('cancel')} diff --git a/src/app/_components/servers/NodeList.tsx b/src/app/_components/servers/NodeList.tsx index 396f0f3a..978dd096 100644 --- a/src/app/_components/servers/NodeList.tsx +++ b/src/app/_components/servers/NodeList.tsx @@ -72,7 +72,7 @@ export default function NodeList({modeChangeCallback}: NodeListProps) { { t('nodes') } {nodes.length === 0 ? ( -

No Nodes

+

{t('noNodes')}

) : ( {nodes.map((node: INode) => ( @@ -86,10 +86,10 @@ export default function NodeList({modeChangeCallback}: NodeListProps) { sx={{m: 1}} onClick={() => setEditNode(node)} > - Edit + {t('edit')} + onClick={() => deleteNode(node.id)}>{t('delete')} ))} diff --git a/src/app/contexts/DataSourceContext.tsx b/src/app/contexts/DataSourceContext.tsx index 4dec8521..72833d91 100644 --- a/src/app/contexts/DataSourceContext.tsx +++ b/src/app/contexts/DataSourceContext.tsx @@ -53,22 +53,26 @@ export default function DataSourceProvider({children}: { children: ReactNode }) let allLanes: Map = new Map(); await Promise.all(nodes.map(async (node: INode) => { - let nodeLaneMap = await node.fetchLaneSystemsAndSubsystems(); - if(!nodeLaneMap) return; + try { + let nodeLaneMap = await node.fetchLaneSystemsAndSubsystems(); + if(!nodeLaneMap) return; - await node.fetchDataStreams(nodeLaneMap); - await node.fetchLaneControlStreams(nodeLaneMap); + await node.fetchDataStreams(nodeLaneMap); + await node.fetchLaneControlStreams(nodeLaneMap); - for (const [key, mapEntry] of nodeLaneMap.entries()) { - try { - mapEntry.addDefaultConSysApis(); - } catch (e) { - console.error(`[ERROR] addDefaultConSysApis failed for ${key}:`, e); + for (const [key, mapEntry] of nodeLaneMap.entries()) { + try { + mapEntry.addDefaultConSysApis(); + } catch (e) { + console.error(`[ERROR] addDefaultConSysApis failed for ${key}:`, e); + } } - } - nodeLaneMap.forEach((value: LaneMapEntry, key: string) =>allLanes.set(key,value)); + nodeLaneMap.forEach((value: LaneMapEntry, key: string) =>allLanes.set(key,value)); + } catch (e) { + console.error(`[ERROR] Failed to fetch data for node ${node.name}:`, e); + } })); dispatch(setLaneMap(allLanes)); @@ -99,7 +103,6 @@ export const initializeDefaultNode = () => (dispatch: AppDispatch) => { port: 8282, oshPathRoot: "/sensorhub", csAPIEndpoint: "/api", - auth: { username: "admin", password: "oscar" }, isSecure: false, isDefaultNode: true }; diff --git a/src/app/event-details/page.tsx b/src/app/event-details/page.tsx index 7675fe87..e0a21332 100644 --- a/src/app/event-details/page.tsx +++ b/src/app/event-details/page.tsx @@ -39,7 +39,7 @@ export default function EventDetailsPage() { let currentLane = eventPreview.eventData.laneId; - const currLaneEntry: LaneMapEntry = laneMapRef.current.get(currentLane); + const currLaneEntry: LaneMapEntry = laneMapRef.current?.get(currentLane); if (!currLaneEntry) { console.error("LaneMapEntry not found for:", currentLane); return; @@ -97,7 +97,7 @@ export default function EventDetailsPage() { }); return ( - + @@ -115,7 +115,7 @@ export default function EventDetailsPage() { reactToPrintFn() }} > - Export as PDF + {t('exportPdf')} @@ -126,7 +126,7 @@ export default function EventDetailsPage() { { datasourcesReady ? ( ); -} \ No newline at end of file +} diff --git a/src/app/lane-view/page.tsx b/src/app/lane-view/page.tsx index 7e04b1a0..6f49e704 100644 --- a/src/app/lane-view/page.tsx +++ b/src/app/lane-view/page.tsx @@ -45,8 +45,8 @@ export default function LaneViewPage() { const toggleButtons = [ - Occupancy Table, - Fault Table + {t('occupancyTable')}, + {t('faultTable')} ]; const handleToggle = (event: React.MouseEvent, newView: string) =>{ @@ -58,7 +58,7 @@ export default function LaneViewPage() { let laneDsCollection = new LaneDSColl(); - const lane = laneMapRef.current.get(currentLane); + const lane = laneMapRef.current?.get(currentLane); if (!lane) { console.warn("Lane not found for currentLane:", currentLane); @@ -67,30 +67,33 @@ export default function LaneViewPage() { setEntry(lane); - for(let i = 0; i < lane.datastreams.length; i++) { - const ds = lane.datastreams[i] + const datastreams = lane.datastreams || []; + for(let i = 0; i < datastreams.length; i++) { + const ds = datastreams[i] const rtDS = lane.datasourcesRealtime[i]; - if (isGammaDataStream(ds)) { - rtDS.properties.mqttOpts.shared = true - laneDsCollection.addDS('gammaRT', rtDS); - setGammaDS(rtDS) - } - if (isNeutronDataStream(ds)) { - rtDS.properties.mqttOpts.shared = true - laneDsCollection.addDS('neutronRT', rtDS); - setNeutronDS(rtDS); - } - if (isTamperDataStream(ds)) { - rtDS.properties.mqttOpts.shared = true - laneDsCollection.addDS('tamperRT', rtDS); - setTamperDS(rtDS) - } - if (isThresholdDataStream(ds)) { - rtDS.properties.mqttOpts.shared = true - laneDsCollection?.addDS('gammaTrshldRT', rtDS); - setThresholdDS(rtDS); + if (rtDS) { + if (isGammaDataStream(ds)) { + rtDS.properties.mqttOpts.shared = true + laneDsCollection.addDS('gammaRT', rtDS); + setGammaDS(rtDS) + } + if (isNeutronDataStream(ds)) { + rtDS.properties.mqttOpts.shared = true + laneDsCollection.addDS('neutronRT', rtDS); + setNeutronDS(rtDS); + } + if (isTamperDataStream(ds)) { + rtDS.properties.mqttOpts.shared = true + laneDsCollection.addDS('tamperRT', rtDS); + setTamperDS(rtDS) + } + if (isThresholdDataStream(ds)) { + rtDS.properties.mqttOpts.shared = true + laneDsCollection?.addDS('gammaTrshldRT', rtDS); + setThresholdDS(rtDS); + } } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8b87d373..4912ac31 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import {CssBaseline} from "@mui/material" +import {CssBaseline, Link} from "@mui/material" import Navbar from "./_components/Navbar" import Providers from "./providers" import StoreProvider from "@/app/StoreProvider"; @@ -11,7 +11,27 @@ export default function RootLayout({children,}: { return ( + + + + + +