Commit 42e279da authored by Marek Veselý's avatar Marek Veselý
Browse files

Merge branch '1065-frontend-add-a-channel-for-sandbox-logs' into 'main'

Resolve "Frontend: Add a channel for sandbox logs"

Closes #1065, #900, and inject-issues#336

See merge request inject/frontend!907
parents fcc84636 9768f3e0
Loading
Loading
Loading
Loading
Compare aac3fc30 to c27fc035
Original line number Diff line number Diff line
Subproject commit aac3fc3002ca4ce278e76dc2baa6d6f8b5f1cc73
Subproject commit c27fc03543594163601281b728067593ffa4815d
+1 −1
Original line number Diff line number Diff line
@@ -55,7 +55,7 @@ const LandingPage = () => {
  }, [])

  return (
    <Container makeFullHeight className={container}>
    <Container className={container}>
      <div className={container}>
        <InjectLogo className={logo} />
        <div className={introduction}>
+59 −26
Original line number Diff line number Diff line
import type { NonIdealStateProps } from '@blueprintjs/core'
import type { CalloutProps, NonIdealStateProps } from '@blueprintjs/core'
import {
  ButtonGroup,
  Callout,
  Classes,
  Colors,
  Divider,
@@ -77,26 +78,36 @@ const emptyState = css`
  justify-content: center;
`

export const ListRenderer: FC<{
const calloutStyles = css`
  // subtract margin
  width: calc(100% - 2rem);
  margin: 1rem;
`

interface ListRendererProps {
  actionLogs: ActionLog[]
  exerciseId: string
  teamId: string
  channel: Channel
  inInstructor: boolean
  getFullscreenLink: (actionLogId: string) => NavigateOptions
  scrollToLast?: boolean
  getFullscreenLink?: (actionLogId: string) => NavigateOptions
  scrollToBottom?: boolean
  noDataProps?: NonIdealStateProps
  getFileLink: (fileId: string) => NavigateOptions
}> = ({
  calloutProps?: CalloutProps
}

export const ListRenderer: FC<ListRendererProps> = ({
  actionLogs,
  exerciseId,
  teamId,
  channel,
  inInstructor,
  getFullscreenLink,
  scrollToLast,
  scrollToBottom,
  noDataProps,
  getFileLink,
  calloutProps,
}) => {
  const { t } = useTranslationFrontend()
  const actionLogsLengthPrev = useRef<number>(actionLogs.length)
@@ -116,19 +127,33 @@ export const ListRenderer: FC<{
  const unreadLogsStartRef = useRef<HTMLDivElement | null>(null)
  const lastLogRef = useRef<HTMLDivElement | null>(null)

  // scroll to bottom when rendered, or when changing teams or channels
  useLayoutEffect(() => {
  const scrollOnce = () => {
    const ref = document.getElementById('scrollable') as HTMLDivElement
    const lastLog = lastLogRef.current
    if (!ref) {
      return
    }

    if (ref && lastLog) {
      if (ref.clientHeight < (lastLog.getBoundingClientRect().height ?? 0)) {
        lastLog.scrollIntoView({ block: 'start', behavior: 'instant' })
    if (lastLogRef.current) {
      lastLogRef.current.scrollIntoView({ block: 'end', behavior: 'instant' })
    } else {
      ref.scrollTo({ top: ref.scrollHeight, behavior: 'instant' })
    }
  }
  }, [actionLogs.length])
  const handleScroll = useCallback(() => {
    scrollOnce()
    // Allow markdown/images to finish layout before final scroll
    requestAnimationFrame(() => {
      requestAnimationFrame(scrollOnce)
    })
  }, [])

  // scroll to bottom when rendered, or when changing teams or channels
  useLayoutEffect(() => {
    if (!scrollToBottom) {
      return
    }
    handleScroll()
  }, [actionLogs.length, handleScroll, scrollToBottom])

  /*
   * Logs with timestampRead === undefined should be rendered as unread
@@ -147,17 +172,21 @@ export const ListRenderer: FC<{

  useEffect(() => {
    if (actionLogs.length !== actionLogsLengthPrev.current) {
      if (firstUnreadId === undefined) setFirstUnreadId(getFirstUnreadId())

      if (scrollToLast) {
        lastLogRef.current?.scrollIntoView({
          behavior: 'smooth',
          inline: 'nearest',
        })
      if (firstUnreadId === undefined) {
        setFirstUnreadId(getFirstUnreadId())
      }
      if (scrollToBottom) {
        handleScroll()
      }
    }
    actionLogsLengthPrev.current = actionLogs.length
  }, [actionLogs.length, firstUnreadId, getFirstUnreadId, scrollToLast])
  }, [
    actionLogs.length,
    firstUnreadId,
    getFirstUnreadId,
    handleScroll,
    scrollToBottom,
  ])

  return (
    <div className={cx(view, { [instructorView]: inInstructor })} id='channel'>
@@ -170,7 +199,7 @@ export const ListRenderer: FC<{
        <Divider />
      </div>

      {actionLogs.length == 0 ? (
      {actionLogs.length === 0 ? (
        <NonIdealState
          icon='low-voltage-pole'
          title={t('actionLog.noInjectsTitle')}
@@ -180,6 +209,10 @@ export const ListRenderer: FC<{
        />
      ) : (
        <div className={scrollable} id='scrollable'>
          {calloutProps && (
            <Callout className={calloutStyles} {...calloutProps} />
          )}

          {actionLogs.map((actionLog, index) => (
            <Fragment key={actionLog.id}>
              {/*
@@ -201,7 +234,7 @@ export const ListRenderer: FC<{
                  exerciseId={exerciseId}
                  teamId={teamId}
                  actionLog={actionLog}
                  fullscreenLink={getFullscreenLink(actionLog.id)}
                  fullscreenLink={getFullscreenLink?.(actionLog.id)}
                  inInstructor={inInstructor}
                />
              </div>
+20 −4
Original line number Diff line number Diff line
import type { CalloutProps } from '@blueprintjs/core'
import { NonIdealState, type NonIdealStateProps } from '@blueprintjs/core'
import { ChannelActionLogsQuery, useTypedQuery } from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
@@ -11,14 +12,20 @@ interface ActionLogProps {
  exerciseId: string
  channelId: string
  inInstructor: boolean
  getFullscreenLink: (actionLogId: string) => NavigateOptions
  getFullscreenLink?: (actionLogId: string) => NavigateOptions
  noDataProps?: NonIdealStateProps
  scrollToLast?: boolean
  scrollToBottom?: boolean
  getFileLink: (fileId: string) => NavigateOptions
  teamLimit?: number
  newestFirst?: boolean
  refetchOnRender?: boolean
  calloutProps?: CalloutProps
}

/**
 * The component expects that the subscription is active for the given teamId
 * and that the data is updated in the cache even if this component is not rendered.
 * If this is not the case, use the `refetchOnRender` prop to refetch the data on mount.
 */
export const ActionLog: FC<ActionLogProps> = ({
  inInstructor,
@@ -27,8 +34,12 @@ export const ActionLog: FC<ActionLogProps> = ({
  channelId,
  getFullscreenLink,
  noDataProps,
  scrollToLast,
  scrollToBottom,
  getFileLink,
  teamLimit,
  newestFirst,
  refetchOnRender,
  calloutProps,
}) => {
  const { t } = useTranslationFrontend()

@@ -37,7 +48,11 @@ export const ActionLog: FC<ActionLogProps> = ({
    variables: {
      teamIds: [teamId],
      channelId,
      exerciseId,
      teamLimit,
      newestFirst,
    },
    requestPolicy: refetchOnRender ? 'cache-and-network' : undefined,
    context: useMemo(
      () => ({
        suspense: true,
@@ -64,13 +79,14 @@ export const ActionLog: FC<ActionLogProps> = ({
    <ListRenderer
      getFileLink={getFileLink}
      actionLogs={data.teamActionLogs}
      scrollToLast={scrollToLast}
      scrollToBottom={scrollToBottom}
      channel={data.channel}
      exerciseId={exerciseId}
      getFullscreenLink={getFullscreenLink}
      inInstructor={inInstructor}
      teamId={teamId}
      noDataProps={noDataProps}
      calloutProps={calloutProps}
    />
  )
}
+82 −0
Original line number Diff line number Diff line
import { Classes, Colors } from '@blueprintjs/core'
import { css, cx } from '@emotion/css'
import type { ISandboxLogDetails, TSandboxLogDetails } from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
import type { FC } from 'react'

const card = css`
  display: grid;
  gap: 0.75rem;
  padding: 0.75rem 1rem;
  border-radius: 8px;
  border: 1px solid ${Colors.LIGHT_GRAY3};
  background: ${Colors.LIGHT_GRAY5};
  .${Classes.DARK} & {
    background: ${Colors.DARK_GRAY4};
    border-color: ${Colors.DARK_GRAY3};
  }
`

const grid = css`
  display: grid;
  gap: 0.5rem 1rem;
`

const row = css`
  display: grid;
  grid-template-columns: 11rem 1fr;
  gap: 0.5rem 1rem;
  align-items: baseline;
`

const label = cx(
  Classes.TEXT_SMALL,
  css`
    text-transform: uppercase;
    color: ${Colors.GRAY1};
    .${Classes.DARK} & {
      color: ${Colors.GRAY5};
    }
  `
)

const value = css`
  color: ${Colors.DARK_GRAY1};
  .${Classes.DARK} & {
    color: ${Colors.LIGHT_GRAY5};
  }
`

interface SandboxLogContentProps {
  details: TSandboxLogDetails | ISandboxLogDetails
}

export const SandboxLogContent: FC<SandboxLogContentProps> = ({ details }) => {
  const { cmdSource, container, username, workingDirectory } = details
  const { t } = useTranslationFrontend()

  return (
    <div className={card}>
      <div className={grid}>
        <div className={row}>
          <div className={label}>{t('sandboxLogContent.source')}</div>
          <div className={value}>{cmdSource}</div>
        </div>
        <div className={row}>
          <div className={label}>{t('sandboxLogContent.container')}</div>
          <div className={value}>{container}</div>
        </div>
        <div className={row}>
          <div className={label}>{t('sandboxLogContent.user')}</div>
          <div className={value}>{username}</div>
        </div>
        <div className={row}>
          <div className={label}>{t('sandboxLogContent.workingDirectory')}</div>
          <div className={value}>
            <code>{workingDirectory}</code>
          </div>
        </div>
      </div>
    </div>
  )
}
Loading