Verified Commit 99e4211b authored by Marek Veselý's avatar Marek Veselý
Browse files

feat: add opensearch config to table and csv export

parent fe4566fc
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -162,6 +162,7 @@ export const ExerciseDetail: FC<ExerciseDetailProps> = ({
          className={collapsibleSection}
          open={sandboxOpen}
          setOpen={setSandboxOpen}
          teams={teams}
        />
      )}
      <ConfigInfo
+159 −52
Original line number Diff line number Diff line
import { Button, ButtonGroup, Collapse } from '@blueprintjs/core'
import { css } from '@emotion/css'
import type { TeamTokenF } from '@inject/graphql'
import { ExerciseTokensQuery, useTypedQuery } from '@inject/graphql'
import { css, cx } from '@emotion/css'
import type { Team } from '@inject/graphql'
import { ExerciseTokensQuery, useHost, useTypedQuery } from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
import type { Column, Row } from '@inject/shared'
import { stringSortingFunction, Table } from '@inject/shared'
import { Table } from '@inject/shared'
import type { Dispatch, SetStateAction } from 'react'
import { type FC } from 'react'
import type { SandboxConfiguration } from '../../../SandboxConfigurationButton/getEnvContent'
import { getSandboxConfiguration } from '../../../SandboxConfigurationButton/getEnvContent'

// TODO: deduplicate
const verticallyCentered = css`
const rendererClassName = css`
  vertical-align: middle;
  white-space: pre-wrap;
  word-break: break-word;
