Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add alert for failed requests #1018

Merged
merged 7 commits into from
Nov 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,31 @@ import { MissionControlProvider } from 'components/Contexts/MissionControlContex
import { MissionFilterProvider } from 'components/Contexts/MissionFilterContext'
import { MissionsProvider } from 'components/Contexts/MissionListsContext'
import { SafeZoneProvider } from 'components/Contexts/SafeZoneContext'
import { AlertProvider } from 'components/Contexts/AlertContext'

function App() {
return (
<SafeZoneProvider>
<MissionsProvider>
<LanguageProvider>
<MissionControlProvider>
<>
<UnauthenticatedTemplate>
<div className="sign-in-page">
<AssetSelectionPage></AssetSelectionPage>
</div>
</UnauthenticatedTemplate>
<AuthenticatedTemplate>
<MissionFilterProvider>
<FlotillaSite />
</MissionFilterProvider>
</AuthenticatedTemplate>
</>
</MissionControlProvider>
</LanguageProvider>
</MissionsProvider>
<AlertProvider>
<MissionsProvider>
<LanguageProvider>
<MissionControlProvider>
<>
<UnauthenticatedTemplate>
<div className="sign-in-page">
<AssetSelectionPage></AssetSelectionPage>
</div>
</UnauthenticatedTemplate>
<AuthenticatedTemplate>
<MissionFilterProvider>
<FlotillaSite />
</MissionFilterProvider>
</AuthenticatedTemplate>
</>
</MissionControlProvider>
</LanguageProvider>
</MissionsProvider>
</AlertProvider>
</SafeZoneProvider>
)
}
Expand Down
11 changes: 1 addition & 10 deletions frontend/src/api/ApiCaller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export class BackendAPICaller {
return { pagination: pagination, content: result.content }
}

