From 9a15f76e7b961ea7ccf01c72fe6d98f0e8bf99f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Uladzimir=20Dzmitra=C4=8Dko=C5=AD?= <3773351+going-confetti@users.noreply.github.com> Date: Thu, 1 Aug 2024 11:58:42 +0200 Subject: [PATCH] feat: Route-based nav (#74) --- src/App.tsx | 9 +- src/browser.ts | 1 + src/components/FileTree/File.tsx | 33 ++--- src/components/FileTree/FileList.tsx | 20 +-- src/components/FileTree/FileTree.tsx | 6 +- src/components/Layout/Layout.tsx | 13 +- .../Layout/Sidebar/Sidebar.hooks.ts | 9 ++ src/components/Layout/Sidebar/Sidebar.tsx | 13 +- src/components/Layout/View.tsx | 23 ++++ src/components/WebLogView/WebLogView.tsx | 27 ++-- src/hooks/useAutoScroll.ts | 6 +- src/hooks/useListenProxyData.ts | 26 ++-- src/main.ts | 74 ++++++----- src/preload.ts | 25 +++- src/store/generator/slices/rules.ts | 3 +- .../slices/testOptions/loadProfile.ts | 16 +-- src/store/generator/useGeneratorStore.ts | 2 +- src/store/recorder/index.ts | 2 - src/store/recorder/selectors.ts | 6 - src/store/recorder/useRecorderStore.ts | 46 ------- src/store/ui/useStudioUIStore.ts | 21 ++-- src/utils/file.ts | 3 + src/utils/generator.ts | 42 +++++++ src/utils/proxyDataToHar.ts | 1 - src/views/Generator/Generator.tsx | 45 ++++--- src/views/Generator/Generator.utils.ts | 10 +- .../GeneratorDrawer/GeneratorDrawer.tsx | 2 +- src/views/Generator/RecordingSelector.tsx | 4 +- src/views/Home/Home.tsx | 30 ++++- src/views/Home/NavigationCard.tsx | 9 +- src/views/Recorder/Recorder.tsx | 113 ++++++++++++----- src/views/Recorder/RecordingButton.tsx | 88 ------------- src/views/Recorder/RequestsSection.tsx | 34 +++++ .../RecordingPreviewer/RecordingPreviewer.tsx | 116 ++++++++++++++++++ src/views/RecordingPreviewer/index.ts | 1 + src/views/Validator/LogsPaneContent.tsx | 26 ++++ src/views/Validator/RequestsPaneContent.tsx | 26 ++++ src/views/Validator/ScriptPaneContent.tsx | 17 +++ src/views/Validator/Validator.tsx | 115 ++++++++--------- src/views/Validator/ValidatorControls.tsx | 46 +++++++ 40 files changed, 708 insertions(+), 401 deletions(-) create mode 100644 src/components/Layout/View.tsx delete mode 100644 src/store/recorder/index.ts delete mode 100644 src/store/recorder/selectors.ts delete mode 100644 src/store/recorder/useRecorderStore.ts create mode 100644 src/utils/file.ts create mode 100644 src/utils/generator.ts delete mode 100644 src/views/Recorder/RecordingButton.tsx create mode 100644 src/views/Recorder/RequestsSection.tsx create mode 100644 src/views/RecordingPreviewer/RecordingPreviewer.tsx create mode 100644 src/views/RecordingPreviewer/index.ts create mode 100644 src/views/Validator/LogsPaneContent.tsx create mode 100644 src/views/Validator/RequestsPaneContent.tsx create mode 100644 src/views/Validator/ScriptPaneContent.tsx create mode 100644 src/views/Validator/ValidatorControls.tsx diff --git a/src/App.tsx b/src/App.tsx index 06bfdf25..6c47f13f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { Home } from '@/views/Home' import { Generator } from './views/Generator/Generator' import { useTheme } from './hooks/useTheme' import { globalStyles } from './globalStyles' +import { RecordingPreviewer } from './views/RecordingPreviewer' export function App() { const theme = useTheme() @@ -21,8 +22,12 @@ export function App() { }> } /> } /> - } /> - } /> + } + /> + } /> + } /> diff --git a/src/browser.ts b/src/browser.ts index a0eb5099..6b143738 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -45,6 +45,7 @@ export const launchBrowser = async () => { '--no-first-run', '--disable-background-networking', '--disable-component-update', + '--disable-search-engine-choice-screen', `--proxy-server=http://localhost:${proxyPort}`, `--ignore-certificate-errors-spki-list=${certificateSPKI}`, disableChromeOptimizations, diff --git a/src/components/FileTree/File.tsx b/src/components/FileTree/File.tsx index 5f9103df..c104de9b 100644 --- a/src/components/FileTree/File.tsx +++ b/src/components/FileTree/File.tsx @@ -1,20 +1,22 @@ import { css } from '@emotion/react' import { Button, Tooltip } from '@radix-ui/themes' +import { getFileNameFromPath } from '@/utils/file' +import { Link } from 'react-router-dom' + interface FileProps { path: string - isSelected?: boolean - onOpen?: (path: string) => void + viewPath: string + isSelected: boolean } -export function File({ path, isSelected, onOpen }: FileProps) { - const fileName = path.split('/').pop() +export function File({ path, viewPath, isSelected }: FileProps) { + const fileName = getFileNameFromPath(path) return ( ) diff --git a/src/components/FileTree/FileList.tsx b/src/components/FileTree/FileList.tsx index 6be67ea1..d011760e 100644 --- a/src/components/FileTree/FileList.tsx +++ b/src/components/FileTree/FileList.tsx @@ -1,23 +1,17 @@ import { css } from '@emotion/react' import { Button } from '@radix-ui/themes' import { File } from './File' -import { useStudioUIStore } from '@/store/ui' +import { useParams } from 'react-router-dom' interface FileListProps { files: string[] + viewPath: string noFilesMessage: string onOpenFile?: (path: string) => void } -export function FileList({ files, onOpenFile, noFilesMessage }: FileListProps) { - const selectedFile = useStudioUIStore((state) => state.selectedFile) - const setSelectedFile = useStudioUIStore((state) => state.setSelectedFile) - - const handleOpenFile = (path: string) => { - if (!onOpenFile) return - onOpenFile(path) - setSelectedFile(path) - } +export function FileList({ files, noFilesMessage, viewPath }: FileListProps) { + const { path } = useParams() if (files.length === 0) { return ( @@ -50,11 +44,7 @@ export function FileList({ files, onOpenFile, noFilesMessage }: FileListProps) { > {files.map((file) => (
  • - +
  • ))} diff --git a/src/components/FileTree/FileTree.tsx b/src/components/FileTree/FileTree.tsx index 009d67a4..9317daaf 100644 --- a/src/components/FileTree/FileTree.tsx +++ b/src/components/FileTree/FileTree.tsx @@ -8,15 +8,15 @@ import { FileList } from './FileList' interface FileTreeProps { label: string files: string[] + viewPath: string noFilesMessage?: string - onOpenFile?: (path: string) => void } export function FileTree({ label, files, + viewPath, noFilesMessage = 'No files found', - onOpenFile, }: FileTreeProps) { const [open, setOpen] = useState(true) @@ -37,7 +37,7 @@ export function FileTree({ diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx index a2a2740c..a5d4074b 100644 --- a/src/components/Layout/Layout.tsx +++ b/src/components/Layout/Layout.tsx @@ -1,4 +1,4 @@ -import { Box, Flex } from '@radix-ui/themes' +import { Box } from '@radix-ui/themes' import { css } from '@emotion/react' import { Allotment } from 'allotment' import { Outlet } from 'react-router-dom' @@ -22,21 +22,14 @@ export function Layout() { `} > - + - - - + {bottomDrawer.isOpen && ( diff --git a/src/components/Layout/Sidebar/Sidebar.hooks.ts b/src/components/Layout/Sidebar/Sidebar.hooks.ts index da758d1e..11497e18 100644 --- a/src/components/Layout/Sidebar/Sidebar.hooks.ts +++ b/src/components/Layout/Sidebar/Sidebar.hooks.ts @@ -6,6 +6,7 @@ export function useFolderContent() { const generators = useStudioUIStore((s) => s.generators) const scripts = useStudioUIStore((s) => s.scripts) const addFile = useStudioUIStore((s) => s.addFile) + const removeFile = useStudioUIStore((s) => s.removeFile) const setFolderContent = useStudioUIStore((s) => s.setFolderContent) useEffect(() => { @@ -23,6 +24,14 @@ export function useFolderContent() { [addFile] ) + useEffect( + () => + window.studio.ui.onRemoveFile((path) => { + removeFile(path) + }), + [removeFile] + ) + return { recordings, generators, diff --git a/src/components/Layout/Sidebar/Sidebar.tsx b/src/components/Layout/Sidebar/Sidebar.tsx index 9943b8dd..5f85e8d9 100644 --- a/src/components/Layout/Sidebar/Sidebar.tsx +++ b/src/components/Layout/Sidebar/Sidebar.tsx @@ -1,21 +1,14 @@ import { css } from '@emotion/react' import { Box, Flex, Heading, IconButton, ScrollArea } from '@radix-ui/themes' -import { Link, useNavigate } from 'react-router-dom' +import { Link } from 'react-router-dom' import { ThemeSwitcher } from '@/components/ThemeSwitcher' import { FileTree } from '@/components/FileTree' import { useFolderContent } from './Sidebar.hooks' -import { loadGenerator } from '@/views/Generator/Generator.utils' import K6Logo from '@/assets/logo.svg' export function Sidebar() { const { recordings, generators, scripts } = useFolderContent() - const navigate = useNavigate() - - const handleOpenGenerator = (path: string) => { - loadGenerator(path) - navigate('/generator') - } return ( diff --git a/src/components/Layout/View.tsx b/src/components/Layout/View.tsx new file mode 100644 index 00000000..8c936310 --- /dev/null +++ b/src/components/Layout/View.tsx @@ -0,0 +1,23 @@ +import { Box, Flex } from '@radix-ui/themes' +import { PropsWithChildren, ReactNode } from 'react' +import { PageHeading } from './PageHeading' + +interface ViewProps { + title: string + actions: ReactNode + loading?: boolean +} + +export function View({ + title, + actions, + loading = false, + children, +}: PropsWithChildren) { + return ( + + {actions} + {loading ? Loading... : children} + + ) +} diff --git a/src/components/WebLogView/WebLogView.tsx b/src/components/WebLogView/WebLogView.tsx index a496f297..b5758b99 100644 --- a/src/components/WebLogView/WebLogView.tsx +++ b/src/components/WebLogView/WebLogView.tsx @@ -7,13 +7,14 @@ import { isGroupedProxyData } from './WebLogView.utils' import { Row } from './Row' import { Group } from './Group' -export function WebLogView({ - requests, -}: { +interface WebLogViewProps { requests: ProxyData[] | GroupedProxyData -}) { + noRequestsMessage?: string +} + +export function WebLogView({ requests, noRequestsMessage }: WebLogViewProps) { if (isEmpty(requests)) { - return + return } if (isGroupedProxyData(requests)) { @@ -31,7 +32,11 @@ export function WebLogView({ return } -function RequestList({ requests }: { requests: ProxyData[] }) { +interface RequestListProps { + requests: ProxyData[] +} + +function RequestList({ requests }: RequestListProps) { return ( <> {requests.map((data) => ( @@ -41,13 +46,19 @@ function RequestList({ requests }: { requests: ProxyData[] }) { ) } -function NoRequestsMessage() { +interface NoRequestsMessageProps { + noRequestsMessage?: string +} + +function NoRequestsMessage({ + noRequestsMessage = 'Your requests will appear here.', +}: NoRequestsMessageProps) { return ( - Your requests will appear here. + {noRequestsMessage} ) } diff --git a/src/hooks/useAutoScroll.ts b/src/hooks/useAutoScroll.ts index f3450fa5..6fed6fbd 100644 --- a/src/hooks/useAutoScroll.ts +++ b/src/hooks/useAutoScroll.ts @@ -1,13 +1,13 @@ import { useEffect, useRef } from 'react' -export function useAutoScroll(items: unknown) { +export function useAutoScroll(items: unknown, enabled = true) { const bottomRef = useRef(null) useEffect(() => { - if (!bottomRef.current) return + if (!bottomRef.current || !enabled) return bottomRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' }) - }, [items]) + }, [items, enabled]) return bottomRef } diff --git a/src/hooks/useListenProxyData.ts b/src/hooks/useListenProxyData.ts index 0c07fce2..47dc7c27 100644 --- a/src/hooks/useListenProxyData.ts +++ b/src/hooks/useListenProxyData.ts @@ -1,16 +1,15 @@ -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' -import { useRecorderStore } from '@/store/recorder/useRecorderStore' +import { ProxyData } from '@/types' +import { mergeRequestsById } from '@/views/Recorder/Recorder.utils' export function useListenProxyData(group?: string) { - const { resetProxyData, addRequest } = useRecorderStore() + const [proxyData, setProxyData] = useState([]) const groupRef = useRef(group) - useEffect(() => { - return () => { - resetProxyData() - } - }, [resetProxyData]) + const resetProxyData = useCallback(() => { + setProxyData([]) + }, []) useEffect(() => { // Create ref to avoid creating multiple listeners @@ -20,7 +19,14 @@ export function useListenProxyData(group?: string) { useEffect(() => { return window.studio.proxy.onProxyData((data) => { - addRequest(data, groupRef.current ?? 'default') + setProxyData((s) => + mergeRequestsById(s, { + ...data, + group: groupRef.current ?? 'default', + }) + ) }) - }, [addRequest]) + }, []) + + return { proxyData, resetProxyData } } diff --git a/src/main.ts b/src/main.ts index a9271f55..8a99f731 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { app, BrowserWindow, dialog, ipcMain, nativeTheme } from 'electron' -import { open, writeFile } from 'fs/promises' +import { open, writeFile, unlink } from 'fs/promises' import { readdirSync } from 'fs' import path from 'path' import eventEmmitter from 'events' @@ -178,6 +178,17 @@ ipcMain.handle('script:select', async (event) => { } }) +ipcMain.handle('script:open', async (_, filePath: string) => { + const fileHandle = await open(filePath, 'r') + try { + const script = await fileHandle?.readFile({ encoding: 'utf-8' }) + + return { path: filePath, content: script } + } finally { + await fileHandle?.close() + } +}) + ipcMain.handle('script:run', async (event, scriptPath: string) => { console.info('script:run event received') await waitForProxy() @@ -216,20 +227,10 @@ ipcMain.on('script:save', async (event, script: string) => { }) // HAR -ipcMain.on('har:save', async (event, data) => { - console.info('har:save event received') - const browserWindow = browserWindowFromEvent(event) - - const dialogResult = await dialog.showSaveDialog(browserWindow, { - message: 'Save HAR file of the recording', - defaultPath: path.join(RECORDINGS_PATH, `${new Date().toISOString()}.har`), - }) - - if (dialogResult.canceled) { - return - } - - await writeFile(dialogResult.filePath, data) +ipcMain.handle('har:save', async (_, data) => { + const fineName = `${new Date().toISOString()}.har` + await writeFile(path.join(RECORDINGS_PATH, fineName), data) + return path.join(RECORDINGS_PATH, fineName) }) ipcMain.handle('har:open', async (event, filePath?: string) => { @@ -243,6 +244,7 @@ ipcMain.handle('har:open', async (event, filePath?: string) => { const dialogResult = await dialog.showOpenDialog(browserWindow, { message: 'Open HAR file', properties: ['openFile'], + defaultPath: RECORDINGS_PATH, filters: [{ name: 'HAR', extensions: ['har'] }], }) @@ -253,6 +255,11 @@ ipcMain.handle('har:open', async (event, filePath?: string) => { return }) +ipcMain.handle('har:delete', async (_, filePath: string) => { + console.info('har:delete event received') + return unlink(filePath) +}) + const loadHarFile = async (filePath: string) => { const fileHandle = await open(filePath, 'r') try { @@ -265,22 +272,32 @@ const loadHarFile = async (filePath: string) => { } } -ipcMain.on('generator:save', async (event, generatorFile: string) => { - console.info('generator:save event received') +// Generator +ipcMain.handle( + 'generator:save', + async (event, generatorFile: string, fileName?: string) => { + console.info('generator:save event received') - const browserWindow = browserWindowFromEvent(event) - const dialogResult = await dialog.showSaveDialog(browserWindow, { - message: 'Save Generator', - defaultPath: path.join(GENERATORS_PATH, 'generator.json'), - filters: [{ name: 'JSON', extensions: ['json'] }], - }) + if (fileName) { + await writeFile(path.join(GENERATORS_PATH, fileName), generatorFile) + return path.join(GENERATORS_PATH, fileName) + } - if (dialogResult.canceled) { - return - } + const browserWindow = browserWindowFromEvent(event) + const dialogResult = await dialog.showSaveDialog(browserWindow, { + message: 'Save Generator', + defaultPath: path.join(GENERATORS_PATH, 'generator.json'), + filters: [{ name: 'JSON', extensions: ['json'] }], + }) - await writeFile(dialogResult.filePath, generatorFile) -}) + if (dialogResult.canceled) { + return + } + + await writeFile(dialogResult.filePath, generatorFile) + return dialogResult.filePath + } +) ipcMain.handle('generator:open', async (event, path?: string) => { console.info('generator:open event received') @@ -293,6 +310,7 @@ ipcMain.handle('generator:open', async (event, path?: string) => { const dialogResult = await dialog.showOpenDialog(browserWindow, { message: 'Open Generator file', properties: ['openFile'], + defaultPath: GENERATORS_PATH, filters: [{ name: 'JSON', extensions: ['json'] }], }) diff --git a/src/preload.ts b/src/preload.ts index 44963c25..4eea71b4 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -47,6 +47,14 @@ const script = { } | void> => { return ipcRenderer.invoke('script:select') }, + openScript: ( + filePath: string + ): Promise<{ + path: string + content: string + }> => { + return ipcRenderer.invoke('script:open', filePath) + }, saveScript: (script: string) => { ipcRenderer.send('script:save', script) }, @@ -65,12 +73,15 @@ const script = { } as const const har = { - saveFile: (data: string) => { - ipcRenderer.send('har:save', data) + saveFile: (data: string): Promise => { + return ipcRenderer.invoke('har:save', data) }, openFile: (filePath?: string): Promise => { return ipcRenderer.invoke('har:open', filePath) }, + deleteFile: (filePath: string): Promise => { + return ipcRenderer.invoke('har:delete', filePath) + }, } as const const ui = { @@ -81,11 +92,17 @@ const ui = { onAddFile: (callback: (path: string) => void) => { return createListener('ui:add-file', callback) }, + onRemoveFile: (callback: (path: string) => void) => { + return createListener('ui:remove-file', callback) + }, } as const const generator = { - saveGenerator: (generatorFile: string) => { - ipcRenderer.send('generator:save', generatorFile) + saveGenerator: ( + generatorFile: string, + fileName?: string + ): Promise => { + return ipcRenderer.invoke('generator:save', generatorFile, fileName) }, loadGenerator: (path?: string): Promise => { return ipcRenderer.invoke('generator:open', path) diff --git a/src/store/generator/slices/rules.ts b/src/store/generator/slices/rules.ts index 6e02ded5..416b0e17 100644 --- a/src/store/generator/slices/rules.ts +++ b/src/store/generator/slices/rules.ts @@ -1,6 +1,5 @@ import { TestRule } from '@/types/rules' import { ImmerStateCreator } from '@/utils/typescript' -import { rules } from '../fixtures' interface State { rules: TestRule[] @@ -19,7 +18,7 @@ interface Actions { export type RulesSliceStore = State & Actions export const createRulesSlice: ImmerStateCreator = (set) => ({ - rules, + rules: [], selectedRuleId: null, createRule: (type) => set((state) => { diff --git a/src/store/generator/slices/testOptions/loadProfile.ts b/src/store/generator/slices/testOptions/loadProfile.ts index c3d6977d..bc18adab 100644 --- a/src/store/generator/slices/testOptions/loadProfile.ts +++ b/src/store/generator/slices/testOptions/loadProfile.ts @@ -4,6 +4,7 @@ import { RampingVUsOptions, SharedIterationsOptions, } from '@/types/testOptions' +import { createStage, getInitialStages } from '@/utils/generator' import { ImmerStateCreator } from '@/utils/typescript' interface SharedIterationsState @@ -124,18 +125,3 @@ export const createLoadProfileSlice: ImmerStateCreator = ( ...createRampingSlice(...args), ...createSharedIterationsSlice(...args), }) - -// TODO: Add validation -function createStage( - target: number | string = '', - duration = '' -): RampingStage { - return { - target, - duration, - } -} - -function getInitialStages() { - return [createStage(20, '1m'), createStage(20, '3m30s'), createStage(0, '1m')] -} diff --git a/src/store/generator/useGeneratorStore.ts b/src/store/generator/useGeneratorStore.ts index cb0f0d72..d5f1d30a 100644 --- a/src/store/generator/useGeneratorStore.ts +++ b/src/store/generator/useGeneratorStore.ts @@ -59,7 +59,7 @@ export const useGeneratorStore = create()( // recording state.requests = recording state.recordingPath = recordingPath - state.showAllowlistDialog = false + state.showAllowlistDialog = !!recordingPath && allowlist.length === 0 state.allowlist = allowlist // rules state.rules = rules diff --git a/src/store/recorder/index.ts b/src/store/recorder/index.ts deleted file mode 100644 index 446bad0e..00000000 --- a/src/store/recorder/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './useRecorderStore' -export * from './selectors' diff --git a/src/store/recorder/selectors.ts b/src/store/recorder/selectors.ts deleted file mode 100644 index bf5a4c11..00000000 --- a/src/store/recorder/selectors.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { groupProxyData } from '@/utils/groups' -import { RecorderStore } from './useRecorderStore' - -export function selectGroupedProxyData(state: RecorderStore) { - return groupProxyData(state.proxyData) -} diff --git a/src/store/recorder/useRecorderStore.ts b/src/store/recorder/useRecorderStore.ts deleted file mode 100644 index 2d7a5572..00000000 --- a/src/store/recorder/useRecorderStore.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { create } from 'zustand' -import { immer } from 'zustand/middleware/immer' - -import { ProxyData } from '@/types' -import { mergeRequestsById } from '@/views/Recorder/Recorder.utils' - -interface State { - isRecording: boolean - proxyData: ProxyData[] -} - -interface Actions { - addRequest: (request: ProxyData, currentGroup: string) => void - setIsRecording: (isRecording: boolean) => void - setProxyData: (proxyData: ProxyData[]) => void - resetProxyData: () => void -} - -export type RecorderStore = State & Actions - -export const useRecorderStore = create()( - immer((set) => ({ - isRecording: false, - proxyData: [], - - addRequest: (request: ProxyData, currentGroup: string) => - set((state) => { - state.proxyData = mergeRequestsById(state.proxyData, { - ...request, - group: currentGroup, - }) - }), - setIsRecording: (isRecording: boolean) => - set((state) => { - state.isRecording = isRecording - }), - setProxyData: (proxyData: ProxyData[]) => - set((state) => { - state.proxyData = proxyData - }), - resetProxyData: () => - set((state) => { - state.proxyData = [] - }), - })) -) diff --git a/src/store/ui/useStudioUIStore.ts b/src/store/ui/useStudioUIStore.ts index e858b7c9..7903cd8b 100644 --- a/src/store/ui/useStudioUIStore.ts +++ b/src/store/ui/useStudioUIStore.ts @@ -2,13 +2,11 @@ import { FolderContent } from '@/types' import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' -interface State extends FolderContent { - selectedFile: string | null -} +interface State extends FolderContent {} interface Actions { addFile: (path: string) => void - setSelectedFile: (path: string | null) => void + removeFile: (path: string) => void setFolderContent: (content: FolderContent) => void } @@ -19,7 +17,6 @@ export const useStudioUIStore = create()( recordings: [], generators: [], scripts: [], - selectedFile: null, addFile: (path) => set((state) => { @@ -35,9 +32,19 @@ export const useStudioUIStore = create()( state.scripts.push(path) } }), - setSelectedFile: (path) => + removeFile: (path) => set((state) => { - state.selectedFile = path + if (path.endsWith('.har')) { + state.recordings = state.recordings.filter((file) => file !== path) + } + + if (path.endsWith('.json')) { + state.generators = state.generators.filter((file) => file !== path) + } + + if (path.endsWith('.js')) { + state.scripts = state.scripts.filter((file) => file !== path) + } }), setFolderContent: ({ recordings, generators, scripts }) => set((state) => { diff --git a/src/utils/file.ts b/src/utils/file.ts new file mode 100644 index 00000000..8d6f6cb1 --- /dev/null +++ b/src/utils/file.ts @@ -0,0 +1,3 @@ +export function getFileNameFromPath(path: string) { + return path.split('/').pop() ?? '' +} diff --git a/src/utils/generator.ts b/src/utils/generator.ts new file mode 100644 index 00000000..532bb66d --- /dev/null +++ b/src/utils/generator.ts @@ -0,0 +1,42 @@ +import { GeneratorFileData } from '@/types/generator' +import { RampingStage } from '@/types/testOptions' + +export function createNewGeneratorFile(recordingPath = ''): GeneratorFileData { + return { + name: 'New test generator', + version: '0', + recordingPath, + options: { + loadProfile: { + executor: 'ramping-vus', + stages: getInitialStages(), + }, + thinkTime: { + sleepType: 'groups', + timing: { + type: 'fixed', + value: 1, + }, + }, + }, + testData: { + variables: [], + }, + rules: [], + allowlist: [], + } +} + +export function createStage( + target: number | string = '', + duration = '' +): RampingStage { + return { + target, + duration, + } +} + +export function getInitialStages() { + return [createStage(20, '1m'), createStage(20, '3m30s'), createStage(0, '1m')] +} diff --git a/src/utils/proxyDataToHar.ts b/src/utils/proxyDataToHar.ts index 01355ac1..ee55af75 100644 --- a/src/utils/proxyDataToHar.ts +++ b/src/utils/proxyDataToHar.ts @@ -16,7 +16,6 @@ function createLog( pages: Page[], entries: EntryWithOptionalResponse[] ): HarWithOptionalResponse['log'] { - console.log(entries[0]?.response) return { version: '1.2', creator: { diff --git a/src/views/Generator/Generator.tsx b/src/views/Generator/Generator.tsx index ae09ecc1..405d290b 100644 --- a/src/views/Generator/Generator.tsx +++ b/src/views/Generator/Generator.tsx @@ -1,8 +1,6 @@ import { Allotment } from 'allotment' import { Button } from '@radix-ui/themes' -import { useEffect } from 'react' -import { PageHeading } from '@/components/Layout/PageHeading' import { useSetWindowTitle } from '@/hooks/useSetWindowTitle' import { useGeneratorStore, @@ -16,29 +14,46 @@ import { GeneratorSidebar } from './GeneratorSidebar' import { TestRuleContainer } from './TestRuleContainer' import { Allowlist } from './Allowlist' import { RecordingSelector } from './RecordingSelector' +import { View } from '@/components/Layout/View' +import { useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' export function Generator() { const name = useGeneratorStore((store) => store.name) - const resetRecording = useGeneratorStore((store) => store.resetRecording) const filteredRequests = useGeneratorStore(selectFilteredRequests) const hasRecording = useGeneratorStore(selectHasRecording) + const [isLoading, setIsLoading] = useState(false) + const { path } = useParams() useSetWindowTitle(name) useEffect(() => { - return () => { - resetRecording() + if (!path) { + return } - }, [resetRecording]) + + ;(async () => { + setIsLoading(true) + await loadGenerator(path) + setIsLoading(false) + })() + }, [path]) return ( - <> - - - - - - {hasRecording && } - + + + + + + {hasRecording && ( + + )} + + } + loading={isLoading} + > @@ -54,6 +69,6 @@ export function Generator() { - + ) } diff --git a/src/views/Generator/Generator.utils.ts b/src/views/Generator/Generator.utils.ts index 0cc37ee0..451fe9bb 100644 --- a/src/views/Generator/Generator.utils.ts +++ b/src/views/Generator/Generator.utils.ts @@ -42,7 +42,9 @@ export async function exportScript() { export const saveGenerator = () => { const generatorFile = selectGeneratorData(useGeneratorStore.getState()) - window.studio.generator.saveGenerator(JSON.stringify(generatorFile, null, 2)) + return window.studio.generator.saveGenerator( + JSON.stringify(generatorFile, null, 2) + ) } export const loadGenerator = async (path?: string) => { @@ -60,9 +62,9 @@ export const loadGenerator = async (path?: string) => { return } - const harFile = await window.studio.har.openFile( - generatorFileData.data.recordingPath - ) + const harFile = generatorFileData.data.recordingPath + ? await window.studio.har.openFile(generatorFileData.data.recordingPath) + : undefined // TODO: we need to better handle errors scenarios const recording = harFile ? harToProxyData(harFile.content) : [] diff --git a/src/views/Generator/GeneratorDrawer/GeneratorDrawer.tsx b/src/views/Generator/GeneratorDrawer/GeneratorDrawer.tsx index 78225dfc..5810ed8f 100644 --- a/src/views/Generator/GeneratorDrawer/GeneratorDrawer.tsx +++ b/src/views/Generator/GeneratorDrawer/GeneratorDrawer.tsx @@ -41,7 +41,7 @@ export function GeneratorDrawer() { } function TabNavLink({ path, label }: { path: string; label: string }) { - const match = useMatch(`generator/${path}`) + const match = useMatch(`generator/:pathp/${path}`) return ( diff --git a/src/views/Generator/RecordingSelector.tsx b/src/views/Generator/RecordingSelector.tsx index 5dee5eb0..c4c80be3 100644 --- a/src/views/Generator/RecordingSelector.tsx +++ b/src/views/Generator/RecordingSelector.tsx @@ -1,4 +1,5 @@ import { useGeneratorStore } from '@/store/generator' +import { getFileNameFromPath } from '@/utils/file' import { harToProxyData } from '@/utils/harToProxyData' import { CaretDownIcon } from '@radix-ui/react-icons' import { Button, Flex, IconButton, Popover, Text } from '@radix-ui/themes' @@ -7,8 +8,7 @@ import { Link } from 'react-router-dom' export function RecordingSelector() { const recordingPath = useGeneratorStore((store) => store.recordingPath) const setRecording = useGeneratorStore((store) => store.setRecording) - - const fileName = recordingPath?.split('/').pop() + const fileName = getFileNameFromPath(recordingPath) const handleImport = async () => { const harFile = await window.studio.har.openFile() diff --git a/src/views/Home/Home.tsx b/src/views/Home/Home.tsx index 5e012078..809b3b56 100644 --- a/src/views/Home/Home.tsx +++ b/src/views/Home/Home.tsx @@ -1,7 +1,31 @@ import { Flex, Grid, Text } from '@radix-ui/themes' import { NavigationCard } from './NavigationCard' +import { createNewGeneratorFile } from '@/utils/generator' +import { useNavigate } from 'react-router-dom' export function Home() { + const navigate = useNavigate() + + // TODO: offer to create a new generator or use an existing one + async function handleCreateTestGenerator() { + const newGenerator = createNewGeneratorFile() + const generatorPath = await window.studio.generator.saveGenerator( + JSON.stringify(newGenerator, null, 2), + `${new Date().toISOString()}.json` + ) + + navigate(`/generator/${encodeURIComponent(generatorPath)}`) + } + + function handleNavigateToRecorder() { + navigate('/recorder') + } + + // TODO: offer to select a script to validate + function handleNavigateToValidator() { + navigate('/validator') + } + return ( @@ -11,17 +35,17 @@ export function Home() { diff --git a/src/views/Home/NavigationCard.tsx b/src/views/Home/NavigationCard.tsx index bd7bb4f3..d3093d93 100644 --- a/src/views/Home/NavigationCard.tsx +++ b/src/views/Home/NavigationCard.tsx @@ -1,27 +1,26 @@ import { Card, Text } from '@radix-ui/themes' -import { Link, To } from 'react-router-dom' interface NavigationCardProps { title: string description: string - to: To + onClick: () => void } export function NavigationCard({ title, description, - to, + onClick, }: NavigationCardProps) { return ( - + ) } diff --git a/src/views/Recorder/Recorder.tsx b/src/views/Recorder/Recorder.tsx index d71d1250..995c5f16 100644 --- a/src/views/Recorder/Recorder.tsx +++ b/src/views/Recorder/Recorder.tsx @@ -1,44 +1,95 @@ -import { useState } from 'react' -import { Flex, Heading, ScrollArea } from '@radix-ui/themes' - -import { PageHeading } from '@/components/Layout/PageHeading' -import { WebLogView } from '@/components/WebLogView' -import { useListenProxyData } from '@/hooks/useListenProxyData' -import { useRecorderStore, selectGroupedProxyData } from '@/store/recorder' -import { useSetWindowTitle } from '@/hooks/useSetWindowTitle' -import { useAutoScroll } from '@/hooks/useAutoScroll' +import { useCallback, useEffect, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { Button, Flex } from '@radix-ui/themes' +import { PlayIcon, StopIcon } from '@radix-ui/react-icons' import { GroupForm } from './GroupForm' import { DebugControls } from './DebugControls' -import { RecordingControls } from './RecordingButton' +import { View } from '@/components/Layout/View' +import { RequestsSection } from './RequestsSection' +import { useSetWindowTitle } from '@/hooks/useSetWindowTitle' +import { useListenProxyData } from '@/hooks/useListenProxyData' +import { groupProxyData } from '@/utils/groups' +import { startRecording, stopRecording } from './Recorder.utils' +import { proxyDataToHar } from '@/utils/proxyDataToHar' export function Recorder() { const [group, setGroup] = useState('Default') - const groupedProxyData = useRecorderStore(selectGroupedProxyData) - const contentRef = useAutoScroll(groupedProxyData) - useListenProxyData(group) + const { proxyData, resetProxyData } = useListenProxyData(group) + const groupedProxyData = groupProxyData(proxyData) + const [isLoading, setIsLoading] = useState(false) + const [isRecording, setIsRecording] = useState(false) + + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const autoStart = searchParams.get('autoStart') !== null useSetWindowTitle('Recorder') + const handleStartRecording = useCallback(async () => { + resetProxyData() + setIsLoading(true) + + await startRecording() + + setIsLoading(false) + setIsRecording(true) + }, [resetProxyData]) + + async function handleStopRecording() { + stopRecording() + setIsRecording(false) + + if (proxyData.length === 0) { + return + } + + const har = proxyDataToHar(groupedProxyData) + const filePath = await window.studio.har.saveFile( + JSON.stringify(har, null, 4) + ) + + navigate(`/recording-previewer/${encodeURIComponent(filePath)}?discardable`) + } + + useEffect(() => { + if (autoStart) { + handleStartRecording() + } + }, [autoStart, handleStartRecording]) + return ( - <> - - - - - - - - - - + + {isRecording ? ( + <> + Stop recording + + ) : ( + <> + Start recording + + )} + + } + > + + + + + - Requests - -
    - -
    -
    - + +
    ) } diff --git a/src/views/Recorder/RecordingButton.tsx b/src/views/Recorder/RecordingButton.tsx deleted file mode 100644 index dfd571dc..00000000 --- a/src/views/Recorder/RecordingButton.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { Button, Spinner } from '@radix-ui/themes' -import { PlayIcon, StopIcon } from '@radix-ui/react-icons' - -import { startRecording, stopRecording } from './Recorder.utils' -import { useRecorderStore } from '@/store/recorder' -import { proxyDataToHar } from '@/utils/proxyDataToHar' -import { useGeneratorStore } from '@/store/generator' -import { groupProxyData } from '@/utils/groups' -import { recordingToProxyData } from '@/utils/serializers/recording' - -export function RecordingControls() { - const requests = useRecorderStore((state) => state.proxyData) - const isRecording = useRecorderStore((state) => state.isRecording) - const setIsRecording = useRecorderStore((state) => state.setIsRecording) - const resetProxyData = useRecorderStore((state) => state.resetProxyData) - const isEmpty = useRecorderStore((state) => state.proxyData.length === 0) - const setGeneratorRecording = useGeneratorStore((state) => state.setRecording) - const navigate = useNavigate() - const [isLoading, setIsLoading] = useState(false) - - function handleStartRecording() { - resetProxyData() - setIsLoading(true) - startRecording().then(() => { - setIsLoading(false) - setIsRecording(true) - }) - } - - function handleStopRecording() { - stopRecording() - setIsRecording(false) - } - - function handleSave() { - const grouped = groupProxyData(requests) - const har = proxyDataToHar(grouped) - window.studio.har.saveFile(JSON.stringify(har, null, 4)) - } - - function handleCreateTestGenerator() { - const proxyData = recordingToProxyData(requests) - - setGeneratorRecording(proxyData, '', true) - navigate('/generator') - } - - return ( - <> - - {!isRecording && !isEmpty && ( - <> - - - - )} - - ) -} - -function Icon({ - recording, - loading, -}: { - recording: boolean - loading: boolean -}) { - if (loading) { - return - } - - if (recording) { - return - } - - return -} diff --git a/src/views/Recorder/RequestsSection.tsx b/src/views/Recorder/RequestsSection.tsx new file mode 100644 index 00000000..28ed93c0 --- /dev/null +++ b/src/views/Recorder/RequestsSection.tsx @@ -0,0 +1,34 @@ +import { WebLogView } from '@/components/WebLogView' +import { useAutoScroll } from '@/hooks/useAutoScroll' +import { GroupedProxyData } from '@/types' +import { Flex, Heading, ScrollArea } from '@radix-ui/themes' + +interface RequestsSectionProps { + groupedProxyData: GroupedProxyData + autoScroll?: boolean + noRequestsMessage?: string +} + +export function RequestsSection({ + groupedProxyData, + noRequestsMessage, + autoScroll = false, +}: RequestsSectionProps) { + const ref = useAutoScroll(groupedProxyData, autoScroll) + + return ( + + + Requests + + +
    + +
    +
    +
    + ) +} diff --git a/src/views/RecordingPreviewer/RecordingPreviewer.tsx b/src/views/RecordingPreviewer/RecordingPreviewer.tsx new file mode 100644 index 00000000..f35f045a --- /dev/null +++ b/src/views/RecordingPreviewer/RecordingPreviewer.tsx @@ -0,0 +1,116 @@ +import { Button, DropdownMenu, IconButton } from '@radix-ui/themes' +import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom' +import { useEffect, useState } from 'react' + +import { getFileNameFromPath } from '@/utils/file' +import { View } from '@/components/Layout/View' +import { RequestsSection } from '@/views/Recorder/RequestsSection' +import { createNewGeneratorFile } from '@/utils/generator' +import { GroupedProxyData } from '@/types' +import { harToProxyData } from '@/utils/harToProxyData' +import { groupProxyData } from '@/utils/groups' +import { DotsVerticalIcon } from '@radix-ui/react-icons' + +export function RecordingPreviewer() { + const [groupedProxyData, setGroupedProxyData] = useState({}) + const [isLoading, setIsLoading] = useState(false) + const { path } = useParams() + const navigate = useNavigate() + const [searchParams] = useSearchParams() + const isDiscardable = searchParams.get('discardable') !== null + + useEffect(() => { + if (!path) { + navigate('/') + return + } + + ;(async () => { + setIsLoading(true) + setGroupedProxyData({}) + const har = await window.studio.har.openFile(path) + setIsLoading(false) + + if (!har) { + return + } + + setGroupedProxyData(groupProxyData(harToProxyData(har.content))) + })() + + return () => { + setGroupedProxyData({}) + } + }, [path, navigate]) + + async function handleDeleteRecording() { + if (!path) { + return + } + + await window.studio.har.deleteFile(path) + navigate('/') + } + + async function handleCreateTestGenerator() { + if (!path) { + return + } + + const newGenerator = createNewGeneratorFile(path) + const generatorPath = await window.studio.generator.saveGenerator( + JSON.stringify(newGenerator, null, 2), + `${new Date().toISOString()}.json` + ) + + navigate(`/generator/${encodeURIComponent(generatorPath)}`) + } + + async function handleDiscard() { + if (!path) { + return + } + + await window.studio.har.deleteFile(path) + navigate('/recorder?autoStart') + } + + return ( + + {isDiscardable && ( + + )} + + + + + + + + + + New recording + + + Delete + + + + + } + > + + + ) +} diff --git a/src/views/RecordingPreviewer/index.ts b/src/views/RecordingPreviewer/index.ts new file mode 100644 index 00000000..78517398 --- /dev/null +++ b/src/views/RecordingPreviewer/index.ts @@ -0,0 +1 @@ +export * from './RecordingPreviewer' diff --git a/src/views/Validator/LogsPaneContent.tsx b/src/views/Validator/LogsPaneContent.tsx new file mode 100644 index 00000000..848a21d3 --- /dev/null +++ b/src/views/Validator/LogsPaneContent.tsx @@ -0,0 +1,26 @@ +import { Flex, Heading, ScrollArea } from '@radix-ui/themes' + +import { LogView } from '@/components/LogView' +import { K6Log } from '@/types' +import { useAutoScroll } from '@/hooks/useAutoScroll' + +interface LogsPaneContentProps { + logs: K6Log[] +} + +export function LogsPaneContent({ logs }: LogsPaneContentProps) { + const ref = useAutoScroll(logs) + + return ( + + + Logs + + +
    + +
    +
    +
    + ) +} diff --git a/src/views/Validator/RequestsPaneContent.tsx b/src/views/Validator/RequestsPaneContent.tsx new file mode 100644 index 00000000..1f92e1ba --- /dev/null +++ b/src/views/Validator/RequestsPaneContent.tsx @@ -0,0 +1,26 @@ +import { Flex, Heading, ScrollArea } from '@radix-ui/themes' + +import { WebLogView } from '@/components/WebLogView' +import { GroupedProxyData } from '@/types' +import { useAutoScroll } from '@/hooks/useAutoScroll' + +interface RequestPaneContentProps { + requests: GroupedProxyData +} + +export function RequestPaneContent({ requests }: RequestPaneContentProps) { + const ref = useAutoScroll(requests) + + return ( + + + Requests + + +
    + +
    +
    +
    + ) +} diff --git a/src/views/Validator/ScriptPaneContent.tsx b/src/views/Validator/ScriptPaneContent.tsx new file mode 100644 index 00000000..0d175139 --- /dev/null +++ b/src/views/Validator/ScriptPaneContent.tsx @@ -0,0 +1,17 @@ +import { ReadOnlyEditor } from '@/components/Monaco/ReadOnlyEditor' +import { Flex, Heading } from '@radix-ui/themes' + +interface ScriptPaneContentProps { + script: string +} + +export function ScriptPaneContent({ script }: ScriptPaneContentProps) { + return ( + + + Script + + + + ) +} diff --git a/src/views/Validator/Validator.tsx b/src/views/Validator/Validator.tsx index d4577a06..b24f6d6f 100644 --- a/src/views/Validator/Validator.tsx +++ b/src/views/Validator/Validator.tsx @@ -1,39 +1,53 @@ -import { PageHeading } from '@/components/Layout/PageHeading' -import { LogView } from '@/components/LogView' -import { WebLogView } from '@/components/WebLogView' +import { useCallback, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { Allotment } from 'allotment' + import { useListenProxyData } from '@/hooks/useListenProxyData' -import { useRecorderStore } from '@/store/recorder' import { useSetWindowTitle } from '@/hooks/useSetWindowTitle' import { K6Log } from '@/types' import { groupProxyData } from '@/utils/groups' -import { Button, Flex, Heading, ScrollArea, Spinner } from '@radix-ui/themes' -import { useEffect, useState } from 'react' -import { Allotment } from 'allotment' -import { ReadOnlyEditor } from '@/components/Monaco/ReadOnlyEditor' -import { useAutoScroll } from '@/hooks/useAutoScroll' +import { getFileNameFromPath } from '@/utils/file' +import { LogsPaneContent } from './LogsPaneContent' +import { ScriptPaneContent } from './ScriptPaneContent' +import { RequestPaneContent } from './RequestsPaneContent' +import { ValidatorControls } from './ValidatorControls' +import { View } from '@/components/Layout/View' export function Validator() { + const [isLoading, setIsLoading] = useState(false) const [scriptPath, setScriptPath] = useState() const [script, setScript] = useState('') const [isRunning, setIsRunning] = useState(false) const [logs, setLogs] = useState([]) - const proxyData = useRecorderStore((store) => store.proxyData) - const resetProxyData = useRecorderStore((store) => store.resetProxyData) + const { path: scripPath } = useParams() + const fileName = getFileNameFromPath(scripPath ?? '') - const requestsRef = useAutoScroll(proxyData) - const logsRef = useAutoScroll(logs) - - useListenProxyData() - useSetWindowTitle('Validator') + const { proxyData, resetProxyData } = useListenProxyData() + useSetWindowTitle(fileName || 'Validator') const groupedProxyData = groupProxyData(proxyData) - async function handleSelectScript() { + const handleSelectScript = useCallback(async () => { const { path = '', content = '' } = (await window.studio.script.showScriptSelectDialog()) || {} setScriptPath(path) setScript(content) - } + }, []) + + useEffect(() => { + if (!scripPath) { + return + } + + ;(async () => { + setIsLoading(true) + const { path = '', content = '' } = + (await window.studio.script.openScript(scripPath)) || {} + setIsLoading(false) + setScriptPath(path) + setScript(content) + })() + }, [scripPath]) function handleRunScript() { if (!scriptPath || !script) { @@ -64,65 +78,34 @@ export function Validator() { }, []) return ( - <> - - - - - + + } + loading={isLoading} + > - - - Requests - - -
    - -
    -
    -
    +
    - - - Script - - - +
    - - - Logs - - -
    - -
    -
    -
    +
    - +
    ) } diff --git a/src/views/Validator/ValidatorControls.tsx b/src/views/Validator/ValidatorControls.tsx new file mode 100644 index 00000000..7dddcee6 --- /dev/null +++ b/src/views/Validator/ValidatorControls.tsx @@ -0,0 +1,46 @@ +import { DotsVerticalIcon } from '@radix-ui/react-icons' +import { Button, DropdownMenu, IconButton } from '@radix-ui/themes' + +interface ValidatorControlsProps { + isRunning: boolean + isScriptSelected: boolean + onRunScript: () => void + onSelectScript: () => void + onStopScript: () => void +} + +export function ValidatorControls({ + isRunning, + isScriptSelected, + onRunScript, + onSelectScript, + onStopScript, +}: ValidatorControlsProps) { + return ( + <> + + + + + + + + + + Select script + + + Delete + + + + + ) +}