`

interface ConfigInfoProps {
@@ -18,6 +21,7 @@ interface ConfigInfoProps {
  exerciseId: string
  open: boolean
  setOpen: Dispatch<SetStateAction<boolean>>
  teams: Team[]
}

export const SandboxInfo: FC<ConfigInfoProps> = ({
@@ -25,6 +29,7 @@ export const SandboxInfo: FC<ConfigInfoProps> = ({
  exerciseId,
  open,
  setOpen,
  teams,
}) => {
  const { t } = useTranslationFrontend()

@@ -36,56 +41,164 @@ export const SandboxInfo: FC<ConfigInfoProps> = ({
    requestPolicy: 'network-only',
  })

  const columns: Column<TeamTokenF>[] = [
  const openSearchEnabled =
    window.VITE_OPENSEARCH_HOST && window.VITE_OPENSEARCH_PORT
  const openSearchConfigColumns: Column<Team>[] = [
    {
      id: 'teamIds',
      name: t('exerciseInfo.teamIds'),
      style: { textAlign: 'right', width: '15ch' },
      renderValue: teamToken => teamToken.teamIds.join(', '),
      className: verticallyCentered,
      sortingFunction: (a, b) =>
        stringSortingFunction(a.teamIds.join(', '), b.teamIds.join(', ')),
      id: 'opensearchUser',
      name: t(
        'exercisePanel.definitionManager.info.sandboxConfig.opensearchUsername'
      ),
      renderValue: team => team.openSearchAccess?.username || '',
      className: rendererClassName,
    },
    {
      id: 'opensearchPassword',
      name: t(
        'exercisePanel.definitionManager.info.sandboxConfig.opensearchPassword'
      ),
      renderValue: team => team.openSearchAccess?.password || '',
      className: rendererClassName,
    },
    {
      id: 'opensearchIndex',
      name: t(
        'exercisePanel.definitionManager.info.sandboxConfig.opensearchIndex'
      ),
      renderValue: team => team.openSearchAccess?.indexName || '',
      className: rendererClassName,
    },
  ]
  const injectConfigColumns: Column<Team>[] = [
    {
      id: 'user',
      name: t('exerciseInfo.user'),
      style: { width: '40%' },
      renderValue: teamToken => teamToken.user.username,
      className: verticallyCentered,
      sortingFunction: (a, b) =>
        stringSortingFunction(a.user.username, b.user.username),
      id: 'teamId',
      name: t('exercisePanel.definitionManager.info.sandboxConfig.teamId'),
      renderValue: team => team.id,
      className: cx(
        rendererClassName,
        css`
          width: 10ch;
        `
      ),
    },
    {
      id: 'users',
      name: t('exercisePanel.definitionManager.info.sandboxConfig.users'),
      renderValue: team =>
        team.users
          .map(user => {
            const { firstName, lastName, username } = user
            return firstName && lastName
              ? `${lastName}, ${firstName} (${username})`
              : username
          })
          .join('; '),
      className: cx(
        rendererClassName,
        css`
          width: 20ch;
        `
      ),
    },
    {
      id: 'token',
      name: t('exerciseInfo.token'),
      style: { width: '60%', whiteSpace: 'pre-wrap', wordBreak: 'break-word' },
      renderValue: teamToken => teamToken.token,
      className: verticallyCentered,
      sortingFunction: (a, b) => stringSortingFunction(a.token, b.token),
      name: t('exercisePanel.definitionManager.info.sandboxConfig.injectToken'),
      renderValue: team =>
        data?.exerciseTokens.find(token => token.teamIds.includes(team.id))
          ?.token || '',
      className: rendererClassName,
    },
  ]

  const rows: Row<TeamTokenF>[] =
    data?.exerciseTokens.map(token => ({
      id: token.token,
      value: token,
    })) || []
  const columns = openSearchEnabled
    ? [...injectConfigColumns, ...openSearchConfigColumns]
    : injectConfigColumns

  const rows: Row<Team>[] = teams.map(team => ({
    id: team.id,
    value: team,
  }))

  const host = useHost()

  const exportAsCSV = () => {
    const header = ['teamIds', 'username', 'token'].join(',')
    const csvRows =
      data?.exerciseTokens.map(token => {
        const teamIds = `"${token.teamIds.join('|')}"`
        const username = `"${token.user.username}"`
        const tokenValue = `"${token.token}"`
        return [teamIds, username, tokenValue].join(',')
      }) || []
    const openSearchHeader = [
      'opensearchHost',
      'opensearchIndex',
      'opensearchUser',
      'opensearchPassword',
    ].join(',')
    const injectHeader = [
      'teamId',
      'username',
      'firstName',
      'lastName',
      'injectHost',
      'injectTeamToken',
    ].join(',')
    const header = openSearchEnabled
      ? `${injectHeader},${openSearchHeader}`
      : injectHeader

    const csvRows = teams.flatMap(team =>
      team.users.map(user => {
        const { id: teamId, openSearchAccess } = team
        const { username, firstName, lastName } = user
        const token = data?.exerciseTokens.find(
          exerciseToken => exerciseToken.user.id === user.id
        )?.token
        // button is disabled when fetching, and each user should have a token
        if (!token) {
          throw new Error(
            `No token found for user ${username} in team ${teamId}`
          )
        }
        const {
          injectHost,
          injectTeamToken,
          opensearchHost,
          opensearchIndex,
          opensearchUser,
          opensearchPassword,
        }: SandboxConfiguration = openSearchAccess
          ? getSandboxConfiguration({
              host,
              teamId,
              token,
              openSearchAccess,
            })
          : getSandboxConfiguration({
              host,
              teamId,
              token,
            })

        const openSearchData = [
          opensearchHost ?? '',
          opensearchIndex ?? '',
          opensearchUser ?? '',
          opensearchPassword ?? '',
        ].join(',')
        const injectData = [
          teamId,
          username,
          firstName,
          lastName,
          injectHost,
          injectTeamToken,
        ].join(',')
        return openSearchEnabled
          ? `${injectData},${openSearchData}`
          : injectData
      })
    )

    const csvContent = [header, ...csvRows].join('\n')
    const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
    const link = document.createElement('a')
    const url = URL.createObjectURL(blob)
    link.setAttribute('href', url)
    link.setAttribute('download', `exercise_${exerciseId}_tokens.csv`)
    link.setAttribute('download', `exercise${exerciseId}-sandbox_config.csv`)
    link.style.visibility = 'hidden'
    document.body.appendChild(link)
    link.click()
@@ -98,26 +211,20 @@ export const SandboxInfo: FC<ConfigInfoProps> = ({
        <Button onClick={() => setOpen(!open)} active={open} loading={fetching}>
          {`${
            open
              ? t('exercisePanel.definitionManager.info.hideTokens')
              : t('exercisePanel.definitionManager.info.showTokens')
          } ${t('exercisePanel.definitionManager.info.tokens')}`}
              ? t('exercisePanel.definitionManager.info.sandboxConfig.hide')
              : t('exercisePanel.definitionManager.info.sandboxConfig.show')
          } ${t('exercisePanel.definitionManager.info.sandboxConfig.config')}`}
        </Button>
        <Button onClick={exportAsCSV} loading={fetching}>
          {t('exercisePanel.definitionManager.info.exportTokens')}
        <Button onClick={exportAsCSV} loading={fetching} icon='export'>
          {t('exercisePanel.definitionManager.info.sandboxConfig.exportCSV')}
        </Button>
      </ButtonGroup>
      <Collapse isOpen={open}>
        <Table<TeamTokenF>
        <Table<Team>
          columns={columns}
          className={className}
          rows={rows}
          defaultSortByColumnId='team'
          noDataStateProps={{
            title: t('exercisePanel.definitionManager.info.noTokensTitle'),
            description: t(
              'exercisePanel.definitionManager.info.noTokensDescription'
            ),
          }}
        />
      </Collapse>
    </div>