static async getAvailableEchoMission(installationCode: string = ''): Promise<EchoMissionDefinition[]> {
static async getAvailableEchoMissions(installationCode: string = ''): Promise<EchoMissionDefinition[]> {
const path: string = 'echo/available-missions/' + installationCode
const result = await BackendAPICaller.GET<EchoMissionDefinition[]>(path).catch((e) => {
console.error(`Failed to GET /${path}: ` + e)
Expand Down Expand Up @@ -263,15 +263,6 @@ export class BackendAPICaller {
})
}

static async getEchoMissions(installationCode: string = ''): Promise<EchoMission[]> {
const path: string = 'echo/missions?installationCode=' + installationCode
const result = await BackendAPICaller.GET<EchoMission[]>(path).catch((e) => {
console.error(`Failed to GET /${path}: ` + e)
throw e
})
return result.content
}

static async getMissionDefinitionById(missionId: string): Promise<CondensedMissionDefinition> {
const path: string = 'missions/definitions/' + missionId + '/condensed'
const result = await BackendAPICaller.GET<CondensedMissionDefinition>(path).catch((e) => {
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/components/Alerts/AlertsBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Button, Card, Icon } from '@equinor/eds-core-react'
import { tokens } from '@equinor/eds-tokens'
import { ReactNode } from 'react'
import styled from 'styled-components'
import { Icons } from 'utils/icons'

const StyledCard = styled(Card)`
display: flex;
width: 100%;
padding: 7px 15px;
gap: 0.2rem;
`

const Horizontal = styled.div`
display: flex;
andchiind marked this conversation as resolved.
Show resolved Hide resolved
flex-direction: row;
justify-content: space-between;
`

const Center = styled.div`
align-items: center;
`

interface AlertProps {
children: ReactNode
dismissAlert: () => void
}

export function AlertBanner({ children, dismissAlert }: AlertProps) {
return (
<>
<StyledCard variant="danger" style={{ boxShadow: tokens.elevation.raised }}>
<Horizontal>
<Center>{children}</Center>
<Button variant="ghost_icon" onClick={dismissAlert}>
<Icon name={Icons.Clear}></Icon>
</Button>
</Horizontal>
</StyledCard>
</>
)
}
64 changes: 64 additions & 0 deletions frontend/src/components/Alerts/FailedMissionAlert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Button, Typography } from '@equinor/eds-core-react'
import { config } from 'config'
import { Mission, MissionStatus } from 'models/Mission'
import styled from 'styled-components'
import { MissionStatusDisplay } from '../Pages/FrontPage/MissionOverview/MissionStatusDisplay'
import { useNavigate } from 'react-router-dom'
import { useLanguageContext } from 'components/Contexts/LanguageContext'

const Indent = styled.div`
padding: 0px 9px;
`

const StyledButton = styled(Button)`
:hover {
background-color: #ff9797;
}
`

interface MissionsProps {
missions: Mission[]
}

function FailedMission({ missions }: MissionsProps) {
const mission = missions[0]
const { TranslateText } = useLanguageContext()
const navigate = useNavigate()
const goToMission = () => {
const path = `${config.FRONTEND_BASE_ROUTE}/mission/${mission.id}`
navigate(path)
}

return (
<StyledButton onClick={goToMission} variant="ghost" color="secondary">
<strong>'{mission.name}'</strong> {TranslateText('failed on robot')}{' '}
<strong>'{mission.robot.name}':</strong> {mission.statusReason}
</StyledButton>
)
}

function SeveralFailedMissions({ missions }: MissionsProps) {
const { TranslateText } = useLanguageContext()
const navigate = useNavigate()
const goToHistory = () => {
const path = `${config.FRONTEND_BASE_ROUTE}/history`
navigate(path)
}

return (
<StyledButton onClick={goToHistory} variant="ghost" color="secondary">
<strong>{missions.length}</strong>{' '}
{' ' + TranslateText("missions failed recently. See 'Mission History' for more information.")}
</StyledButton>
)
}

export function FailedMissionAlertContent({ missions }: MissionsProps) {
return (
<Indent>
<MissionStatusDisplay status={MissionStatus.Failed} />
{missions.length === 1 && <FailedMission missions={missions} />}
{missions.length > 1 && <SeveralFailedMissions missions={missions} />}
</Indent>
)
}
36 changes: 36 additions & 0 deletions frontend/src/components/Alerts/FailedRequestAlert.tsx
Original file line number Diff line number Diff line change
@@ -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 function FailedRequestAlertContent({ message }: { message: string }) {
const { TranslateText } = useLanguageContext()
return (
<StyledDiv>
<StyledAlertTitle>
<Icon name={Icons.Failed} style={{ color: tokens.colors.interactive.danger__resting.rgba }} />
<Typography>{TranslateText('Request error')}</Typography>
andchiind marked this conversation as resolved.
Show resolved Hide resolved
</StyledAlertTitle>
<Indent>
<Button as={Typography} variant="ghost" color="secondary">
{TranslateText(message)}
</Button>
</Indent>
</StyledDiv>
)
}
141 changes: 141 additions & 0 deletions frontend/src/components/Contexts/AlertContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { createContext, FC, ReactNode, useContext, useEffect, useState } from 'react'
import { addMinutes, max } from 'date-fns'
import { Mission, MissionStatus } from 'models/Mission'
import { FailedMissionAlertContent } from 'components/Alerts/FailedMissionAlert'
import { BackendAPICaller } from 'api/ApiCaller'
import { SignalREventLabels, useSignalRContext } from './SignalRContext'
import { useInstallationContext } from './InstallationContext'

export enum AlertType {
MissionFail,
RequestFail,
}

type AlertDictionaryType = { [key in AlertType]?: { content: ReactNode | undefined; dismissFunction: () => void } }

interface IAlertContext {
alerts: AlertDictionaryType
setAlert: (source: AlertType, alert: ReactNode) => void
clearAlerts: () => void
clearAlert: (source: AlertType) => void
}

interface Props {
children: React.ReactNode
}

const defaultAlertInterface = {
alerts: {},
setAlert: (source: AlertType, alert: ReactNode) => {},
clearAlerts: () => {},
clearAlert: (source: AlertType) => {},
}

export const AlertContext = createContext<IAlertContext>(defaultAlertInterface)

export const AlertProvider: FC<Props> = ({ children }) => {
andchiind marked this conversation as resolved.
Show resolved Hide resolved
const [alerts, setAlerts] = useState<AlertDictionaryType>(defaultAlertInterface.alerts)
const [recentFailedMissions, setRecentFailedMissions] = useState<Mission[]>([])
const { registerEvent, connectionReady } = useSignalRContext()
const { installationCode } = useInstallationContext()

const pageSize: number = 100
// The default amount of minutes in the past for failed missions to generate an alert
const defaultTimeInterval: number = 10
// The maximum amount of minutes in the past for failed missions to generate an alert
const maxTimeInterval: number = 60
const dismissMissionFailTimeKey: string = 'lastMissionFailDismissalTime'

const setAlert = (source: AlertType, alert: ReactNode) =>
setAlerts({ ...alerts, [source]: { content: alert, dismissFunction: () => clearAlert(source) } })

const clearAlerts = () => setAlerts({})

const clearAlert = (source: AlertType) => {
if (source === AlertType.MissionFail)
sessionStorage.setItem(dismissMissionFailTimeKey, JSON.stringify(Date.now()))
let newAlerts = { ...alerts }
delete newAlerts[source]
setAlerts(newAlerts)
}

const getLastDismissalTime = (): Date => {
const sessionValue = sessionStorage.getItem(dismissMissionFailTimeKey)
if (sessionValue === null || sessionValue === '') {
return addMinutes(Date.now(), -defaultTimeInterval)
} else {
// If last dismissal time was more than {MaxTimeInterval} minutes ago, use the limit value instead
return max([addMinutes(Date.now(), -maxTimeInterval), JSON.parse(sessionValue)])
}
}

// This variable is needed since the state in the useEffect below uses an outdated alert object
const [newFailedMissions, setNewFailedMissions] = useState<Mission[]>([])

// Set the initial failed missions when loading the page or changing installations
useEffect(() => {
const updateRecentFailedMissions = () => {
const lastDismissTime: Date = getLastDismissalTime()
BackendAPICaller.getMissionRuns({ statuses: [MissionStatus.Failed], pageSize: pageSize }).then(
(missions) => {
const newRecentFailedMissions = missions.content.filter(
(m) =>
new Date(m.endTime!) > lastDismissTime &&
(!installationCode ||
m.installationCode!.toLocaleLowerCase() !== installationCode.toLocaleLowerCase())
)
if (newRecentFailedMissions.length > 0) setNewFailedMissions(newRecentFailedMissions)
setRecentFailedMissions(newRecentFailedMissions)
}
)
}
if (!recentFailedMissions || recentFailedMissions.length === 0) updateRecentFailedMissions()
}, [installationCode])

// Register a signalR event handler that listens for new failed missions
useEffect(() => {
if (connectionReady)
registerEvent(SignalREventLabels.missionRunFailed, (username: string, message: string) => {
const newFailedMission: Mission = JSON.parse(message)
const lastDismissTime: Date = getLastDismissalTime()

setRecentFailedMissions((failedMissions) => {
if (
installationCode &&
(!newFailedMission.installationCode ||
newFailedMission.installationCode.toLocaleLowerCase() !==
installationCode.toLocaleLowerCase())
)
return failedMissions // Ignore missions for other installations
// Ignore missions shortly after the user dismissed the last one
if (new Date(newFailedMission.endTime!) <= lastDismissTime) return failedMissions
let isDuplicate = failedMissions.filter((m) => m.id === newFailedMission.id).length > 0
if (isDuplicate) return failedMissions // Ignore duplicate failed missions
return [...failedMissions, newFailedMission]
})
})
}, [registerEvent, connectionReady])

useEffect(() => {
if (newFailedMissions.length > 0) {
setAlert(AlertType.MissionFail, <FailedMissionAlertContent missions={newFailedMissions} />)
setNewFailedMissions([])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newFailedMissions])

return (
<AlertContext.Provider
value={{
alerts,
setAlert,
clearAlerts,
clearAlert,
}}
>
{children}
</AlertContext.Provider>
)
}

export const useAlertContext = () => useContext(AlertContext)
Loading
Loading