diff --git a/.buildpacks b/.buildpacks deleted file mode 100644 index 5e3076321eec9fec4b5259e0995b621324308cfc..0000000000000000000000000000000000000000 --- a/.buildpacks +++ /dev/null @@ -1,2 +0,0 @@ -https://github.com/heroku/heroku-buildpack-nodejs.git#v236 -https://github.com/dokku/heroku-buildpack-nginx.git#v25 \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3e1fe65d024604be430bd9dc2ab8781ce4253cf2..c6a70954d1e4f8fa913c0aa02b200ca2918307db 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -10,6 +10,14 @@ variables: CI_REGISTRY: gitlab.fi.muni.cz:5050/inject/container-registry/$CI_REGISTRY_IMAGE IMAGE_TAG: $CI_REGISTRY:$VER IMAGE_LATEST: $CI_REGISTRY:latest + # using "docker" as the host is only possible if you alias the service below + DOCKER_HOST: tcp://docker:2375 + # could be wrong here but although Docker defaults to overlay2, + # Docker-in-Docker (DIND) does not according to the following GitLab doc: + # https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#use-the-overlayfs-driver + DOCKER_DRIVER: overlay2 + DOCKER_TLS_CERTDIR: "" + default: tags: @@ -45,7 +53,9 @@ tsc-test-job: create-image: image: docker:20.10.16 services: - - docker:20.10.16-dind + - name: docker:20.10.16-dind + alias: docker + command: ["--tls=false"] stage: buildimage only: refs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 4380c82faf2a9f6c135e48f8cd807b2fa940d732..c89ad7085f932e0e5df2258d92d0c46dcd65d5f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ -2024-05-09 - add role and exercise info into team selectors +2024-05-15 - add role and exercise info into team selectors +2024-05-15 - fix draft persistance +2024-05-15 - add a title to the toolbar and improve the section titles +2024-05-14 - improve new email highlighting 2024-05-07 - add the option to add not team-visible addresses to the recipients list 2024-05-07 - add exercise name, rework exercise panel add dialogs 2024-05-07 - add learning objectives page to the instructor view diff --git a/frontend/src/analyst/HealthCheck/index.tsx b/frontend/src/analyst/HealthCheck/index.tsx index 8d69a22c0fd072a0f57ae6ba2fe0aacd1b1c97dd..c81f5dabfe72d1b5f4c9b79072ed828c3e0ebde4 100644 --- a/frontend/src/analyst/HealthCheck/index.tsx +++ b/frontend/src/analyst/HealthCheck/index.tsx @@ -1,14 +1,44 @@ import type { SpinnerProps } from '@blueprintjs/core' import { Spinner } from '@blueprintjs/core' import { useApolloNetworkStatus } from '@inject/graphql/client' +import useApolloClient from '@inject/graphql/client/useApolloClient' +import { GetExerciseDocument } from '@inject/graphql/queries/GetExercise.generated' +import { GetExerciseLoopStatusDocument } from '@inject/graphql/queries/GetExerciseLoopStatus.generated' import { useGetExerciseTimeLeft } from '@inject/graphql/queries/GetExerciseTimeLeft.generated' +import { useEffect, useState } from 'react' import { HEALTH_CHECK_INTERVAL } from '../utilities' const HealthCheck = () => { + const [wasError, setWasError] = useState(false) const { loading, error } = useGetExerciseTimeLeft({ pollInterval: HEALTH_CHECK_INTERVAL, }) const { numPendingQueries, numPendingMutations } = useApolloNetworkStatus() + const client = useApolloClient() + + /** + * During a backend server outage, the exercise loop of a running exercise + * will be stopped, but the exercise will still be marked as running + * + * This inconsistency is resolved in + * /frontend/graphql/utils/useExerciseSubscriptionStatus.ts by restarting + * the exercise and refetching the exercise loop status + * + * To be able to detect this inconsistency, the relevant queries need to be + * refetched after the backend server outage + */ + useEffect(() => { + if (error) { + setWasError(true) + } else { + if (wasError) { + setWasError(false) + client.refetchQueries({ + include: [GetExerciseLoopStatusDocument, GetExerciseDocument], + }) + } + } + }, [client, error, wasError]) let spinnerProps: SpinnerProps if (loading) { diff --git a/frontend/src/components/Sidebar/index.tsx b/frontend/src/components/Sidebar/index.tsx index 99d1c6774b7042bf2eed158edcfc48e7084f59a9..60f2f55bc7e490745a9b3af39484fd216eaf69e5 100644 --- a/frontend/src/components/Sidebar/index.tsx +++ b/frontend/src/components/Sidebar/index.tsx @@ -25,6 +25,12 @@ const sectionName = css` font-size: 1rem; ` +const sidebarTitle = css` + margin: 0 0 0.5rem 0.5rem; + font-size: 1.2rem; + text-align: center; +` + export interface Section { name?: string node: ReactNode @@ -36,6 +42,7 @@ interface SidebarProps { width?: number sections: Section[] showLogo?: boolean + title?: string } const Sidebar: FC<SidebarProps> = ({ @@ -44,11 +51,19 @@ const Sidebar: FC<SidebarProps> = ({ showLogo, hideNames, width, + title, }) => ( <div className={container(position, width)}> {position === 'right' && <Divider />} <div className={sidebar}> + {title && ( + <div> + <h2 className={sidebarTitle}>{title}</h2> + <Divider /> + </div> + )} + {showLogo && ( <> <InjectLogo diff --git a/frontend/src/email/EmailForm/InstructorEmailForm.tsx b/frontend/src/email/EmailForm/InstructorEmailForm.tsx index 7d7626dad907fec8fc6245605923ae71837928c4..f46e2cf8e8687d9d09153ce4b09e5a5f52334e10 100644 --- a/frontend/src/email/EmailForm/InstructorEmailForm.tsx +++ b/frontend/src/email/EmailForm/InstructorEmailForm.tsx @@ -81,7 +81,10 @@ const InstructorEmailForm: FC<InstructorEmailFormProps> = ({ deactivateMilestone, }, }, - onCompleted: () => notify('Email sent successfully'), + onCompleted: () => { + discardDraft() + notify('Email sent successfully') + }, }) onFinish() }, @@ -205,9 +208,9 @@ const InstructorEmailForm: FC<InstructorEmailFormProps> = ({ type='submit' rightIcon='send-message' onClick={onSubmit} - disabled={loading} + loading={loading} > - {loading ? 'Sending...' : 'Send'} + Send </Button> </ButtonGroup> </FormGroup> diff --git a/frontend/src/email/EmailForm/TraineeEmailForm.tsx b/frontend/src/email/EmailForm/TraineeEmailForm.tsx index 757f4ccf6d7da01c0ab9f3ca3034cc7bbba10f5e..46f278719fb9779ca646130d652da71e7c1a2151 100644 --- a/frontend/src/email/EmailForm/TraineeEmailForm.tsx +++ b/frontend/src/email/EmailForm/TraineeEmailForm.tsx @@ -70,7 +70,10 @@ const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ fileId: fileInfo?.id, }, }, - onCompleted: () => notify('Email sent successfully'), + onCompleted: () => { + discardDraft() + notify('Email sent successfully') + }, }) onFinish() }, @@ -137,9 +140,9 @@ const TraineeEmailForm: FC<TraineeEmailFormProps> = ({ type='submit' rightIcon='send-message' onClick={onSend} - disabled={loading} + loading={loading} > - {loading ? 'Sending...' : 'Send'} + Send </Button> </ButtonGroup> </FormGroup> diff --git a/frontend/src/email/TeamEmails/EmailCard.tsx b/frontend/src/email/TeamEmails/EmailCard.tsx index bd5c78d5541c98c454faa94817d06eb08cc4c839..8fe1ca6eefb3fce4a8b95bfbcadda2684021859d 100644 --- a/frontend/src/email/TeamEmails/EmailCard.tsx +++ b/frontend/src/email/TeamEmails/EmailCard.tsx @@ -1,12 +1,12 @@ import useFormatTimestamp from '@/analyst/useFormatTimestamp' import { HIGHLIGHTED_COLOR } from '@/analyst/utilities' import FileViewRedirectButton from '@/components/FileViewRedirectButton' -import { Classes, Icon, Section, SectionCard } from '@blueprintjs/core' +import { Classes, Colors, Icon, Section, SectionCard } from '@blueprintjs/core' import type { Email } from '@inject/graphql/fragments/Email.generated' import { useWriteReadReceiptEmail } from '@inject/graphql/mutations/clientonly/WriteReadReceiptEmail.generated' import Timestamp from '@inject/shared/components/Timestamp' import type { FC } from 'react' -import { useEffect, useState } from 'react' +import { useEffect, useMemo } from 'react' interface EmailCardProps { exerciseId: string @@ -25,21 +25,22 @@ const EmailCard: FC<EmailCardProps> = ({ }) => { const formatTimestamp = useFormatTimestamp() - const [newIndicator, setNewIndicator] = useState(false) + // this ensures the message is rendered as 'not read' the first time it's rendered + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialReadReceipt = useMemo(() => email.readReceipt, []) const [mutate] = useWriteReadReceiptEmail({ variables: { emailId: email.id }, }) useEffect(() => { if (!email.readReceipt) { mutate() - setNewIndicator(() => true) } }, [email.readReceipt, mutate]) return ( <Section style={{ - background: newIndicator ? HIGHLIGHTED_COLOR : undefined, + background: initialReadReceipt ? undefined : HIGHLIGHTED_COLOR, overflow: 'unset', }} icon={ @@ -63,7 +64,16 @@ const EmailCard: FC<EmailCardProps> = ({ {inAnalyst ? ( formatTimestamp(email.timestamp) ) : ( - <Timestamp minimal datetime={new Date(email.timestamp)} /> + <Timestamp + minimal + datetime={new Date(email.timestamp)} + style={{ + backgroundColor: initialReadReceipt + ? `${Colors.GREEN3}4d` + : `${Colors.ORANGE3}4d`, + alignSelf: 'center', + }} + /> )} </span> } diff --git a/frontend/src/email/TeamEmails/ThreadLogCard.tsx b/frontend/src/email/TeamEmails/ThreadLogCard.tsx index d52fa5283a947cf711ab88cf5a1f3bd96e2155df..fae9976009a1b759e2b13bccca0ff6b01bdf0867 100644 --- a/frontend/src/email/TeamEmails/ThreadLogCard.tsx +++ b/frontend/src/email/TeamEmails/ThreadLogCard.tsx @@ -1,6 +1,6 @@ import useFormatTimestamp from '@/analyst/useFormatTimestamp' import { HIGHLIGHTED_COLOR, ellipsized } from '@/analyst/utilities' -import { Card, Classes, Icon } from '@blueprintjs/core' +import { Card, Classes, Colors, Icon } from '@blueprintjs/core' import type { Email } from '@inject/graphql/fragments/Email.generated' import type { EmailThread } from '@inject/graphql/fragments/EmailThread.generated' import { useWriteReadReceiptEmailThread } from '@inject/graphql/mutations/clientonly/WriteReadReceiptEmailThread.generated' @@ -94,7 +94,16 @@ const ThreadLogCard: FC<ThreadLogCardProps> = ({ {inAnalyst ? ( formatTimestamp(lastEmail.timestamp) ) : ( - <Timestamp minimal datetime={new Date(lastEmail.timestamp)} /> + <Timestamp + minimal + datetime={new Date(lastEmail.timestamp)} + style={{ + backgroundColor: emailThread.readReceipt + ? `${Colors.GREEN3}4d` + : `${Colors.ORANGE3}4d`, + alignSelf: 'center', + }} + /> )} </span> <Icon icon='chevron-right' className={Classes.TEXT_MUTED} /> diff --git a/frontend/src/views/TraineeView/Toolbar.tsx b/frontend/src/views/TraineeView/Toolbar.tsx index 5f792fad8f531363447e38448f28d3f3e887b2c0..76c406ba6ccee93715e9fd059af49789ca21099f 100644 --- a/frontend/src/views/TraineeView/Toolbar.tsx +++ b/frontend/src/views/TraineeView/Toolbar.tsx @@ -23,24 +23,28 @@ const Toolbar: FC<ToolbarProps> = ({ teamId, exerciseId }) => { }) const process = (data?.teamTools ?? []).filter(notEmpty) - const groups = useMemo(() => { - const map = new Map<string, Tool[]>() + const groups: Map<string | undefined, Tool[]> = useMemo(() => { + const uncategorized: Tool[] = [] - process.forEach(x => { - let prefix = x.name.split('_')[0]! + const groupsMap = new Map<string | undefined, Tool[]>() + process.forEach(tool => { + const prefix = tool.name.split('_').at(0) - if (prefix === x.name) { - prefix = 'Uncategorized' + if (!prefix || prefix === tool.name) { + uncategorized.push(tool) + return } - if (map.has(prefix)) { - map.get(prefix)?.push(x) + if (groupsMap.has(prefix)) { + groupsMap.get(prefix)?.push(tool) } else { - map.set(prefix, [x]) + groupsMap.set(prefix, [tool]) } }) - return map + groupsMap.set(groupsMap.size === 0 ? undefined : 'Other', uncategorized) + + return groupsMap }, [process]) const sections: Section[] = [...groups.entries()].map(([key, value]) => ({ @@ -58,7 +62,14 @@ const Toolbar: FC<ToolbarProps> = ({ teamId, exerciseId }) => { ), })) - return <Sidebar position='right' sections={sections} width={300} /> + return ( + <Sidebar + position='right' + sections={sections} + width={300} + title='Available tools' + /> + ) } export default memo(Toolbar) diff --git a/graphql/utils/useExerciseSubscriptionStatus.ts b/graphql/utils/useExerciseSubscriptionStatus.ts index 293285af81c05fb894edd6569fab07c7e1b576ea..3ba409bfebf1df49a7dd10e993a321a061a6e37a 100644 --- a/graphql/utils/useExerciseSubscriptionStatus.ts +++ b/graphql/utils/useExerciseSubscriptionStatus.ts @@ -1,10 +1,17 @@ import { useEffect } from 'react' +import { useStartExercise } from '../mutations/StartExercise.generated' +import { useStopExercise } from '../mutations/StopExercise.generated' +import { useGetExercise } from '../queries/GetExercise.generated' import { useGetExerciseLoopStatus } from '../queries/GetExerciseLoopStatus.generated' import type { ExerciseLoopRunningSubscriptionResult } from '../subscriptions/ExerciseLoopStatus.generated' import { ExerciseLoopRunningDocument } from '../subscriptions/ExerciseLoopStatus.generated' const useExerciseSubscriptionStatus = (exerciseId?: string) => { - const { data: dataExerciseLoop, subscribeToMore } = useGetExerciseLoopStatus({ + const { + data: dataExerciseLoop, + subscribeToMore, + refetch: refetchExerciseLoop, + } = useGetExerciseLoopStatus({ fetchPolicy: 'network-only', nextFetchPolicy: 'cache-first', variables: { @@ -12,6 +19,13 @@ const useExerciseSubscriptionStatus = (exerciseId?: string) => { }, skip: !exerciseId, }) + const { data: dataExercise } = useGetExercise({ + variables: { exerciseId: exerciseId || '' }, + skip: !exerciseId, + }) + const [stopExercise] = useStopExercise() + const [startExercise] = useStartExercise() + useEffect(() => { if (exerciseId) { return subscribeToMore({ @@ -29,6 +43,52 @@ const useExerciseSubscriptionStatus = (exerciseId?: string) => { }) } }, [subscribeToMore, exerciseId]) + + /** + * During a backend server outage, the exercise loop of a running exercise + * will be stopped, but the exercise will still be marked as running + * + * This useEffect resolves this inconsistency by restarting the exercise and + * refetching the exercise loop status; only one client can do this at a time + * + * Note that the issue will persist if the exercise is not subscribed to at + * the moment of the backend server failure + */ + useEffect(() => { + const asyncEffect = async () => { + while ( + dataExerciseLoop?.exerciseLoopRunning === false && + dataExercise?.exerciseId?.running === true && + exerciseId + ) { + const lock = localStorage.getItem('backendOutageFixLock') + if (lock) { + await new Promise(resolve => setTimeout(resolve, 1000)) + await refetchExerciseLoop() + return + } + + localStorage.setItem('backendOutageFixLock', 'true') + stopExercise({ + variables: { id: exerciseId }, + onCompleted: () => { + startExercise({ + variables: { id: exerciseId }, + onCompleted: () => { + localStorage.removeItem('backendOutageFixLock') + refetchExerciseLoop() + }, + }) + }, + }) + break + } + } + + asyncEffect() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dataExercise?.exerciseId?.running, dataExerciseLoop?.exerciseLoopRunning]) + return dataExerciseLoop?.exerciseLoopRunning }