+125 −0
Original line number Diff line number Diff line
import type { OpenSearchAccess } from '@inject/graphql'
import { createSandboxLogUrl } from '@inject/shared'

// TODO: consolidate env variable typings
declare global {
  interface Window {
    VITE_OPENSEARCH_HOST: string | undefined
    VITE_OPENSEARCH_PORT: string | undefined
  }
}

type Empty<T> = {
  [K in keyof T]?: never
}

export type InjectSandboxConfiguration = {
  injectHost: string
  injectTeamToken: string
}
export type OpenSearchSandboxConfiguration = {
  opensearchHost: string
  opensearchIndex: string
  opensearchUser: string
  opensearchPassword: string
}

type SandboxConfigurationParams = {
  openSearchAccess?: OpenSearchAccess
  teamId: string
  token: string
  host: string
}

export type SandboxConfiguration =
  | (InjectSandboxConfiguration & Empty<OpenSearchSandboxConfiguration>)
  | (InjectSandboxConfiguration & OpenSearchSandboxConfiguration)

// Function overloads for proper typing based on presence of openSearchAccess
export function getSandboxConfiguration(
  params: SandboxConfigurationParams & { openSearchAccess?: never }
): InjectSandboxConfiguration
export function getSandboxConfiguration(
  params: SandboxConfigurationParams & { openSearchAccess: OpenSearchAccess }
): InjectSandboxConfiguration & OpenSearchSandboxConfiguration
export function getSandboxConfiguration({
  openSearchAccess,
  teamId,
  token,
  host,
}: SandboxConfigurationParams): SandboxConfiguration {
  const injectHost = createSandboxLogUrl(host, teamId)
  const injectTeamToken = token

  if (
    !window.VITE_OPENSEARCH_HOST ||
    !window.VITE_OPENSEARCH_PORT ||
    !openSearchAccess
  ) {
    return {
      injectHost,
      injectTeamToken,
    }
  }

  const opensearchHost = `https://${window.VITE_OPENSEARCH_HOST}:${window.VITE_OPENSEARCH_PORT}`
  const opensearchIndex = openSearchAccess.indexName
  const opensearchUser = openSearchAccess.username
  const opensearchPassword = openSearchAccess.password

  return {
    injectHost,
    injectTeamToken,
    opensearchHost,
    opensearchIndex,
    opensearchUser,
    opensearchPassword,
  }
}

