Commit 4c482680 authored by Marek Veselý's avatar Marek Veselý
Browse files

Merge branch '1004-sandbox-logs-view' into 'main'

Resolve "Add a new analyst view, substitute to Discover in OpenSearch Dashboards"

Closes #1004

See merge request inject/frontend!894
parents eb7dfa49 9a541b5c
Loading
Loading
Loading
Loading
+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

@@ -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
@@ -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>
  )
}
+24 −5
Original line number Diff line number Diff line
@@ -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'
@@ -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,
@@ -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(() => {
@@ -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(
    () => [
+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}
@@ -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>
      )}
    />
  )
}
+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) => {
@@ -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,
  }
+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