diff --git a/backend/api/Controllers/Models/AlertResponse.cs b/backend/api/Controllers/Models/AlertResponse.cs new file mode 100644 index 000000000..94c4b3cff --- /dev/null +++ b/backend/api/Controllers/Models/AlertResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +namespace Api.Controllers.Models +{ + [method: JsonConstructor] + public class AlertResponse(string code, string name, string message, string installationCode, string? robotId) + { + public string AlertCode { get; set; } = code; + public string AlertName { get; set; } = name; + public string AlertMessage { get; set; } = message; + public string InstallationCode { get; set; } = installationCode; + public string? RobotId { get; set; } = robotId; + } +} diff --git a/backend/api/Database/Context/InitDb.cs b/backend/api/Database/Context/InitDb.cs index 2cf8a23ff..afec59495 100644 --- a/backend/api/Database/Context/InitDb.cs +++ b/backend/api/Database/Context/InitDb.cs @@ -241,7 +241,7 @@ private static List GetAreas() Name = "HB", MapMetadata = new MapMetadata(), DefaultLocalizationPose = new DefaultLocalizationPose(), - SafePositions = new List() + SafePositions = new List(new[] { new SafePosition() }) }; return new List(new[] diff --git a/backend/api/EventHandlers/MissionEventHandler.cs b/backend/api/EventHandlers/MissionEventHandler.cs index b600d0c46..edbfb16fd 100644 --- a/backend/api/EventHandlers/MissionEventHandler.cs +++ b/backend/api/EventHandlers/MissionEventHandler.cs @@ -1,4 +1,5 @@ -using Api.Database.Models; +using Api.Controllers.Models; +using Api.Database.Models; using Api.Services; using Api.Services.Events; using Api.Utilities; @@ -38,6 +39,8 @@ IServiceScopeFactory scopeFactory private IMissionSchedulingService MissionScheduling => _scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + private ISignalRService SignalRService => _scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + public override void Subscribe() { MissionRunService.MissionRunCreated += OnMissionRunCreated; @@ -134,8 +137,14 @@ private async void OnRobotAvailable(object? sender, RobotAvailableEventArgs e) await RobotService.UpdateCurrentArea(robot.Id, null); return; } + try { await ReturnToHomeService.ScheduleReturnToHomeMissionRun(robot.Id); } - catch (Exception ex) when (ex is RobotNotFoundException or AreaNotFoundException or DeckNotFoundException or PoseNotFoundException) { await RobotService.UpdateCurrentArea(robot.Id, null); } + catch (Exception ex) when (ex is RobotNotFoundException or AreaNotFoundException or DeckNotFoundException or PoseNotFoundException) + { + ReportFailureToSignalR(robot, $"Failed to send {robot.Name} to a safe zone"); + await RobotService.UpdateCurrentArea(robot.Id, null); + return; + } return; } @@ -155,6 +164,16 @@ private async void OnRobotAvailable(object? sender, RobotAvailableEventArgs e) _scheduleMissionSemaphore.Release(); } + private void ReportFailureToSignalR(Robot robot, string message) + { + var installation = robot.CurrentInstallation; + if (installation != null) + _ = SignalRService.SendMessageAsync( + "Alert", + installation, + new AlertResponse("safezoneFailure", "Safezone failure", message, installation.InstallationCode, robot.Id)); + } + private async void OnEmergencyButtonPressedForRobot(object? sender, EmergencyButtonPressedForRobotEventArgs e) { _logger.LogInformation("Triggered EmergencyButtonPressed event for robot ID: {RobotId}", e.RobotId); @@ -169,6 +188,7 @@ private async void OnEmergencyButtonPressedForRobot(object? sender, EmergencyBut if (area == null) { _logger.LogError("Could not find area with ID {AreaId}", robot.CurrentArea!.Id); + ReportFailureToSignalR(robot, $"Robot {robot.Name} was not correctly localised. Could not find area {robot.CurrentArea.Name}"); return; } @@ -179,6 +199,7 @@ private async void OnEmergencyButtonPressedForRobot(object? sender, EmergencyBut catch (SafeZoneException ex) { _logger.LogError(ex, "Failed to schedule return to safe zone mission on robot {RobotName} because: {ErrorMessage}", robot.Name, ex.Message); + ReportFailureToSignalR(robot, $"Failed to send {robot.Name} to a safe zone"); try { await MissionScheduling.UnfreezeMissionRunQueueForRobot(e.RobotId); } catch (RobotNotFoundException) { return; } } @@ -195,12 +216,14 @@ private async void OnEmergencyButtonPressedForRobot(object? sender, EmergencyBut if (ex.IsarStatusCode != StatusCodes.Status409Conflict) { _logger.LogError(ex, "Failed to stop the current mission on robot {RobotName} because: {ErrorMessage}", robot.Name, ex.Message); + ReportFailureToSignalR(robot, $"Failed to stop current mission for robot {robot.Name}"); return; } } catch (Exception ex) { const string Message = "Error in ISAR while stopping current mission, cannot drive to safe position"; + ReportFailureToSignalR(robot, $"Robot {robot.Name} failed to drive to safe position"); _logger.LogError(ex, "{Message}", Message); return; } @@ -219,7 +242,11 @@ private async void OnEmergencyButtonDepressedForRobot(object? sender, EmergencyB } var area = await AreaService.ReadById(robot.CurrentArea!.Id); - if (area == null) { _logger.LogError("Could not find area with ID {AreaId}", robot.CurrentArea!.Id); } + if (area == null) + { + _logger.LogError("Could not find area with ID {AreaId}", robot.CurrentArea!.Id); + ReportFailureToSignalR(robot, $"Robot {robot.Name} could not be sent from safe zone as it is not correctly localised"); + } try { await MissionScheduling.UnfreezeMissionRunQueueForRobot(e.RobotId); } catch (RobotNotFoundException) { return; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a5738507c..5c7bac1ee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,28 +7,42 @@ import { MissionFilterProvider } from 'components/Contexts/MissionFilterContext' import { MissionsProvider } from 'components/Contexts/MissionListsContext' import { SafeZoneProvider } from 'components/Contexts/SafeZoneContext' import { AlertProvider } from 'components/Contexts/AlertContext' +import { InstallationProvider } from 'components/Contexts/InstallationContext' +import { AuthProvider } from 'components/Contexts/AuthProvider' +import { SignalRProvider } from 'components/Contexts/SignalRContext' +import { RobotProvider } from 'components/Contexts/RobotContext' const App = () => ( - - - - - - -
- -
-
- - - - - -
-
-
-
-
+ + + + + + + + + + + +
+ +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
) export default App diff --git a/frontend/src/components/Alerts/FailedSafeZoneAlertContent.tsx b/frontend/src/components/Alerts/FailedSafeZoneAlertContent.tsx new file mode 100644 index 000000000..04cc18836 --- /dev/null +++ b/frontend/src/components/Alerts/FailedSafeZoneAlertContent.tsx @@ -0,0 +1,36 @@ +import { Button, Icon, Typography } from '@equinor/eds-core-react' +import styled from 'styled-components' +import { useLanguageContext } from 'components/Contexts/LanguageContext' +import { Icons } from 'utils/icons' +import { tokens } from '@equinor/eds-tokens' + +const StyledDiv = styled.div` + align-items: center; +` + +const StyledAlertTitle = styled.div` + display: flex; + gap: 0.3em; + align-items: flex-end; +` + +const Indent = styled.div` + padding: 0px 9px; +` + +export const FailedSafeZoneAlertContent = ({ message }: { message: string }) => { + const { TranslateText } = useLanguageContext() + return ( + + + + {TranslateText('Safe zone failure')} + + + + + + ) +} diff --git a/frontend/src/components/Contexts/AlertContext.tsx b/frontend/src/components/Contexts/AlertContext.tsx index d11d5cb63..71d3ca9ac 100644 --- a/frontend/src/components/Contexts/AlertContext.tsx +++ b/frontend/src/components/Contexts/AlertContext.tsx @@ -5,13 +5,21 @@ import { FailedMissionAlertContent } from 'components/Alerts/FailedMissionAlert' import { BackendAPICaller } from 'api/ApiCaller' import { SignalREventLabels, useSignalRContext } from './SignalRContext' import { useInstallationContext } from './InstallationContext' +import { Alert } from 'models/Alert' +import { FailedSafeZoneAlertContent } from 'components/Alerts/FailedSafeZoneAlertContent' +import { useRobotContext } from './RobotContext' + +type AlertDictionaryType = { [key in AlertType]?: { content: ReactNode | undefined; dismissFunction: () => void } } export enum AlertType { MissionFail, RequestFail, + SafeZoneFail, } -type AlertDictionaryType = { [key in AlertType]?: { content: ReactNode | undefined; dismissFunction: () => void } } +const alertTypeEnumMap: { [key: string]: AlertType } = { + safezoneFailure: AlertType.SafeZoneFail, +} interface IAlertContext { alerts: AlertDictionaryType @@ -38,6 +46,7 @@ export const AlertProvider: FC = ({ children }) => { const [recentFailedMissions, setRecentFailedMissions] = useState([]) const { registerEvent, connectionReady } = useSignalRContext() const { installationCode } = useInstallationContext() + const { enabledRobots } = useRobotContext() const pageSize: number = 100 // The default amount of minutes in the past for failed missions to generate an alert @@ -95,7 +104,7 @@ export const AlertProvider: FC = ({ children }) => { // Register a signalR event handler that listens for new failed missions useEffect(() => { - if (connectionReady) + if (connectionReady) { registerEvent(SignalREventLabels.missionRunFailed, (username: string, message: string) => { const newFailedMission: Mission = JSON.parse(message) const lastDismissTime: Date = getLastDismissalTime() @@ -115,8 +124,31 @@ export const AlertProvider: FC = ({ children }) => { return [...failedMissions, newFailedMission] }) }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [registerEvent, connectionReady, installationCode]) + useEffect(() => { + if (connectionReady) { + registerEvent(SignalREventLabels.alert, (username: string, message: string) => { + const backendAlert: Alert = JSON.parse(message) + const alertType = alertTypeEnumMap[backendAlert.alertCode] + + if (backendAlert.robotId !== null) { + const relevantRobots = enabledRobots.filter((r) => r.id === backendAlert.robotId) + if (!relevantRobots) return + const relevantRobot = relevantRobots[0] + if (relevantRobot.currentInstallation.installationCode !== installationCode) return + + // Here we could update the robot state manually, but this is best done on the backend + } + + setAlert(alertType, ) + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [registerEvent, connectionReady, installationCode, enabledRobots]) + useEffect(() => { if (newFailedMissions.length > 0) { setAlert(AlertType.MissionFail, ) diff --git a/frontend/src/components/Contexts/SignalRContext.tsx b/frontend/src/components/Contexts/SignalRContext.tsx index f7fa9c3ee..e49e78787 100644 --- a/frontend/src/components/Contexts/SignalRContext.tsx +++ b/frontend/src/components/Contexts/SignalRContext.tsx @@ -110,4 +110,5 @@ export enum SignalREventLabels { robotUpdated = 'Robot updated', robotDeleted = 'Robot deleted', inspectionUpdated = 'Inspection updated', + alert = 'Alert', } diff --git a/frontend/src/components/Pages/FlotillaSite.tsx b/frontend/src/components/Pages/FlotillaSite.tsx index 26aa46be0..ca88800c2 100644 --- a/frontend/src/components/Pages/FlotillaSite.tsx +++ b/frontend/src/components/Pages/FlotillaSite.tsx @@ -2,60 +2,30 @@ import { config } from 'config' import { BrowserRouter, Route, Routes } from 'react-router-dom' import { FrontPage } from './FrontPage/FrontPage' import { MissionPage } from './MissionPage/MissionPage' -import { InstallationProvider } from 'components/Contexts/InstallationContext' import { MissionHistoryPage } from './MissionHistoryPage/MissionHistoryPage' import { RobotPage } from './RobotPage/RobotPage' -import { AuthProvider } from 'components/Contexts/AuthProvider' import { APIUpdater } from 'components/Contexts/APIUpdater' import { MissionDefinitionPage } from './MissionDefinitionPage/MissionDefinitionPage' import { AssetSelectionPage } from './AssetSelectionPage/AssetSelectionPage' -import { SignalRProvider } from 'components/Contexts/SignalRContext' -import { MissionsProvider } from 'components/Contexts/MissionListsContext' -import { RobotProvider } from 'components/Contexts/RobotContext' export const FlotillaSite = () => { return ( <> - - - - - - - - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - - - - - - - - + + + + } /> + } /> + } /> + } + /> + } /> + } /> + + + ) } diff --git a/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusSection.tsx b/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusSection.tsx index 17db3c697..dcf0c099f 100644 --- a/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusSection.tsx +++ b/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusSection.tsx @@ -1,6 +1,6 @@ import { Typography } from '@equinor/eds-core-react' import { Robot } from 'models/Robot' -import { useEffect, useState } from 'react' +import { useEffect } from 'react' import styled from 'styled-components' import { RobotStatusCard, RobotStatusCardPlaceholder } from './RobotStatusCard' import { useInstallationContext } from 'components/Contexts/InstallationContext' @@ -24,38 +24,20 @@ export const RobotStatusSection = () => { const { installationCode } = useInstallationContext() const { enabledRobots } = useRobotContext() const { switchSafeZoneStatus } = useSafeZoneContext() - const [robots, setRobots] = useState([]) - useEffect(() => { - const sortRobotsByStatus = (robots: Robot[]): Robot[] => { - const sortedRobots = robots.sort((robot, robotToCompareWith) => - robot.status! > robotToCompareWith.status! ? 1 : -1 - ) - return sortedRobots - } - const relevantRobots = sortRobotsByStatus( - enabledRobots.filter((robot) => { - return ( - robot.currentInstallation.installationCode.toLocaleLowerCase() === - installationCode.toLocaleLowerCase() - ) - }) + const relevantRobots = enabledRobots + .filter( + (robot) => + robot.currentInstallation.installationCode.toLocaleLowerCase() === installationCode.toLocaleLowerCase() ) - setRobots(relevantRobots) - - const missionQueueFozenStatus = relevantRobots - .map((robot: Robot) => { - return robot.missionQueueFrozen - }) - .filter((status) => status === true) + .sort((robot, robotToCompareWith) => (robot.status! > robotToCompareWith.status! ? 1 : -1)) - if (missionQueueFozenStatus.length > 0) switchSafeZoneStatus(true) - else switchSafeZoneStatus(false) - }, [enabledRobots, installationCode, switchSafeZoneStatus]) + useEffect(() => { + const missionQueueFozenStatus = relevantRobots.some((robot: Robot) => robot.missionQueueFrozen) + switchSafeZoneStatus(missionQueueFozenStatus) + }, [enabledRobots, installationCode, switchSafeZoneStatus, relevantRobots]) - const getRobotDisplay = () => { - return robots.map((robot) => ) - } + const robotDisplay = relevantRobots.map((robot) => ) return ( @@ -63,8 +45,8 @@ export const RobotStatusSection = () => { {TranslateText('Robot Status')} - {robots.length > 0 && getRobotDisplay()} - {robots.length === 0 && } + {relevantRobots.length > 0 && robotDisplay} + {relevantRobots.length === 0 && } ) diff --git a/frontend/src/models/Alert.ts b/frontend/src/models/Alert.ts new file mode 100644 index 000000000..4ac30f9d6 --- /dev/null +++ b/frontend/src/models/Alert.ts @@ -0,0 +1,6 @@ +export interface Alert { + alertCode: string + alertName: string + alertMessage: string + robotId?: string +}