export const getEnvContent = ({
  openSearchAccess,
  teamId,
  token,
  host,
}: {
  openSearchAccess?: OpenSearchAccess | null
  teamId: string
  token: string
  host: string
}) => {
  const sandboxConfiguration = openSearchAccess
    ? getSandboxConfiguration({
        openSearchAccess,
        teamId,
        token,
        host,
      })
    : getSandboxConfiguration({
        teamId,
        token,
        host,
      })

  const injectLoggingContent =
    `INJECT_HOST=${sandboxConfiguration.injectHost}\n` +
    `INJECT_TEAM_TOKEN=${sandboxConfiguration.injectTeamToken}\n`

  const isOpenSearchConfig = (
    config:
      | InjectSandboxConfiguration
      | (InjectSandboxConfiguration & OpenSearchSandboxConfiguration)
  ): config is InjectSandboxConfiguration & OpenSearchSandboxConfiguration =>
    'opensearchHost' in config

  if (isOpenSearchConfig(sandboxConfiguration)) {
    const openSearchContent =
      `OPENSEARCH_HOST=${sandboxConfiguration.opensearchHost}\n` +
      `OPENSEARCH_INDEX=${sandboxConfiguration.opensearchIndex}\n` +
      `OPENSEARCH_USER=${sandboxConfiguration.opensearchUser}\n` +
      `OPENSEARCH_PASSWORD=${sandboxConfiguration.opensearchPassword}\n`
    return openSearchContent + injectLoggingContent
  }

  return injectLoggingContent
}
+8 −31
Original line number Diff line number Diff line
@@ -6,21 +6,13 @@ import {
  type OpenSearchAccess,
} from '@inject/graphql'
import { useTranslationFrontend } from '@inject/locale'
import { createSandboxLogUrl, dialog, dialogBody } from '@inject/shared'
import { dialog, dialogBody } from '@inject/shared'
import { useState, type FC } from 'react'
import { getEnvContent } from './getEnvContent'

// TODO: localization

// TODO: verification of OpenSearch connection

// TODO: consolidate
declare global {
  interface Window {
    VITE_OPENSEARCH_HOST: string | undefined
    VITE_OPENSEARCH_PORT: string | undefined
  }
}

interface SandboxConfigurationButtonProps {
  openSearchAccess?: OpenSearchAccess | null
  hideLabel?: boolean
@@ -40,27 +32,12 @@ export const SandboxConfigurationButton: FC<
  const token = data?.userTeamToken || ''

  const handleDownload = () => {
    const openSearchHost = window.VITE_OPENSEARCH_HOST
    const port = window.VITE_OPENSEARCH_PORT

    const injectLoggingContent =
      `INJECT_HOST=${createSandboxLogUrl(host, teamId)}\n` +
      `INJECT_TEAM_TOKEN=${token}\n`

    const envContent = (() => {
      if (openSearchHost && port && openSearchAccess) {
        const { indexName, password, username } = openSearchAccess
        const openSearchContent =
          `OPENSEARCH_HOST=https://${openSearchHost}:${port}\n` +
          `OPENSEARCH_INDEX=${indexName}\n` +
          `OPENSEARCH_USER=${username}\n` +
          `OPENSEARCH_PASSWORD=${password}\n`

        return openSearchContent + injectLoggingContent
      }

      return injectLoggingContent
    })()
    const envContent = getEnvContent({
      openSearchAccess,
      teamId,
      token,
      host,
    })

    const blob = new Blob([envContent], { type: 'text/plain' })
    const url = URL.createObjectURL(blob)
+1 −1
Original line number Diff line number Diff line
@@ -20,13 +20,13 @@ import {
} from '@inject/shared'
import { Suspense, useEffect, type FC, type PropsWithChildren } from 'react'
import { ExerciseStatus, ExitButton } from '../../components'
import { SandboxConfigurationButton } from '../../components/SandboxConfigurationButton'
import TraineeEmailFormOverlay from '../../email/EmailFormOverlay/TraineeEmailFormOverlay'
import useMailToRef from '../../hooks/useMailToRef'
import { RootRoute } from '../../routes/__root'
import { TraineeDriveRoute } from '../../routes/_protected/trainee/$exerciseId/$teamId/drive'
import ChannelButton from '../ChannelButton'
import { OverviewButton } from './OverviewButton'
import { SandboxConfigurationButton } from './SandboxConfigurationButton'
import StopAnnounce from './StopAnnounce'
import useTraineeViewData from './useTraineeViewData'

Loading