Loading analyst/src/components/Plots/TimeScatterPlot/TimeRangeSelector/index.tsx +47 −26 Original line number Diff line number Diff line Loading @@ -24,31 +24,20 @@ export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ selectedTimeRangeInitialEnd, ]) return ( <RangeSlider min={0} max={selectedTimeRangeInitialEnd} value={sliderRange} onChange={setSliderRange} onRelease={([start, end]) => setSelectedTimeRange({ rangeStart: start, rangeEnd: end, }) const labelRenderer = ( value: number, opts?: { isHandleTooltip: boolean } 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', })} > ) => { const children = (() => { if (!opts?.isHandleTooltip && value === 0) { return 'Exercise Started' } if (!opts?.isHandleTooltip && value === selectedTimeRangeInitialEnd) { return 'Duration Expired' } return ( <Timestamp stringOnly formatTimestampProps={{ Loading @@ -62,8 +51,40 @@ export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ `, }} /> ) })() return ( <div className={cx({ [css` text-align: center; `]: true, [css` white-space: nowrap; `]: timeFormat !== 'absolute' || !opts?.isHandleTooltip, })} > {children} </div> )} ) } return ( <RangeSlider min={0} max={selectedTimeRangeInitialEnd} value={sliderRange} onChange={setSliderRange} onRelease={([start, end]) => setSelectedTimeRange({ rangeStart: start, rangeEnd: end, }) } stepSize={1000 * 60} // 1 minute steps labelStepSize={selectedTimeRangeInitialEnd / 5} labelRenderer={labelRenderer} /> ) } analyst/src/components/SandboxLogsTable/index.tsx 0 → 100644 +111 −0 Original line number Diff line number Diff line import { css } from '@emotion/css' import type { Column, Row } from '@inject/shared' import { SortingOrder, stringSortingFunction, Table, Timestamp, timestampSortingFunction, } from '@inject/shared' import type { FC } from 'react' import type { SandboxLogChecked } from './types' const centerChildren = css` display: flex; justify-content: center; align-items: center; ` interface SandboxLogsTableProps { sandboxLogs: SandboxLogChecked[] allTeamsTab: boolean } export const SandboxLogsTable: FC<SandboxLogsTableProps> = ({ sandboxLogs, allTeamsTab, }) => { const columns: Column<SandboxLogChecked>[] = [ { id: 'team-name', name: 'Team', display: allTeamsTab, renderValue: log => log.team.name, sortingFunction: (a, b) => stringSortingFunction(a.team.name, b.team.name), }, { id: 'timestamp', name: 'Time', renderValue: log => ( <div className={centerChildren}> <Timestamp formatTimestampProps={{ timestamp: log.timestamp, inExerciseTime: log.inExerciseTime, minimal: true, }} /> </div> ), sortingFunction: (a, b) => timestampSortingFunction(a.timestamp, b.timestamp), defaultSortingOrder: SortingOrder.DESC, }, { id: 'command', name: 'Command', renderValue: log => log.details.cmd, sortingFunction: (a, b) => stringSortingFunction(a.details.cmd, b.details.cmd), }, { id: 'source', name: 'Source', renderValue: log => log.details.cmdSource, sortingFunction: (a, b) => stringSortingFunction(a.details.cmdSource, b.details.cmdSource), }, { id: 'directory', name: 'Directory', renderValue: log => log.details.workingDirectory, sortingFunction: (a, b) => stringSortingFunction( a.details.workingDirectory, b.details.workingDirectory ), }, { id: 'username', name: 'Username', renderValue: log => log.details.username, sortingFunction: (a, b) => stringSortingFunction(a.details.username, b.details.username), }, { id: 'container', name: 'Container', renderValue: log => log.details.container, sortingFunction: (a, b) => stringSortingFunction(a.details.container, b.details.container), }, ] 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} /> ) } analyst/src/components/SandboxLogsTable/types.ts 0 → 100644 +8 −0 Original line number Diff line number Diff line import type { IAnalystSandboxLog, IAnalystSandboxLogDetails, } from '@inject/graphql' export type SandboxLogChecked = Omit<IAnalystSandboxLog, 'details'> & { details: IAnalystSandboxLogDetails } analyst/src/routes/_layout/$exerciseId/action-logs.tsx +8 −6 Original line number Diff line number Diff line Loading @@ -22,12 +22,6 @@ const RouteComponent = () => { useEffect(() => dispatch({ type: 'reset' }), [dispatch, exerciseId]) const actionLogOptionsProps = useActionLogOptionsWithAutoProps({ teamCount: teams.length, teamLimitDefault: 20, selectedTeamId: teams.length === 1 ? teams[0].id : null, }) const { earliestStartTime, latestStartTime, Loading @@ -41,6 +35,14 @@ const RouteComponent = () => { includeAll: true, }) const { selectedTeam } = teamSelectorProps const selectedTeamId = selectedTeam?.id || null const teamIds = selectedTeamId ? [selectedTeamId] : teams.map(t => t.id) const actionLogOptionsProps = useActionLogOptionsWithAutoProps({ teamCount: teamIds.length, teamLimitDefault: 20, selectedTeamId, }) return ( <div Loading analyst/src/routes/_layout/$exerciseId/sandbox-logs.tsx +125 −16 Original line number Diff line number Diff line import { Divider } from '@blueprintjs/core' import { css } from '@emotion/css' import { useSubscribedTeams } from '@inject/frontend' import type { TeamLimitOptions } from '@inject/frontend' import { TeamLimitSelect, useSubscribedTeams } from '@inject/frontend' import type { VariablesOf } from '@inject/graphql' import { AnalystSandboxLogsQuery, AnalystSandboxLogsSubscription, useSubscription, useTypedQuery, } from '@inject/graphql' import { CenteredSpinner } from '@inject/shared' import { createFileRoute } from '@tanstack/react-router' import { useEffect, useRef, useState } from 'react' import { TimeRangeSelector } from '../../../components/Plots/TimeScatterPlot/TimeRangeSelector' import { useTimeRangeSelectorProps } from '../../../components/Plots/TimeScatterPlot/TimeRangeSelector/useTimeRangeSelectorProps' import { SandboxLogsTable } from '../../../components/SandboxLogsTable' import type { SandboxLogChecked } from '../../../components/SandboxLogsTable/types' import { TeamSelector } from '../../../components/TeamSelector' import { useTeamSelectorProps } from '../../../components/TeamSelector/useTeamSelectorProps' // TODO: table // TODO: sortable table // TODO: select columns // TODO: filtered table // TODO: searchbar // TODO: histogram // TODO: time range selector like action-logs // TODO: when all teams selected, show how many logs per team // a) inside the histogram // b) a separate bar chart const wrapper = css` display: flex; flex-direction: column; height: 100%; overflow: auto; ` // TODO: allow logs outside of time range (outside of exercise time) const RouteComponent = () => { const { exerciseId } = SandboxLogsRoute.useParams() Loading @@ -30,12 +35,116 @@ const RouteComponent = () => { teams, includeAll: true, }) const selectedTeamId = teamSelectorProps.selectedTeam?.id || null const teamLimitDefault = selectedTeamId ? 50 : 10 const [teamLimit, setTeamLimit] = useState<TeamLimitOptions>(teamLimitDefault) const prevTeamIdRef = useRef<string | null>(selectedTeamId) // Reset team limit when switching from a specific team to "all teams" useEffect(() => { if (prevTeamIdRef.current !== null && selectedTeamId === null) { setTeamLimit(teamLimitDefault) } prevTeamIdRef.current = selectedTeamId }, [selectedTeamId, teamLimitDefault]) // TODO: add first and last timestamp so that logs outside of the exercise can also be shown (e.g. sandbox logs from before the exercise started) // TODO: also in other places where time range selector is used const { earliestStartTime, selectedTimeRange, selectedTimeRangeInitialEnd, setSelectedTimeRange, } = useTimeRangeSelectorProps() // TODO: test if works when changing teams const teamIds = selectedTeamId ? [selectedTeamId] : teams.map(t => t.id) const [{ data, fetching }] = useTypedQuery({ query: AnalystSandboxLogsQuery, variables: { teamIds, exerciseId, teamLimit, }, pause: !teamIds.length, requestPolicy: 'cache-and-network', }) useSubscription({ document: AnalystSandboxLogsSubscription, variables: { teamIds } as VariablesOf< typeof AnalystSandboxLogsSubscription >, pause: !teamIds.length, }) const sandboxLogsInRange = data ? (data.teamActionLogs as SandboxLogChecked[]).filter(actionLog => { const actionLogTime = new Date(actionLog.timestamp).getTime() return ( <div className={wrapper}> actionLogTime >= earliestStartTime + selectedTimeRange.rangeStart && actionLogTime <= earliestStartTime + selectedTimeRange.rangeEnd ) }) : [] return ( <main className={css` display: flex; flex-direction: column; height: 100%; overflow: auto; gap: 2rem; `} > <section className={css` display: flex; flex-direction: column; `} > <div className={css` display: flex; justify-content: space-between; gap: 1rem; `} > <TeamSelector {...teamSelectorProps} /> <Divider /> <TeamLimitSelect teamLimit={teamLimit} setTeamLimit={setTeamLimit} teamCount={teamIds.length} /> </div> <div className={css` padding: 0.5rem 4rem 0 4rem; `} > <TimeRangeSelector earliestStartTime={earliestStartTime} selectedTimeRangeInitialEnd={selectedTimeRangeInitialEnd} setSelectedTimeRange={setSelectedTimeRange} /> </div> </section> <section className={css` flex: 1; overflow: auto; `} > {(fetching || !data) && <CenteredSpinner />} {data && ( <SandboxLogsTable sandboxLogs={sandboxLogsInRange} allTeamsTab={selectedTeamId === null} /> )} </section> </main> ) } Loading Loading
analyst/src/components/Plots/TimeScatterPlot/TimeRangeSelector/index.tsx +47 −26 Original line number Diff line number Diff line Loading @@ -24,31 +24,20 @@ export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ selectedTimeRangeInitialEnd, ]) return ( <RangeSlider min={0} max={selectedTimeRangeInitialEnd} value={sliderRange} onChange={setSliderRange} onRelease={([start, end]) => setSelectedTimeRange({ rangeStart: start, rangeEnd: end, }) const labelRenderer = ( value: number, opts?: { isHandleTooltip: boolean } 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', })} > ) => { const children = (() => { if (!opts?.isHandleTooltip && value === 0) { return 'Exercise Started' } if (!opts?.isHandleTooltip && value === selectedTimeRangeInitialEnd) { return 'Duration Expired' } return ( <Timestamp stringOnly formatTimestampProps={{ Loading @@ -62,8 +51,40 @@ export const TimeRangeSelector: FC<TimeRangeSelectorProps> = ({ `, }} /> ) })() return ( <div className={cx({ [css` text-align: center; `]: true, [css` white-space: nowrap; `]: timeFormat !== 'absolute' || !opts?.isHandleTooltip, })} > {children} </div> )} ) } return ( <RangeSlider min={0} max={selectedTimeRangeInitialEnd} value={sliderRange} onChange={setSliderRange} onRelease={([start, end]) => setSelectedTimeRange({ rangeStart: start, rangeEnd: end, }) } stepSize={1000 * 60} // 1 minute steps labelStepSize={selectedTimeRangeInitialEnd / 5} labelRenderer={labelRenderer} /> ) }
analyst/src/components/SandboxLogsTable/index.tsx 0 → 100644 +111 −0 Original line number Diff line number Diff line import { css } from '@emotion/css' import type { Column, Row } from '@inject/shared' import { SortingOrder, stringSortingFunction, Table, Timestamp, timestampSortingFunction, } from '@inject/shared' import type { FC } from 'react' import type { SandboxLogChecked } from './types' const centerChildren = css` display: flex; justify-content: center; align-items: center; ` interface SandboxLogsTableProps { sandboxLogs: SandboxLogChecked[] allTeamsTab: boolean } export const SandboxLogsTable: FC<SandboxLogsTableProps> = ({ sandboxLogs, allTeamsTab, }) => { const columns: Column<SandboxLogChecked>[] = [ { id: 'team-name', name: 'Team', display: allTeamsTab, renderValue: log => log.team.name, sortingFunction: (a, b) => stringSortingFunction(a.team.name, b.team.name), }, { id: 'timestamp', name: 'Time', renderValue: log => ( <div className={centerChildren}> <Timestamp formatTimestampProps={{ timestamp: log.timestamp, inExerciseTime: log.inExerciseTime, minimal: true, }} /> </div> ), sortingFunction: (a, b) => timestampSortingFunction(a.timestamp, b.timestamp), defaultSortingOrder: SortingOrder.DESC, }, { id: 'command', name: 'Command', renderValue: log => log.details.cmd, sortingFunction: (a, b) => stringSortingFunction(a.details.cmd, b.details.cmd), }, { id: 'source', name: 'Source', renderValue: log => log.details.cmdSource, sortingFunction: (a, b) => stringSortingFunction(a.details.cmdSource, b.details.cmdSource), }, { id: 'directory', name: 'Directory', renderValue: log => log.details.workingDirectory, sortingFunction: (a, b) => stringSortingFunction( a.details.workingDirectory, b.details.workingDirectory ), }, { id: 'username', name: 'Username', renderValue: log => log.details.username, sortingFunction: (a, b) => stringSortingFunction(a.details.username, b.details.username), }, { id: 'container', name: 'Container', renderValue: log => log.details.container, sortingFunction: (a, b) => stringSortingFunction(a.details.container, b.details.container), }, ] 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} /> ) }
analyst/src/components/SandboxLogsTable/types.ts 0 → 100644 +8 −0 Original line number Diff line number Diff line import type { IAnalystSandboxLog, IAnalystSandboxLogDetails, } from '@inject/graphql' export type SandboxLogChecked = Omit<IAnalystSandboxLog, 'details'> & { details: IAnalystSandboxLogDetails }
analyst/src/routes/_layout/$exerciseId/action-logs.tsx +8 −6 Original line number Diff line number Diff line Loading @@ -22,12 +22,6 @@ const RouteComponent = () => { useEffect(() => dispatch({ type: 'reset' }), [dispatch, exerciseId]) const actionLogOptionsProps = useActionLogOptionsWithAutoProps({ teamCount: teams.length, teamLimitDefault: 20, selectedTeamId: teams.length === 1 ? teams[0].id : null, }) const { earliestStartTime, latestStartTime, Loading @@ -41,6 +35,14 @@ const RouteComponent = () => { includeAll: true, }) const { selectedTeam } = teamSelectorProps const selectedTeamId = selectedTeam?.id || null const teamIds = selectedTeamId ? [selectedTeamId] : teams.map(t => t.id) const actionLogOptionsProps = useActionLogOptionsWithAutoProps({ teamCount: teamIds.length, teamLimitDefault: 20, selectedTeamId, }) return ( <div Loading
analyst/src/routes/_layout/$exerciseId/sandbox-logs.tsx +125 −16 Original line number Diff line number Diff line import { Divider } from '@blueprintjs/core' import { css } from '@emotion/css' import { useSubscribedTeams } from '@inject/frontend' import type { TeamLimitOptions } from '@inject/frontend' import { TeamLimitSelect, useSubscribedTeams } from '@inject/frontend' import type { VariablesOf } from '@inject/graphql' import { AnalystSandboxLogsQuery, AnalystSandboxLogsSubscription, useSubscription, useTypedQuery, } from '@inject/graphql' import { CenteredSpinner } from '@inject/shared' import { createFileRoute } from '@tanstack/react-router' import { useEffect, useRef, useState } from 'react' import { TimeRangeSelector } from '../../../components/Plots/TimeScatterPlot/TimeRangeSelector' import { useTimeRangeSelectorProps } from '../../../components/Plots/TimeScatterPlot/TimeRangeSelector/useTimeRangeSelectorProps' import { SandboxLogsTable } from '../../../components/SandboxLogsTable' import type { SandboxLogChecked } from '../../../components/SandboxLogsTable/types' import { TeamSelector } from '../../../components/TeamSelector' import { useTeamSelectorProps } from '../../../components/TeamSelector/useTeamSelectorProps' // TODO: table // TODO: sortable table // TODO: select columns // TODO: filtered table // TODO: searchbar // TODO: histogram // TODO: time range selector like action-logs // TODO: when all teams selected, show how many logs per team // a) inside the histogram // b) a separate bar chart const wrapper = css` display: flex; flex-direction: column; height: 100%; overflow: auto; ` // TODO: allow logs outside of time range (outside of exercise time) const RouteComponent = () => { const { exerciseId } = SandboxLogsRoute.useParams() Loading @@ -30,12 +35,116 @@ const RouteComponent = () => { teams, includeAll: true, }) const selectedTeamId = teamSelectorProps.selectedTeam?.id || null const teamLimitDefault = selectedTeamId ? 50 : 10 const [teamLimit, setTeamLimit] = useState<TeamLimitOptions>(teamLimitDefault) const prevTeamIdRef = useRef<string | null>(selectedTeamId) // Reset team limit when switching from a specific team to "all teams" useEffect(() => { if (prevTeamIdRef.current !== null && selectedTeamId === null) { setTeamLimit(teamLimitDefault) } prevTeamIdRef.current = selectedTeamId }, [selectedTeamId, teamLimitDefault]) // TODO: add first and last timestamp so that logs outside of the exercise can also be shown (e.g. sandbox logs from before the exercise started) // TODO: also in other places where time range selector is used const { earliestStartTime, selectedTimeRange, selectedTimeRangeInitialEnd, setSelectedTimeRange, } = useTimeRangeSelectorProps() // TODO: test if works when changing teams const teamIds = selectedTeamId ? [selectedTeamId] : teams.map(t => t.id) const [{ data, fetching }] = useTypedQuery({ query: AnalystSandboxLogsQuery, variables: { teamIds, exerciseId, teamLimit, }, pause: !teamIds.length, requestPolicy: 'cache-and-network', }) useSubscription({ document: AnalystSandboxLogsSubscription, variables: { teamIds } as VariablesOf< typeof AnalystSandboxLogsSubscription >, pause: !teamIds.length, }) const sandboxLogsInRange = data ? (data.teamActionLogs as SandboxLogChecked[]).filter(actionLog => { const actionLogTime = new Date(actionLog.timestamp).getTime() return ( <div className={wrapper}> actionLogTime >= earliestStartTime + selectedTimeRange.rangeStart && actionLogTime <= earliestStartTime + selectedTimeRange.rangeEnd ) }) : [] return ( <main className={css` display: flex; flex-direction: column; height: 100%; overflow: auto; gap: 2rem; `} > <section className={css` display: flex; flex-direction: column; `} > <div className={css` display: flex; justify-content: space-between; gap: 1rem; `} > <TeamSelector {...teamSelectorProps} /> <Divider /> <TeamLimitSelect teamLimit={teamLimit} setTeamLimit={setTeamLimit} teamCount={teamIds.length} /> </div> <div className={css` padding: 0.5rem 4rem 0 4rem; `} > <TimeRangeSelector earliestStartTime={earliestStartTime} selectedTimeRangeInitialEnd={selectedTimeRangeInitialEnd} setSelectedTimeRange={setSelectedTimeRange} /> </div> </section> <section className={css` flex: 1; overflow: auto; `} > {(fetching || !data) && <CenteredSpinner />} {data && ( <SandboxLogsTable sandboxLogs={sandboxLogsInRange} allTeamsTab={selectedTeamId === null} /> )} </section> </main> ) } Loading