Loading analyst/src/components/Milestones/index.tsx +9 −54 Original line number Diff line number Diff line import { Boundary, ButtonGroup, Classes, OverflowList, Popover, } from '@blueprintjs/core' import { css } from '@emotion/css' import { useSubscribedTeams } from '@inject/frontend' import type { Team } from '@inject/graphql' import { useContext, useState } from 'react' import { useContext } from 'react' import { ExerciseContext } from '../ExerciseContext' import { TeamSelector } from '../TeamSelector' import { useTeamSelectorProps } from '../TeamSelector/useTeamSelectorProps' import MilestoneCards from './MilestoneCards' import Title from './Title' // TODO: convert to table Loading @@ -23,42 +16,10 @@ export const Milestones = () => { context: 'analyst', }) const [selectedId, setSelectedId] = useState<string | undefined>() const handleClick = (item: Team | undefined) => () => { setSelectedId(item?.id) } const overflowRenderer = (overflowItems: (Team | undefined)[]) => ( <div style={{ marginLeft: 'auto' }}> <Popover content={ <ButtonGroup vertical style={{ padding: 8 }}> {overflowItems.map(item => ( <Title key={item?.id || 'AllTeamsTab'} team={item} onClick={handleClick(item)} active={selectedId === item?.id} /> ))} </ButtonGroup> } position='bottom-left' > <span className={Classes.BREADCRUMBS_COLLAPSED} style={{ margin: 4 }} /> </Popover> </div> ) const visibleItemRenderer = (item: Team | undefined) => ( <Title key={item?.id || 'AllTeamsTab'} team={item} onClick={handleClick(item)} active={selectedId === item?.id} /> ) const teamSelectorProps = useTeamSelectorProps({ teams, includeAll: true, }) return ( <div Loading @@ -70,15 +31,9 @@ export const Milestones = () => { height: 100%; `} > <OverflowList style={{ alignItems: 'center' }} collapseFrom={Boundary.END} items={[undefined, ...teams]} overflowRenderer={overflowRenderer} visibleItemRenderer={visibleItemRenderer} /> <TeamSelector {...teamSelectorProps} /> <MilestoneCards teamId={selectedId} /> <MilestoneCards teamId={teamSelectorProps.selectedTeam?.id} /> </div> ) } analyst/src/components/NavigationBar/index.tsx +24 −5 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ import { LearningObjectivesPageRoute } from '../../routes/_layout/$exerciseId/le import { MilestonesPageRoute } from '../../routes/_layout/$exerciseId/milestones' import { QuestionnairesPageRoute } from '../../routes/_layout/$exerciseId/questionnaires' import { RoomViewRoute } from '../../routes/_layout/$exerciseId/room' import { SandboxLogsRoute } from '../../routes/_layout/$exerciseId/sandbox-logs' import { SelectTeamsPageRoute } from '../../routes/_layout/$exerciseId/select-teams' import { TeamClusteringPageRoute } from '../../routes/_layout/$exerciseId/team-clustering' import { ToolsPageRoute } from '../../routes/_layout/$exerciseId/tools' Loading Loading @@ -78,6 +79,11 @@ export const NavigationBar: FC<NavigationBarProps> = ({ [nav] ) const hide = useHideSidebar() const { exercise } = useContext(ExerciseContext) const teams = useSubscribedTeams({ exerciseId, context: 'analyst' }) const emailsEnabled = useChannelTypeEnabled('EMAIL', { variables: { exerciseId: exerciseId! }, pause: !exerciseId, Loading @@ -90,10 +96,7 @@ export const NavigationBar: FC<NavigationBarProps> = ({ variables: { exerciseId: exerciseId! }, pause: !exerciseId, }) const hide = useHideSidebar() const { exercise } = useContext(ExerciseContext) const teams = useSubscribedTeams({ exerciseId, context: 'analyst' }) const sandboxLogsEnabled = exercise.sandbox // Grouped and renamed views const overviewPaths: PathType[] = useMemo(() => { Loading Loading @@ -199,8 +202,24 @@ export const NavigationBar: FC<NavigationBarProps> = ({ text: 'Questionnaires', }) } if (sandboxLogsEnabled) { arr.push({ link: { to: SandboxLogsRoute.to, params: { exerciseId }, }, icon: 'console', text: 'Sandbox Logs', }) } return arr }, [emailsEnabled, exerciseId, questionnairesEnabled, toolsEnabled]) }, [ emailsEnabled, exerciseId, questionnairesEnabled, sandboxLogsEnabled, toolsEnabled, ]) const stickySections: Section[] = useMemo( () => [ Loading analyst/src/components/Plots/TimeScatterPlot/TimeRangeSelector/index.tsx +134 −43 Original line number Diff line number Diff line import type { NumberRange } from '@blueprintjs/core' import { RangeSlider } from '@blueprintjs/core' import { Classes, RangeSlider, Tag } from '@blueprintjs/core' import { css, cx } from '@emotion/css' import { Timestamp, useTimeFormat } from '@inject/shared' import { useState, type Dispatch, type FC, type SetStateAction } from 'react' import { useContext, useEffect, useState, type Dispatch, type FC, type SetStateAction, } from 'react' import { ExerciseContext } from '../../../ExerciseContext' import { MIN_TO_MS } from '../../../utilities' import type { TimeRange } from '../types' const timestampTagClassName = css` padding: 0.25rem !important; ` const centerTextClassName = css` text-align: center; ` const moveTagUpClassName = css` margin-top: -3.2rem; ` const noTextWrapClassName = css` white-space: nowrap; ` const wrapperClassName = css` padding: 2.5rem 5rem 0 5rem; position: relative; ` const labelClassName = css` position: absolute; left: 50%; top: 70%; transform: translate(-50%, -50%); z-index: -1; ` interface TimeRangeSelectorProps { earliestStartTime: number selectedTimeRangeInitialStart: number selectedTimeRangeInitialEnd: number setSelectedTimeRange: Dispatch<SetStateAction<TimeRange>> } export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ earliestStartTime, selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd, setSelectedTimeRange, }) => { const timeFormat = useTimeFormat() const { exercise } = useContext(ExerciseContext) // a separate state to avoid re-rendering during slider drag const [sliderRange, setSliderRange] = useState<NumberRange>([ selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd, ]) useEffect(() => { setSliderRange([selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd]) }, [selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd]) const labelRenderer = ( value: number, opts?: { isHandleTooltip: boolean } ) => { const children = (() => { const timestamp = ( <Timestamp stringOnly formatTimestampProps={{ timestamp: earliestStartTime + value, inExerciseTime: value / 1000, minimal: !opts?.isHandleTooltip, }} tagProps={{ className: timestampTagClassName, }} /> ) if (opts?.isHandleTooltip) { return timestamp } if (value === 0) { return ( <Tag intent='primary' minimal> Exercise Started </Tag> ) } if (value === exercise.config.exerciseDuration * MIN_TO_MS) { return ( <Tag intent='primary' minimal> Duration Expired </Tag> ) } if (value === selectedTimeRangeInitialStart) { return <Tag minimal>Earliest Log</Tag> } if (value === selectedTimeRangeInitialEnd) { return <Tag minimal>Latest Log</Tag> } return null })() return ( <div className={cx({ [centerTextClassName]: true, [moveTagUpClassName]: !opts?.isHandleTooltip, [noTextWrapClassName]: timeFormat !== 'absolute' || !opts?.isHandleTooltip, })} > {children} </div> ) } const labelValues = Array.from( new Set([ selectedTimeRangeInitialStart, 0, selectedTimeRangeInitialEnd, exercise.config.exerciseDuration * MIN_TO_MS, ]) ).sort((a, b) => a - b) return ( <div className={wrapperClassName}> <RangeSlider min={0} max={selectedTimeRangeInitialEnd} Loading @@ -37,33 +147,14 @@ export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ }) } stepSize={1000 * 60} // 1 minute steps labelStepSize={selectedTimeRangeInitialEnd / 5} labelRenderer={(value, opts) => ( <div className={cx({ [css` text-align: center; `]: true, [css` white-space: nowrap; `]: timeFormat !== 'absolute', })} > <Timestamp stringOnly formatTimestampProps={{ timestamp: earliestStartTime + value, inExerciseTime: value / 1000, minimal: !opts?.isHandleTooltip, }} tagProps={{ className: css` padding: 0.25rem !important; `, }} labelValues={labelValues} labelRenderer={labelRenderer} /> <span className={cx(Classes.TEXT_SMALL, Classes.TEXT_MUTED, labelClassName)} > Select Time Range </span> </div> )} /> ) } analyst/src/components/Plots/TimeScatterPlot/TimeRangeSelector/useTimeRangeSelectorProps.ts +34 −12 Original line number Diff line number Diff line import { useContext, useState } from 'react' import { useContext, useEffect, useState } from 'react' import { ExerciseContext } from '../../../ExerciseContext' import { MIN_TO_MS } from '../../../utilities' export const useTimeRangeSelectorProps = () => { export const useTimeRangeSelectorProps = ({ earliestLogTime, latestLogTime, }: { earliestLogTime?: number latestLogTime?: number }) => { const { exercise } = useContext(ExerciseContext) // TODO: add paused time (elapsedS) const selectedTimeRangeInitialEnd = exercise.config.exerciseDuration * MIN_TO_MS const [selectedTimeRange, setSelectedTimeRange] = useState<{ rangeStart: number rangeEnd: number }>({ rangeStart: 0, rangeEnd: selectedTimeRangeInitialEnd, }) const { earliest: earliestStartTimeDirty, latest: latestStartTimeDirty } = exercise.states.reduce( ({ earliest, latest }, state) => { Loading @@ -38,10 +34,36 @@ export const useTimeRangeSelectorProps = () => { const latestStartTime = latestStartTimeDirty > -Infinity ? latestStartTimeDirty : 0 // TODO: add paused time (elapsedS) // allow logs outside of exercise duration // (either sandbox logs or paused/extended time) const selectedTimeRangeInitialStart = Math.min( earliestLogTime ? earliestLogTime - earliestLogTime : Infinity, 0 ) const selectedTimeRangeInitialEnd = Math.max( latestLogTime ? latestLogTime - earliestStartTime : -Infinity, exercise.config.exerciseDuration * MIN_TO_MS ) const [selectedTimeRange, setSelectedTimeRange] = useState<{ rangeStart: number rangeEnd: number }>({ rangeStart: selectedTimeRangeInitialStart, rangeEnd: selectedTimeRangeInitialEnd, }) useEffect(() => { setSelectedTimeRange({ rangeStart: selectedTimeRangeInitialStart, rangeEnd: selectedTimeRangeInitialEnd, }) }, [selectedTimeRangeInitialEnd, selectedTimeRangeInitialStart]) return { earliestStartTime, latestStartTime, selectedTimeRange, selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd, setSelectedTimeRange, } Loading analyst/src/components/SandboxLogsTable/index.tsx 0 → 100644 +31 −0 Original line number Diff line number Diff line import type { Column, Row } from '@inject/shared' import { Table } from '@inject/shared' import type { FC } from 'react' import type { SandboxLogChecked } from './types' interface SandboxLogsTableProps { sandboxLogs: SandboxLogChecked[] columns: Column<SandboxLogChecked>[] } export const SandboxLogsTable: FC<SandboxLogsTableProps> = ({ sandboxLogs, columns, }) => { const rows: Row<SandboxLogChecked>[] = sandboxLogs.map(log => ({ id: log.id, value: log, })) return ( <Table<SandboxLogChecked> columns={columns} noDataStateProps={{ title: 'No logs to display', description: 'There are no executed commands matching the filters.', }} defaultSortByColumnId='timestamp' rows={rows} /> ) } Loading
analyst/src/components/Milestones/index.tsx +9 −54 Original line number Diff line number Diff line import { Boundary, ButtonGroup, Classes, OverflowList, Popover, } from '@blueprintjs/core' import { css } from '@emotion/css' import { useSubscribedTeams } from '@inject/frontend' import type { Team } from '@inject/graphql' import { useContext, useState } from 'react' import { useContext } from 'react' import { ExerciseContext } from '../ExerciseContext' import { TeamSelector } from '../TeamSelector' import { useTeamSelectorProps } from '../TeamSelector/useTeamSelectorProps' import MilestoneCards from './MilestoneCards' import Title from './Title' // TODO: convert to table Loading @@ -23,42 +16,10 @@ export const Milestones = () => { context: 'analyst', }) const [selectedId, setSelectedId] = useState<string | undefined>() const handleClick = (item: Team | undefined) => () => { setSelectedId(item?.id) } const overflowRenderer = (overflowItems: (Team | undefined)[]) => ( <div style={{ marginLeft: 'auto' }}> <Popover content={ <ButtonGroup vertical style={{ padding: 8 }}> {overflowItems.map(item => ( <Title key={item?.id || 'AllTeamsTab'} team={item} onClick={handleClick(item)} active={selectedId === item?.id} /> ))} </ButtonGroup> } position='bottom-left' > <span className={Classes.BREADCRUMBS_COLLAPSED} style={{ margin: 4 }} /> </Popover> </div> ) const visibleItemRenderer = (item: Team | undefined) => ( <Title key={item?.id || 'AllTeamsTab'} team={item} onClick={handleClick(item)} active={selectedId === item?.id} /> ) const teamSelectorProps = useTeamSelectorProps({ teams, includeAll: true, }) return ( <div Loading @@ -70,15 +31,9 @@ export const Milestones = () => { height: 100%; `} > <OverflowList style={{ alignItems: 'center' }} collapseFrom={Boundary.END} items={[undefined, ...teams]} overflowRenderer={overflowRenderer} visibleItemRenderer={visibleItemRenderer} /> <TeamSelector {...teamSelectorProps} /> <MilestoneCards teamId={selectedId} /> <MilestoneCards teamId={teamSelectorProps.selectedTeam?.id} /> </div> ) }
analyst/src/components/NavigationBar/index.tsx +24 −5 Original line number Diff line number Diff line Loading @@ -35,6 +35,7 @@ import { LearningObjectivesPageRoute } from '../../routes/_layout/$exerciseId/le import { MilestonesPageRoute } from '../../routes/_layout/$exerciseId/milestones' import { QuestionnairesPageRoute } from '../../routes/_layout/$exerciseId/questionnaires' import { RoomViewRoute } from '../../routes/_layout/$exerciseId/room' import { SandboxLogsRoute } from '../../routes/_layout/$exerciseId/sandbox-logs' import { SelectTeamsPageRoute } from '../../routes/_layout/$exerciseId/select-teams' import { TeamClusteringPageRoute } from '../../routes/_layout/$exerciseId/team-clustering' import { ToolsPageRoute } from '../../routes/_layout/$exerciseId/tools' Loading Loading @@ -78,6 +79,11 @@ export const NavigationBar: FC<NavigationBarProps> = ({ [nav] ) const hide = useHideSidebar() const { exercise } = useContext(ExerciseContext) const teams = useSubscribedTeams({ exerciseId, context: 'analyst' }) const emailsEnabled = useChannelTypeEnabled('EMAIL', { variables: { exerciseId: exerciseId! }, pause: !exerciseId, Loading @@ -90,10 +96,7 @@ export const NavigationBar: FC<NavigationBarProps> = ({ variables: { exerciseId: exerciseId! }, pause: !exerciseId, }) const hide = useHideSidebar() const { exercise } = useContext(ExerciseContext) const teams = useSubscribedTeams({ exerciseId, context: 'analyst' }) const sandboxLogsEnabled = exercise.sandbox // Grouped and renamed views const overviewPaths: PathType[] = useMemo(() => { Loading Loading @@ -199,8 +202,24 @@ export const NavigationBar: FC<NavigationBarProps> = ({ text: 'Questionnaires', }) } if (sandboxLogsEnabled) { arr.push({ link: { to: SandboxLogsRoute.to, params: { exerciseId }, }, icon: 'console', text: 'Sandbox Logs', }) } return arr }, [emailsEnabled, exerciseId, questionnairesEnabled, toolsEnabled]) }, [ emailsEnabled, exerciseId, questionnairesEnabled, sandboxLogsEnabled, toolsEnabled, ]) const stickySections: Section[] = useMemo( () => [ Loading
analyst/src/components/Plots/TimeScatterPlot/TimeRangeSelector/index.tsx +134 −43 Original line number Diff line number Diff line import type { NumberRange } from '@blueprintjs/core' import { RangeSlider } from '@blueprintjs/core' import { Classes, RangeSlider, Tag } from '@blueprintjs/core' import { css, cx } from '@emotion/css' import { Timestamp, useTimeFormat } from '@inject/shared' import { useState, type Dispatch, type FC, type SetStateAction } from 'react' import { useContext, useEffect, useState, type Dispatch, type FC, type SetStateAction, } from 'react' import { ExerciseContext } from '../../../ExerciseContext' import { MIN_TO_MS } from '../../../utilities' import type { TimeRange } from '../types' const timestampTagClassName = css` padding: 0.25rem !important; ` const centerTextClassName = css` text-align: center; ` const moveTagUpClassName = css` margin-top: -3.2rem; ` const noTextWrapClassName = css` white-space: nowrap; ` const wrapperClassName = css` padding: 2.5rem 5rem 0 5rem; position: relative; ` const labelClassName = css` position: absolute; left: 50%; top: 70%; transform: translate(-50%, -50%); z-index: -1; ` interface TimeRangeSelectorProps { earliestStartTime: number selectedTimeRangeInitialStart: number selectedTimeRangeInitialEnd: number setSelectedTimeRange: Dispatch<SetStateAction<TimeRange>> } export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ earliestStartTime, selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd, setSelectedTimeRange, }) => { const timeFormat = useTimeFormat() const { exercise } = useContext(ExerciseContext) // a separate state to avoid re-rendering during slider drag const [sliderRange, setSliderRange] = useState<NumberRange>([ selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd, ]) useEffect(() => { setSliderRange([selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd]) }, [selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd]) const labelRenderer = ( value: number, opts?: { isHandleTooltip: boolean } ) => { const children = (() => { const timestamp = ( <Timestamp stringOnly formatTimestampProps={{ timestamp: earliestStartTime + value, inExerciseTime: value / 1000, minimal: !opts?.isHandleTooltip, }} tagProps={{ className: timestampTagClassName, }} /> ) if (opts?.isHandleTooltip) { return timestamp } if (value === 0) { return ( <Tag intent='primary' minimal> Exercise Started </Tag> ) } if (value === exercise.config.exerciseDuration * MIN_TO_MS) { return ( <Tag intent='primary' minimal> Duration Expired </Tag> ) } if (value === selectedTimeRangeInitialStart) { return <Tag minimal>Earliest Log</Tag> } if (value === selectedTimeRangeInitialEnd) { return <Tag minimal>Latest Log</Tag> } return null })() return ( <div className={cx({ [centerTextClassName]: true, [moveTagUpClassName]: !opts?.isHandleTooltip, [noTextWrapClassName]: timeFormat !== 'absolute' || !opts?.isHandleTooltip, })} > {children} </div> ) } const labelValues = Array.from( new Set([ selectedTimeRangeInitialStart, 0, selectedTimeRangeInitialEnd, exercise.config.exerciseDuration * MIN_TO_MS, ]) ).sort((a, b) => a - b) return ( <div className={wrapperClassName}> <RangeSlider min={0} max={selectedTimeRangeInitialEnd} Loading @@ -37,33 +147,14 @@ export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ }) } stepSize={1000 * 60} // 1 minute steps labelStepSize={selectedTimeRangeInitialEnd / 5} labelRenderer={(value, opts) => ( <div className={cx({ [css` text-align: center; `]: true, [css` white-space: nowrap; `]: timeFormat !== 'absolute', })} > <Timestamp stringOnly formatTimestampProps={{ timestamp: earliestStartTime + value, inExerciseTime: value / 1000, minimal: !opts?.isHandleTooltip, }} tagProps={{ className: css` padding: 0.25rem !important; `, }} labelValues={labelValues} labelRenderer={labelRenderer} /> <span className={cx(Classes.TEXT_SMALL, Classes.TEXT_MUTED, labelClassName)} > Select Time Range </span> </div> )} /> ) }
analyst/src/components/Plots/TimeScatterPlot/TimeRangeSelector/useTimeRangeSelectorProps.ts +34 −12 Original line number Diff line number Diff line import { useContext, useState } from 'react' import { useContext, useEffect, useState } from 'react' import { ExerciseContext } from '../../../ExerciseContext' import { MIN_TO_MS } from '../../../utilities' export const useTimeRangeSelectorProps = () => { export const useTimeRangeSelectorProps = ({ earliestLogTime, latestLogTime, }: { earliestLogTime?: number latestLogTime?: number }) => { const { exercise } = useContext(ExerciseContext) // TODO: add paused time (elapsedS) const selectedTimeRangeInitialEnd = exercise.config.exerciseDuration * MIN_TO_MS const [selectedTimeRange, setSelectedTimeRange] = useState<{ rangeStart: number rangeEnd: number }>({ rangeStart: 0, rangeEnd: selectedTimeRangeInitialEnd, }) const { earliest: earliestStartTimeDirty, latest: latestStartTimeDirty } = exercise.states.reduce( ({ earliest, latest }, state) => { Loading @@ -38,10 +34,36 @@ export const useTimeRangeSelectorProps = () => { const latestStartTime = latestStartTimeDirty > -Infinity ? latestStartTimeDirty : 0 // TODO: add paused time (elapsedS) // allow logs outside of exercise duration // (either sandbox logs or paused/extended time) const selectedTimeRangeInitialStart = Math.min( earliestLogTime ? earliestLogTime - earliestLogTime : Infinity, 0 ) const selectedTimeRangeInitialEnd = Math.max( latestLogTime ? latestLogTime - earliestStartTime : -Infinity, exercise.config.exerciseDuration * MIN_TO_MS ) const [selectedTimeRange, setSelectedTimeRange] = useState<{ rangeStart: number rangeEnd: number }>({ rangeStart: selectedTimeRangeInitialStart, rangeEnd: selectedTimeRangeInitialEnd, }) useEffect(() => { setSelectedTimeRange({ rangeStart: selectedTimeRangeInitialStart, rangeEnd: selectedTimeRangeInitialEnd, }) }, [selectedTimeRangeInitialEnd, selectedTimeRangeInitialStart]) return { earliestStartTime, latestStartTime, selectedTimeRange, selectedTimeRangeInitialStart, selectedTimeRangeInitialEnd, setSelectedTimeRange, } Loading
analyst/src/components/SandboxLogsTable/index.tsx 0 → 100644 +31 −0 Original line number Diff line number Diff line import type { Column, Row } from '@inject/shared' import { Table } from '@inject/shared' import type { FC } from 'react' import type { SandboxLogChecked } from './types' interface SandboxLogsTableProps { sandboxLogs: SandboxLogChecked[] columns: Column<SandboxLogChecked>[] } export const SandboxLogsTable: FC<SandboxLogsTableProps> = ({ sandboxLogs, columns, }) => { const rows: Row<SandboxLogChecked>[] = sandboxLogs.map(log => ({ id: log.id, value: log, })) return ( <Table<SandboxLogChecked> columns={columns} noDataStateProps={{ title: 'No logs to display', description: 'There are no executed commands matching the filters.', }} defaultSortByColumnId='timestamp' rows={rows} /> ) }