diff --git a/frontend/src/components/ArgumentDefinitionForm.tsx b/frontend/src/components/ArgumentDefinitionForm.tsx new file mode 100644 index 000000000..ac984f1f2 --- /dev/null +++ b/frontend/src/components/ArgumentDefinitionForm.tsx @@ -0,0 +1,284 @@ +import React, { useState } from 'react' +import { + List, + ListItem, + ListItemText, + ListItemSecondaryAction, + IconButton as MuiIconButton, + TextField, + Select, + MenuItem, + FormControl, + InputLabel, + Button, + Stack, + Collapse, + Typography, + Box, +} from '@mui/material' +import { Icon } from './Icon' + +type Props = { + definitions: IArgumentDefinition[] + onChange: (definitions: IArgumentDefinition[]) => void + disabled?: boolean +} + +const ARGUMENT_TYPES: { value: IFileArgumentType; label: string; description: string }[] = [ + { value: 'StringEntry', label: 'Text Input', description: 'Free text entry field' }, + { value: 'StringSelect', label: 'Dropdown', description: 'Select from predefined options' }, + { value: 'FileSelect', label: 'File Select', description: 'Select a file (optionally filter by extension)' }, +] + +const emptyDefinition: IArgumentDefinition = { + name: '', + type: 'StringEntry', + desc: '', + options: [], +} + +export const ArgumentDefinitionForm: React.FC = ({ definitions, onChange, disabled }) => { + const [editing, setEditing] = useState(null) + const [editForm, setEditForm] = useState(emptyDefinition) + const [optionsText, setOptionsText] = useState('') + + const startEdit = (index: number) => { + const def = definitions[index] + setEditing(index) + setEditForm(def) + setOptionsText(def.options?.join(', ') || '') + } + + const startNew = () => { + setEditing('new') + setEditForm(emptyDefinition) + setOptionsText('') + } + + const cancelEdit = () => { + setEditing(null) + setEditForm(emptyDefinition) + setOptionsText('') + } + + const saveEdit = () => { + const options = optionsText + .split(',') + .map(o => o.trim()) + .filter(o => o.length > 0) + + const updated: IArgumentDefinition = { + ...editForm, + options: options.length > 0 ? options : undefined, + } + + if (editing === 'new') { + onChange([...definitions, updated]) + } else if (typeof editing === 'number') { + const newDefs = [...definitions] + newDefs[editing] = updated + onChange(newDefs) + } + cancelEdit() + } + + const deleteDefinition = (index: number) => { + onChange(definitions.filter((_, i) => i !== index)) + } + + const moveUp = (index: number) => { + if (index === 0) return + const newDefs = [...definitions] + ;[newDefs[index - 1], newDefs[index]] = [newDefs[index], newDefs[index - 1]] + onChange(newDefs) + } + + const moveDown = (index: number) => { + if (index === definitions.length - 1) return + const newDefs = [...definitions] + ;[newDefs[index], newDefs[index + 1]] = [newDefs[index + 1], newDefs[index]] + onChange(newDefs) + } + + const canSave = editForm.name.trim().length > 0 + + const showOptions = editForm.type === 'StringSelect' || editForm.type === 'FileSelect' + + return ( + + + Script Arguments + + + {definitions.map((def, index) => ( + + + + {ARGUMENT_TYPES.find(t => t.value === def.type)?.label || def.type} + {def.desc && ` - ${def.desc}`} + {def.options?.length ? ` (${def.options.length} options)` : ''} + + } + /> + {!disabled && editing !== index && ( + + moveUp(index)} disabled={index === 0}> + + + moveDown(index)} disabled={index === definitions.length - 1}> + + + startEdit(index)}> + + + deleteDefinition(index)}> + + + + )} + + + + + + ))} + + + {!disabled && editing !== 'new' && ( + + )} + + + + + New Argument + + + + + + {definitions.length === 0 && editing !== 'new' && ( + + No arguments defined. Arguments allow users to provide input values when running the script. + + )} + + ) +} + +// Extracted edit form component to avoid duplication +type EditFormProps = { + form: IArgumentDefinition + optionsText: string + showOptions: boolean + canSave: boolean + onFormChange: (form: IArgumentDefinition) => void + onOptionsChange: (text: string) => void + onSave: () => void + onCancel: () => void +} + +const ArgumentEditForm: React.FC = ({ + form, + optionsText, + showOptions, + canSave, + onFormChange, + onOptionsChange, + onSave, + onCancel, +}) => ( + + onFormChange({ ...form, name: e.target.value.replace(/\s/g, '_') })} + helperText="Name used in script (no spaces)" + error={form.name.trim().length === 0} + /> + + Type + + + onFormChange({ ...form, desc: e.target.value })} + helperText="Help text displayed to user when filling in the value" + /> + {showOptions && ( + onOptionsChange(e.target.value)} + helperText={ + form.type === 'FileSelect' + ? 'Comma-separated file extensions to filter by (optional)' + : 'Comma-separated list of options for dropdown' + } + /> + )} + + + + + +) diff --git a/frontend/src/components/ArgumentsValueForm.tsx b/frontend/src/components/ArgumentsValueForm.tsx new file mode 100644 index 000000000..49c617f28 --- /dev/null +++ b/frontend/src/components/ArgumentsValueForm.tsx @@ -0,0 +1,291 @@ +import React, { useCallback } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { List, ListItem, TextField, MenuItem, Typography, Box, ButtonBase, Stack } from '@mui/material' +import { State, Dispatch } from '../store' +import { selectActiveAccountId } from '../selectors/accounts' +import { useDropzone } from 'react-dropzone' +import { radius } from '../styling' + +type Props = { + arguments: IFileArgument[] + values: IArgumentValue[] + onChange: (values: IArgumentValue[]) => void + disabled?: boolean +} + +// Helper to find a file by either file ID or version ID +const findFileByIdOrVersionId = (files: IFile[], id: string): IFile | undefined => { + // First try to find by file ID + const byFileId = files.find(f => f.id === id) + if (byFileId) return byFileId + + // Then try to find by version ID (FileSelect arguments store version IDs) + return files.find(f => f.versions?.some(v => v.id === id)) +} + +export const ArgumentsValueForm: React.FC = ({ arguments: argDefs, values, onChange, disabled }) => { + const dispatch = useDispatch() + + // Get ALL files (both scripts and non-scripts) for FileSelect type + const accountId = useSelector(selectActiveAccountId) + const files = useSelector((state: State) => state.files.all[accountId] || []) + + // Sort arguments by order + const sortedArgs = [...argDefs].sort((a, b) => a.order - b.order) + + const getValue = (name: string): string => { + return values.find(v => v.name === name)?.value || '' + } + + const setValue = (name: string, value: string) => { + const existing = values.find(v => v.name === name) + if (existing) { + onChange(values.map(v => (v.name === name ? { ...v, value } : v))) + } else { + onChange([...values, { name, value }]) + } + } + + if (sortedArgs.length === 0) { + return null + } + + return ( + + + Script Arguments + + + {sortedArgs.map(arg => ( + + setValue(arg.name, value)} + disabled={disabled} + files={files} + /> + + ))} + + + ) +} + +type ArgumentInputProps = { + argument: IFileArgument + value: string + onChange: (value: string) => void + disabled?: boolean + files: IFile[] +} + +const ArgumentInput: React.FC = ({ argument, value, onChange, disabled, files }) => { + const { name, desc, argumentType, options } = argument + const dispatch = useDispatch() + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0] + const reader = new FileReader() + + reader.onabort = () => dispatch.ui.set({ errorMessage: 'File reading was aborted' }) + reader.onerror = () => dispatch.ui.set({ errorMessage: 'File reading has failed' }) + reader.onloadend = async () => { + // Upload the file and get the file ID + const fileId = await dispatch.files.upload({ + name: file.name, + description: '', + executable: false, + deviceIds: [], + access: 'NONE', + fileId: '', + file, + }) + if (fileId) { + onChange(fileId) + } + } + + reader.readAsArrayBuffer(file) + } + }, [dispatch, onChange]) + + // Map common file extensions to MIME types for file picker + const getMimeTypes = (extensions: string[]) => { + const mimeMap: Record = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.svg': 'image/svg+xml', + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.csv': 'text/csv', + '.json': 'application/json', + '.xml': 'application/xml', + '.zip': 'application/zip', + '.mp4': 'video/mp4', + '.mp3': 'audio/mpeg', + } + + const accept: Record = {} + extensions.forEach(ext => { + const cleanExt = ext.startsWith('.') ? ext : `.${ext}` + const mime = mimeMap[cleanExt.toLowerCase()] + if (mime) { + if (!accept[mime]) accept[mime] = [] + accept[mime].push(cleanExt) + } + }) + return Object.keys(accept).length > 0 ? accept : undefined + } + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: argumentType === 'FileSelect' && options.length > 0 ? getMimeTypes(options) : undefined, + }) + + switch (argumentType) { + case 'StringEntry': + return ( + onChange(e.target.value)} + disabled={disabled} + helperText={desc} + variant="filled" + /> + ) + + case 'StringSelect': + return ( + onChange(e.target.value === '_none_' ? '' : e.target.value)} + disabled={disabled} + helperText={desc} + variant="filled" + > + + Select... + + {options.map(option => ( + + {option} + + ))} + + ) + + case 'FileSelect': + // Filter to non-executable files only, and optionally by file extension + const filteredFiles = files.filter(f => { + if (f.executable) return false + if (options.length === 0) return true + // Check if file extension matches allowed types + const ext = f.name.split('.').pop()?.toLowerCase() + return options.some(opt => { + const allowedExt = opt.replace(/^\*?\.?/, '').toLowerCase() + return ext === allowedExt + }) + }) + + // Find the currently selected file - value could be file ID or version ID + const selectedFile = findFileByIdOrVersionId(files, value) + + // Ensure selected file is always in the list, even if it doesn't match filter + const availableFiles = selectedFile && !filteredFiles.find(f => f.id === selectedFile.id) + ? [selectedFile, ...filteredFiles] + : filteredFiles + + // Check if we have a value but the file wasn't found (deleted or inaccessible) + const fileMissing = value && !selectedFile + + const fileHelperText = [ + desc, + options.length > 0 ? `Allowed types: ${options.join(', ')}` : '', + fileMissing ? '⚠️ Previously selected file is no longer available' : '' + ] + .filter(Boolean) + .join(' | ') + + // Get the latest version ID for a file (used as the stored value) + const getLatestVersionId = (file: IFile): string => { + return file.versions?.[0]?.id || file.id + } + + // For the dropdown, use the selected file's latest version ID if found + const dropdownValue = selectedFile ? getLatestVersionId(selectedFile) : '_none_' + + return ( + + {/* Dropdown to select existing file */} + onChange(e.target.value === '_none_' ? '' : e.target.value)} + disabled={disabled} + helperText={fileHelperText} + variant="filled" + > + + Select existing file... + + {availableFiles.map(file => ( + + {file.name} + + ))} + + + {/* Upload option */} + + Or upload a new file: + + + + Upload New File + Drag and drop or click + + + ) + + default: + return ( + onChange(e.target.value)} + disabled={disabled} + helperText={desc || `Unknown type: ${argumentType}`} + variant="filled" + /> + ) + } +} diff --git a/frontend/src/components/FileDeleteButton.tsx b/frontend/src/components/FileDeleteButton.tsx new file mode 100644 index 000000000..036b35ebd --- /dev/null +++ b/frontend/src/components/FileDeleteButton.tsx @@ -0,0 +1,31 @@ +import React from 'react' +import { Dispatch } from '../store' +import { useParams, useHistory } from 'react-router-dom' +import { selectPermissions } from '../selectors/organizations' +import { useDispatch, useSelector } from 'react-redux' +import { DeleteButton } from '../buttons/DeleteButton' +import { Notice } from './Notice' + +export const FileDeleteButton: React.FC = () => { + const { fileID } = useParams<{ fileID?: string }>() + const permissions = useSelector(selectPermissions) + const dispatch = useDispatch() + const history = useHistory() + + if (!permissions.includes('ADMIN') || !fileID) return null + + return ( + + This can not be undone. + + } + onDelete={async () => { + await dispatch.files.delete(fileID) + history.push('/files') + }} + /> + ) +} diff --git a/frontend/src/components/FileList.tsx b/frontend/src/components/FileList.tsx index ae5d7d2a9..cf041c517 100644 --- a/frontend/src/components/FileList.tsx +++ b/frontend/src/components/FileList.tsx @@ -14,16 +14,17 @@ interface FileListProps { columnWidths: ILookup fetching?: boolean scripts?: IScript[] + isScriptList?: boolean } -export const FileList: React.FC = ({ attributes, required, scripts = [], columnWidths, fetching }) => { +export const FileList: React.FC = ({ attributes, required, scripts = [], columnWidths, fetching, isScriptList = true }) => { const { fileID } = useParams<{ fileID?: string }>() const selectedIds = useSelector((state: State) => state.ui.selected) const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`) return ( {scripts?.map((script, index) => ( - + ))} ) diff --git a/frontend/src/components/FileListItem.tsx b/frontend/src/components/FileListItem.tsx index 3908653bd..9bb445773 100644 --- a/frontend/src/components/FileListItem.tsx +++ b/frontend/src/components/FileListItem.tsx @@ -1,9 +1,11 @@ import React from 'react' import { useHistory } from 'react-router-dom' +import { useSelector } from 'react-redux' import { AttributeValue } from './AttributeValue' import { JobStatusIcon } from './JobStatusIcon' import { GridListItem } from './GridListItem' import { Attribute } from './Attributes' +import { State } from '../store' import { Icon } from './Icon' import { Box } from '@mui/material' @@ -14,16 +16,22 @@ type Props = { mobile?: boolean selectedIds?: string[] fileID?: string + isScript?: boolean } -export const FileListItem: React.FC = ({ script, required, attributes, mobile, selectedIds, fileID }) => { +export const FileListItem: React.FC = ({ script, required, attributes, mobile, selectedIds, fileID, isScript = true }) => { const history = useHistory() + const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel) if (!script) return null + const basePath = isScript ? 'script' : 'file' + const listPath = isScript ? 'scripts' : 'files' + const handleClick = () => { - if (selectedIds?.length) history.push(`/scripts/${script.id}`) - else history.push(`/script/${script.id}`) + if (selectedIds?.length) history.push(`/${listPath}/${script.id}`) + else if (isScript && !singlePanel) history.push(`/${basePath}/${script.id}/latest/edit`) + else history.push(`/${basePath}/${script.id}`) } return ( diff --git a/frontend/src/components/FileUpload.tsx b/frontend/src/components/FileUpload.tsx index 7eb87328a..fb2560930 100644 --- a/frontend/src/components/FileUpload.tsx +++ b/frontend/src/components/FileUpload.tsx @@ -38,17 +38,21 @@ export const FileUpload: React.FC = ({ script = '', loading, disabled, on const text = new TextDecoder().decode(buffer) const isBinary = containsNonPrintableChars(text) + setFilename(file.name) if (!isBinary) { setIsText(true) - setFilename(file.name) onChange(text, file) } else { + // Binary files are allowed - pass the file with the binary token setIsText(false) + onChange(BINARY_DATA_TOKEN, file) } } catch (e) { console.error('Error decoding text:', e) - dispatch.ui.set({ errorMessage: 'File could not be decoded as text.' }) + // If decoding fails, treat as binary + setFilename(file.name) setIsText(false) + onChange(BINARY_DATA_TOKEN, file) } } @@ -72,6 +76,32 @@ export const FileUpload: React.FC = ({ script = '', loading, disabled, on return ( + {showUpload && ( + <> + + + Upload + Drag and drop or click + + + + )} {isText ? ( <> = ({ script = '', loading, disabled, on label="Script" value={loading ? 'loading...' : script.toString()} variant="filled" - maxRows={30} InputLabelProps={{ shrink: true }} InputProps={{ sx: theme => ({ - borderRadius: showUpload ? `${radius.sm}px ${radius.sm}px 0 0` : undefined, + borderRadius: showUpload ? `0 0 ${radius.sm}px ${radius.sm}px` : undefined, fontFamily: "'Roboto Mono', monospace", fontSize: theme.typography.caption.fontSize, lineHeight: theme.typography.caption.lineHeight, @@ -106,35 +135,9 @@ export const FileUpload: React.FC = ({ script = '', loading, disabled, on ) : ( - This script appears to be binary. + Binary script uploaded: {filename} )} - {showUpload && ( - <> - - - - Upload - Drag and drop or click - - - )} ) } diff --git a/frontend/src/components/JobStatusIcon.tsx b/frontend/src/components/JobStatusIcon.tsx index c1d4f9908..fd9374d45 100644 --- a/frontend/src/components/JobStatusIcon.tsx +++ b/frontend/src/components/JobStatusIcon.tsx @@ -21,15 +21,15 @@ export const JobStatusIcon: React.FC = ({ const icon = ( {status === 'READY' ? ( - + ) : status === 'SUCCESS' ? ( ) : status === 'FAILED' || status === 'CANCELLED' ? ( ) : status === 'WAITING' ? ( - + ) : status === 'RUNNING' ? ( - + ) : device ? ( ) : ( diff --git a/frontend/src/components/ScriptDeleteButton.tsx b/frontend/src/components/ScriptDeleteButton.tsx index e75f3ef2d..f2e7bc13a 100644 --- a/frontend/src/components/ScriptDeleteButton.tsx +++ b/frontend/src/components/ScriptDeleteButton.tsx @@ -1,6 +1,6 @@ import React from 'react' import { Dispatch } from '../store' -import { useParams } from 'react-router-dom' +import { useParams, useHistory } from 'react-router-dom' import { selectPermissions } from '../selectors/organizations' import { useDispatch, useSelector } from 'react-redux' import { DeleteButton } from '../buttons/DeleteButton' @@ -12,6 +12,7 @@ export const ScriptDeleteButton: React.FC = ({ device, service }) => { const { fileID } = useParams<{ fileID?: string }>() const permissions = useSelector(selectPermissions) const dispatch = useDispatch() + const history = useHistory() if (!permissions.includes('ADMIN') || !fileID) return null @@ -23,7 +24,10 @@ export const ScriptDeleteButton: React.FC = ({ device, service }) => { This can not be undone. } - onDelete={async () => await dispatch.files.delete(fileID)} + onDelete={async () => { + await dispatch.files.delete(fileID) + history.push('/scripts') + }} /> ) } diff --git a/frontend/src/components/ScriptForm.tsx b/frontend/src/components/ScriptForm.tsx index 0050ca789..b0310d02c 100644 --- a/frontend/src/components/ScriptForm.tsx +++ b/frontend/src/components/ScriptForm.tsx @@ -1,10 +1,13 @@ import React, { useState, useEffect } from 'react' +import structuredClone from '@ungap/structured-clone' import isEqual from 'lodash.isequal' import { Dispatch } from '../store' import { useHistory } from 'react-router-dom' import { useDispatch } from 'react-redux' -import { List, ListItem, TextField, Button, Stack } from '@mui/material' +import { List, ListItem, TextField, Button, Stack, Divider } from '@mui/material' import { DynamicButton } from '../buttons/DynamicButton' +import { ArgumentDefinitionForm } from './ArgumentDefinitionForm' +import { ArgumentsValueForm } from './ArgumentsValueForm' import { FileUpload } from './FileUpload' import { TagFilter } from './TagFilter' import { Notice } from './Notice' @@ -15,10 +18,11 @@ type Props = { selectedIds: string[] loading?: boolean manager?: boolean + scriptArguments?: IFileArgument[] // Existing argument definitions from file version onChange: (form: IFileForm) => void } -export const ScriptForm: React.FC = ({ form, defaultForm, selectedIds, loading, manager, onChange }) => { +export const ScriptForm: React.FC = ({ form, defaultForm, selectedIds, loading, manager, scriptArguments = [], onChange }) => { const [unauthorized, setUnauthorized] = useState() const [running, setRunning] = useState(false) const [saving, setSaving] = useState(false) @@ -27,7 +31,10 @@ export const ScriptForm: React.FC = ({ form, defaultForm, selectedIds, lo const changed = !isEqual(form, defaultForm) const canSave = !unauthorized && !!form.script && !!form.name const scriptChanged = - form.script !== defaultForm.script || form.description !== defaultForm.description || form.name !== defaultForm.name + form.script !== defaultForm.script || + form.description !== defaultForm.description || + form.name !== defaultForm.name || + !isEqual(form.argumentDefinitions, defaultForm.argumentDefinitions) const canRun = (form.access === 'SELECTED' && selectedIds.length) || (form.access === 'TAG' && form.tag?.values.length) || @@ -112,6 +119,16 @@ export const ScriptForm: React.FC = ({ form, defaultForm, selectedIds, lo onChange={event => onChange({ ...form, description: event.target.value })} /> + {manager && ( + + onChange({ ...form, argumentDefinitions })} + disabled={loading || saving} + /> + + )} + = ({ form, defaultForm, selectedIds, lo )} + {scriptArguments.length > 0 && ( + onChange({ ...form, argumentValues })} + disabled={loading || saving || running} + /> + )} = ({ children }) => { {!selectedIds.length && ( - + = ({ children }) => { - + @@ -65,7 +65,7 @@ export const ScriptingHeader: React.FC = ({ children }) => { startIcon={} component={RouteLink} > - Add + {location.pathname.startsWith('/files') ? 'Upload' : 'Add'} diff --git a/frontend/src/components/ScriptingTabBar.tsx b/frontend/src/components/ScriptingTabBar.tsx index c3d041b8b..72bbef224 100644 --- a/frontend/src/components/ScriptingTabBar.tsx +++ b/frontend/src/components/ScriptingTabBar.tsx @@ -4,8 +4,8 @@ import { useDispatch } from 'react-redux' import { useHistory, useLocation } from 'react-router-dom' import { Tabs, Tab } from '@mui/material' -const tabs = ['scripts', 'runs' /* , 'files' */] -const tabTitles = ['Scripts', 'Runs', 'Assets'] +const tabs = ['scripts', 'runs', 'files'] +const tabTitles = ['Scripts', 'Runs', 'Files'] export const ScriptingTabBar: React.FC = () => { const dispatch = useDispatch() diff --git a/frontend/src/components/ScriptsListHeader.tsx b/frontend/src/components/ScriptsListHeader.tsx new file mode 100644 index 000000000..6f630e8e6 --- /dev/null +++ b/frontend/src/components/ScriptsListHeader.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' +import { Box, Button, Typography, Tooltip, useMediaQuery } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { selectPermissions } from '../selectors/organizations' +import { IconButton } from '../buttons/IconButton' +import { RefreshButton } from '../buttons/RefreshButton' +import { Icon } from '../components/Icon' +import { Title } from '../components/Title' +import { spacing } from '../styling' +import { HIDE_SIDEBAR_WIDTH } from '../constants' +import { Dispatch } from '../store' + +type Props = { + showBack?: boolean + onBack?: () => void + scripts?: boolean +} + +export const ScriptsListHeader: React.FC = ({ showBack, onBack, scripts }) => { + const history = useHistory() + const location = useLocation() + const dispatch = useDispatch() + const css = useStyles() + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + const permissions = useSelector(selectPermissions) + + const title = scripts ? 'Scripts' : 'Files' + const addPath = scripts ? '/scripts/add' : '/files/add' + const addLabel = scripts ? 'Add' : 'Upload' + + return ( + + + {sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> + )} + {showBack && ( + + )} + + {sidebarHidden && ( + + {title} + + )} + + + + + + + + + + ) +} + +const useStyles = makeStyles(({ palette }) => ({ + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + height: 45, + paddingLeft: spacing.md, + paddingRight: spacing.md, + marginTop: spacing.sm, + }, + left: { + display: 'flex', + alignItems: 'center', + }, + right: { + display: 'flex', + alignItems: 'center', + }, + title: {}, +})) diff --git a/frontend/src/models/files.ts b/frontend/src/models/files.ts index 229345167..b13532a1d 100644 --- a/frontend/src/models/files.ts +++ b/frontend/src/models/files.ts @@ -3,7 +3,7 @@ import structuredClone from '@ungap/structured-clone' import { DEMO_SCRIPT_URL, BINARY_DATA_TOKEN } from '../constants' import { createModel } from '@rematch/core' import { graphQLFiles } from '../services/graphQLRequest' -import { graphQLDeleteFile } from '../services/graphQLMutation' +import { graphQLDeleteFile, graphQLModifyFile } from '../services/graphQLMutation' import { selectActiveAccountId } from '../selectors/accounts' import { RootModel } from '.' import { postFile } from '../services/post' @@ -26,6 +26,8 @@ export const initialForm: IFileForm = { access: 'NONE', fileId: '', script: '', + argumentDefinitions: [], + argumentValues: [], } const defaultState: FilesState = { @@ -51,10 +53,14 @@ export default createModel()({ dispatch.files.set({ fetching: true }) accountId = accountId || selectActiveAccountId(state) const result = await graphQLFiles(accountId, [fileId]) - if (result === 'ERROR') return + if (result === 'ERROR') { + dispatch.files.set({ fetching: false }) + return + } const files = await dispatch.files.parse(result) - console.log('LOADED FILE', accountId, files) - dispatch.files.setFile({ accountId, file: files[0] }) + if (files?.[0]) { + dispatch.files.setFile({ accountId, file: files[0] }) + } dispatch.files.set({ fetching: false }) }, async fetchIfEmpty(accountId: string | void, state) { @@ -68,13 +74,18 @@ export default createModel()({ async upload(form: IFileForm, state): Promise { if (!form.file) return '' - const data = { + const data: Record = { accountId: selectActiveAccountId(state), executable: form.executable, shortDesc: form.description, name: form.name, } + // Include argument definitions if provided + if (form.argumentDefinitions?.length) { + data.arguments = JSON.stringify(form.argumentDefinitions) + } + const result = await postFile(form.file, data, `/file/upload`) if (result === 'ERROR') return '' @@ -116,6 +127,18 @@ export default createModel()({ await dispatch.files.fetch() }, + async updateMetadata(params: { fileId: string; name?: string; shortDesc?: string }, state) { + console.log('UPDATE FILE METADATA', params) + + const result = await graphQLModifyFile(params) + if (result === 'ERROR') { + dispatch.ui.set({ errorMessage: 'Error updating file' }) + return false + } + + await dispatch.files.fetchSingle({ fileId: params.fileId }) + return true + }, async setFile({ accountId, file }: { accountId: string; file: IFile }, state) { const files = structuredClone(state.files.all[accountId] || []) const index = files.findIndex(f => f.id === file.id) diff --git a/frontend/src/models/jobs.ts b/frontend/src/models/jobs.ts index 60b602db2..3ea2353f5 100644 --- a/frontend/src/models/jobs.ts +++ b/frontend/src/models/jobs.ts @@ -4,7 +4,7 @@ import { VALID_JOB_ID_LENGTH } from '../constants' import { selectJobs } from '../selectors/scripting' import { getDevices } from '../selectors/devices' import { selectActiveAccountId } from '../selectors/accounts' -import { graphQLSetJob, graphQLStartJob, graphQLCancelJob } from '../services/graphQLMutation' +import { graphQLSetJob, graphQLStartJob, graphQLCancelJob, graphQLDeleteJob } from '../services/graphQLMutation' import { AxiosResponse } from 'axios' import { createModel } from '@rematch/core' import { graphQLJobs } from '../services/graphQLRequest' @@ -114,6 +114,11 @@ export default createModel()({ async runAgain(script: IScript) { const deviceIds = script?.job?.jobDevices.map(d => d.device.id) || [] const tagValues = script?.job?.tag?.values || [] + // Convert job arguments to argument values format + const argumentValues: IArgumentValue[] = script?.job?.arguments?.map(arg => ({ + name: arg.name, + value: arg.value || '', + })) || [] await dispatch.jobs.saveRun({ deviceIds, jobId: script.job?.id || '', @@ -123,6 +128,7 @@ export default createModel()({ executable: script.executable, tag: script.job?.tag, access: tagValues.length ? 'TAG' : deviceIds.length ? 'CUSTOM' : 'NONE', + argumentValues, }) }, async cancel(jobId: string | undefined) { @@ -130,6 +136,21 @@ export default createModel()({ if (result === 'ERROR') return console.log('CANCELED JOB', { result, jobId }) }, + async delete({ jobId, fileId }: { jobId: string; fileId: string }, state) { + const result = await graphQLDeleteJob(jobId) + if (result === 'ERROR') { + dispatch.ui.set({ errorMessage: 'Error deleting job' }) + return false + } + console.log('DELETED JOB', { result, jobId }) + // Remove from local state + const accountId = selectActiveAccountId(state) + const jobs = state.jobs.all[accountId]?.filter(j => j.id !== jobId) || [] + dispatch.jobs.setAccount({ accountId, jobs }) + // Redirect to script page + dispatch.ui.set({ redirect: `/script/${fileId}` }) + return true + }, async unauthorized(deviceIds: string[], state) { return getDevices(state).filter( d => deviceIds.includes(d.id) && (!d.permissions.includes('SCRIPTING') || !d.scriptable) @@ -167,7 +188,7 @@ function formAdaptor(form: IFileForm) { return { fileId: form.fileId, jobId: form.jobId, - arguments: undefined, // form.arguments to be implemented + arguments: form.argumentValues?.length ? form.argumentValues : undefined, tagFilter: form.access === 'TAG' ? form.tag : undefined, deviceIds: form.access === 'SELECTED' || form.access === 'CUSTOM' ? form.deviceIds : undefined, // all: form.access === 'ALL', diff --git a/frontend/src/pages/FileAddPage.tsx b/frontend/src/pages/FileAddPage.tsx new file mode 100644 index 000000000..00ff6c264 --- /dev/null +++ b/frontend/src/pages/FileAddPage.tsx @@ -0,0 +1,172 @@ +import React, { useState, useCallback } from 'react' +import { State } from '../store' +import { Typography, Box, List, ListItem, TextField, Button, ButtonBase, Stack } from '@mui/material' +import { selectRole } from '../selectors/organizations' +import { initialForm } from '../models/files' +import { useSelector, useDispatch } from 'react-redux' +import { useHistory } from 'react-router-dom' +import { useDropzone } from 'react-dropzone' +import { Body } from '../components/Body' +import { Dispatch } from '../store' +import { Icon } from '../components/Icon' +import { radius } from '../styling' + +type Props = { center?: boolean } + +export const FileAddPage: React.FC = ({ center }) => { + const dispatch = useDispatch() + const history = useHistory() + const role = useSelector(selectRole) + const manager = role.permissions.includes('MANAGE') + + const [form, setForm] = useState({ + name: '', + description: '', + executable: false, + tag: { operator: 'ALL', values: [] }, + deviceIds: [], + access: 'NONE', + fileId: '', + script: '', + argumentDefinitions: [], + argumentValues: [], + }) + const [loading, setLoading] = useState(false) + const [uploadedFile, setUploadedFile] = useState(null) + + const onDrop = useCallback((files: File[]) => { + if (files.length > 0) { + const file = files[0] + setUploadedFile(file) + setForm(prev => ({ ...prev, name: file.name, file })) + } + }, []) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + if (!uploadedFile || !form.name) return + + const uploadForm = { ...form, executable: false } + setLoading(true) + const fileId = await dispatch.files.upload(uploadForm) + setLoading(false) + + if (fileId) { + history.push('/files') + } + } + + const clearFile = () => { + setUploadedFile(null) + setForm(prev => ({ ...prev, name: '', file: undefined })) + } + + if (!manager) { + return ( + + + You do not have permission to upload files + + + ) + } + + return ( + + + + Upload File + +
+ + + setForm({ ...form, name: event.target.value })} + /> + + + setForm({ ...form, description: event.target.value })} + /> + + + + {!uploadedFile ? ( + + + + Upload File + Drag and drop or click + + ) : ( + + + + + {uploadedFile.name} + + {(uploadedFile.size / 1024).toFixed(1)} KB + + + + + + )} + + + + +
+
+ + ) +} diff --git a/frontend/src/pages/FileDetailPage.tsx b/frontend/src/pages/FileDetailPage.tsx new file mode 100644 index 000000000..efdfca713 --- /dev/null +++ b/frontend/src/pages/FileDetailPage.tsx @@ -0,0 +1,214 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { useParams, useHistory, Redirect } from 'react-router-dom' +import { useSelector, useDispatch } from 'react-redux' +import { Typography, List, ListItem, TextField, Box, Divider, Button, ButtonBase, useMediaQuery } from '@mui/material' +import { useDropzone } from 'react-dropzone' +import { State, Dispatch } from '../store' +import { HIDE_SIDEBAR_WIDTH } from '../constants' +import { selectFile } from '../selectors/scripting' +import { selectRole } from '../selectors/organizations' +import { initialForm } from '../models/files' +import { FileDeleteButton } from '../components/FileDeleteButton' +import { Container } from '../components/Container' +import { IconButton } from '../buttons/IconButton' +import { Gutters } from '../components/Gutters' +import { Icon } from '../components/Icon' +import { radius } from '../styling' + +export const FileDetailPage: React.FC = () => { + const dispatch = useDispatch() + const history = useHistory() + const { fileID } = useParams<{ fileID: string }>() + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + + const role = useSelector(selectRole) + const manager = role.permissions.includes('MANAGE') + const file = useSelector((state: State) => selectFile(state, undefined, fileID)) + const fetching = useSelector((state: State) => state.files.fetching) + + const [form, setForm] = useState() + const [defaultForm, setDefaultForm] = useState() + const [saving, setSaving] = useState(false) + const [uploadedFile, setUploadedFile] = useState() + + // Initialize form + useEffect(() => { + if (!file) return + const setupForm: IFileForm = { + ...role, + ...initialForm, + fileId: file.id, + name: file.name, + description: file.shortDesc || '', + executable: false, // Non-executable files + } + setDefaultForm(setupForm) + setForm(setupForm) + setUploadedFile(undefined) + }, [fileID, file?.id]) + + const onDrop = useCallback((files: File[]) => { + if (files.length > 0) { + const droppedFile = files[0] + setUploadedFile(droppedFile) + // Update form name to match new file if name hasn't been changed + if (form && form.name === defaultForm?.name) { + setForm(prev => prev ? { ...prev, name: droppedFile.name } : prev) + } + } + }, [form, defaultForm]) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }) + + const handleSave = async () => { + if (!form || !file) return + setSaving(true) + + const metadataChanged = form.name !== defaultForm?.name || form.description !== defaultForm?.description + + if (uploadedFile) { + // New file uploaded - upload new version (includes metadata) + const uploadForm: IFileForm = { + ...form, + executable: false, + file: uploadedFile, + } + await dispatch.files.upload(uploadForm) + setUploadedFile(undefined) + } else if (metadataChanged) { + // Only metadata changed - use updateMetadata mutation (no new version) + await dispatch.files.updateMetadata({ + fileId: file.id, + name: form.name, + shortDesc: form.description, + }) + } + + setSaving(false) + setDefaultForm(form) + } + + // Can save when there's a new file or metadata has changed + const metadataChanged = form && defaultForm && (form.name !== defaultForm.name || form.description !== defaultForm.description) + const canSave = form && (!!uploadedFile || metadataChanged) + + if (!file) { + return + } + + if (!form) return null + + return ( + + + {sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> + )} + history.push('/files')} + size="md" + title="Back to Files" + /> + + {file.name} + + {manager && ( + + )} + + + } + > + + + + setForm({ ...form, name: e.target.value })} + /> + + + setForm({ ...form, description: e.target.value })} + /> + + + + {manager && ( + <> + + + + Upload New Version + + + + + {uploadedFile ? ( + <> + + {uploadedFile.name} + + {(uploadedFile.size / 1024).toFixed(1)} KB - Click or drag to replace + + + ) : ( + <> + Upload + Drag and drop or click + + )} + + + )} + + + + + + + ) +} diff --git a/frontend/src/pages/FilesPage.tsx b/frontend/src/pages/FilesPage.tsx index cc89dc8eb..ee9719e6a 100644 --- a/frontend/src/pages/FilesPage.tsx +++ b/frontend/src/pages/FilesPage.tsx @@ -3,19 +3,26 @@ import { useHistory } from 'react-router-dom' import { removeObject } from '../helpers/utilHelper' import { State, Dispatch } from '../store' import { ScriptingHeader } from '../components/ScriptingHeader' +import { ScriptsListHeader } from '../components/ScriptsListHeader' import { scriptAttributes } from '../components/FileAttributes' import { useSelector, useDispatch } from 'react-redux' import { selectScripts, selectFiles } from '../selectors/scripting' -import { Typography, Button } from '@mui/material' +import { Typography, Button, Box } from '@mui/material' import { LoadingMessage } from '../components/LoadingMessage' import { initialForm } from '../models/files' import { selectRole } from '../selectors/organizations' import { FileList } from '../components/FileList' +import { Container } from '../components/Container' import { Body } from '../components/Body' import { Icon } from '../components/Icon' import { Link } from '../components/Link' -export const FilesPage: React.FC<{ scripts?: boolean }> = ({ scripts }) => { +type Props = { + scripts?: boolean + showHeader?: boolean +} + +export const FilesPage: React.FC = ({ scripts, showHeader }) => { const dispatch = useDispatch() const history = useHistory() const [loading, setLoading] = useState(false) @@ -42,39 +49,54 @@ export const FilesPage: React.FC<{ scripts?: boolean }> = ({ scripts }) => { setLoading(false) } - return ( - - {!initialized ? ( - - ) : !files.length ? ( - - - {scripts ? ( - <> - - See how easy it is to run a script with our demo script. -
- For more examples and detailed guidance, - visit our documentation site. -
- - Need a device to test with? - - Try a docker container! - - - - ) : ( - - No files found - - )} - + const content = !initialized ? ( + + ) : !files.length ? ( + + + {scripts ? ( + <> + + See how easy it is to run a script with our demo script. +
+ For more examples and detailed guidance, + visit our documentation site. +
+ + Need a device to test with? + + Try a docker container! + + + ) : ( - + + No files found + )} -
+ + ) : ( + ) + + // Simple header for three-panel layout with hamburger menu for small screens + if (showHeader) { + return ( + + + + {content} + + + ) + } + + // Full header with tabs and action bar + return {content} } diff --git a/frontend/src/pages/FilesWithDetailPage.tsx b/frontend/src/pages/FilesWithDetailPage.tsx new file mode 100644 index 000000000..ef67ce833 --- /dev/null +++ b/frontend/src/pages/FilesWithDetailPage.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { useParams } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { State } from '../store' +import { selectFile } from '../selectors/scripting' +import { useContainerWidth } from '../hooks/useContainerWidth' +import { useResizablePanel } from '../hooks/useResizablePanel' +import { FilesPage } from './FilesPage' +import { FileDetailPage } from './FileDetailPage' +import { Stack } from '@mui/material' + +export const FilesWithDetailPage: React.FC = () => { + const { fileID } = useParams<{ fileID: string }>() + const file = useSelector((state: State) => selectFile(state, undefined, fileID)) + + const { containerRef, containerWidth } = useContainerWidth() + const panel1 = useResizablePanel(400, containerRef, { minWidth: 200 }) + + // Determine how many panels to show based on width + const maxPanels = containerWidth > 900 ? 2 : 1 + + // Show file details when a file is selected + const showPanel1 = maxPanels >= 1 + const showPanel2 = maxPanels >= 2 && !!fileID + + return ( + + {/* Panel 1: Files List */} + {showPanel1 && ( + + + + )} + + {/* Panel 2: File Details */} + {showPanel2 && ( + + + + )} + + ) +} diff --git a/frontend/src/pages/JobDetailPage.tsx b/frontend/src/pages/JobDetailPage.tsx new file mode 100644 index 000000000..35def8ae5 --- /dev/null +++ b/frontend/src/pages/JobDetailPage.tsx @@ -0,0 +1,256 @@ +import React, { useEffect } from 'react' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector, useDispatch } from 'react-redux' +import { Box, Stack, List, Typography, Divider, Button, useMediaQuery } from '@mui/material' +import { State, Dispatch } from '../store' +import { HIDE_SIDEBAR_WIDTH } from '../constants' +import { selectScript } from '../selectors/scripting' +import { getJobAttribute } from '../components/JobAttributes' +import { JobStatusIcon } from '../components/JobStatusIcon' +import { ListItemLocation } from '../components/ListItemLocation' +import { IconButton } from '../buttons/IconButton' +import { Container } from '../components/Container' +import { Timestamp } from '../components/Timestamp' +import { RunButton } from '../buttons/RunButton' +import { Gutters } from '../components/Gutters' +import { Notice } from '../components/Notice' +import { Title } from '../components/Title' +import { Icon } from '../components/Icon' +import { DeleteButton } from '../buttons/DeleteButton' + +type Props = { + showBack?: boolean + showMenu?: boolean +} + +export const JobDetailPage: React.FC = ({ showBack, showMenu }) => { + const dispatch = useDispatch() + const history = useHistory() + const { fileID, jobID } = useParams<{ fileID: string; jobID: string }>() + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + + const script = useSelector((state: State) => selectScript(state, undefined, fileID, jobID)) + const file = script + const job = script?.job + const fetching = useSelector((state: State) => state.files.fetching) + + // load jobs if not already loaded + useEffect(() => { + if (!job && !fetching && file) { + dispatch.jobs.fetchByFileIds({ fileIds: [file.id] }) + } + }, [file, job, fetching]) + + const handleRunWithUpdates = () => { + // Convert job arguments to argument values format for the form + const argumentValues: IArgumentValue[] = job.arguments?.map(arg => ({ + name: arg.name, + value: arg.value || '', + })) || [] + + // Store the form data in UI state + dispatch.ui.set({ + scriptForm: { + name: file.name, + description: file.shortDesc || '', + executable: file.executable, + fileId: fileID, + jobId: job.id, + deviceIds: job.jobDevices.map(jd => jd.device.id), + tag: job.tag, + access: job.tag.values.length > 0 ? 'TAG' : 'CUSTOM', + argumentValues, + }, + }) + + // Navigate to configure & run page + history.push(`/script/${fileID}/run`) + } + + const handlePrepareRun = async () => { + // Convert job arguments to argument values format for the form + const argumentValues: IArgumentValue[] = job.arguments?.map(arg => ({ + name: arg.name, + value: arg.value || '', + })) || [] + + // Create a prepared job with the same selections + await dispatch.jobs.save({ + name: file.name, + description: file.shortDesc || '', + executable: file.executable, + fileId: fileID, + deviceIds: job.jobDevices.map(jd => jd.device.id), + tag: job.tag, + access: job.tag.values.length > 0 ? 'TAG' : 'CUSTOM', + argumentValues, + }) + } + + if (!file || !job) { + return ( + + Job not found + + ) + } + + return ( + + + {showMenu && sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> + )} + history.push(`/script/${fileID}`)} + size="md" + title="Back" + /> + + {file.name} + + { + await dispatch.jobs.delete({ jobId: job.id, fileId: file.id }) + }} + /> + + + } + > + + {/* Status */} + + + + + {job.status.toLowerCase()} + + {job.jobDevices[0]?.updated && ( + + + + )} + + + + {/* Tags */} + {!!job?.tag.values.length && ( + + {getJobAttribute('jobTags').value({ job })} + + )} + + {/* Run Buttons */} + + await dispatch.jobs.run({ jobId: job?.id, fileId: file.id })} + onRunAgain={async () => await dispatch.jobs.runAgain({ ...file, job })} + onCancel={async () => await dispatch.jobs.cancel(job?.id)} + fullWidth + /> + + + + + {/* Arguments */} + {!!job.arguments?.length && ( + <> + + Arguments + + + {job.arguments.map(arg => ( + + + {arg.name} + + + {arg.value || '(empty)'} + + + ))} + + + )} + + + + {/* Device Status Summary */} + + Devices + + + + {job?.jobDevices.length || '-'} + + + + {job?.jobDevices.filter(d => d.status === 'WAITING').length || '-'} + + + + {job?.jobDevices.filter(d => d.status === 'RUNNING').length || '-'} + + + + {job?.jobDevices.filter(d => d.status === 'SUCCESS').length || '-'} + + + + {job?.jobDevices.filter(d => d.status === 'FAILED' || d.status === 'CANCELLED').length || '-'} + + + + + {!job.jobDevices.length ? ( + No devices in this run + ) : ( + + {job.jobDevices.map(jd => ( + } + /> + ))} + + )} + + + ) +} diff --git a/frontend/src/pages/JobDeviceDetailPage.tsx b/frontend/src/pages/JobDeviceDetailPage.tsx index a3fe09efa..266f7395a 100644 --- a/frontend/src/pages/JobDeviceDetailPage.tsx +++ b/frontend/src/pages/JobDeviceDetailPage.tsx @@ -1,9 +1,9 @@ import React from 'react' -import { State } from '../store' -import { useParams } from 'react-router-dom' -import { useSelector } from 'react-redux' +import { State, Dispatch } from '../store' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector, useDispatch } from 'react-redux' import { JobStatusIcon } from '../components/JobStatusIcon' -import { Box, Typography } from '@mui/material' +import { Box, Typography, useMediaQuery } from '@mui/material' import { JobAttribute } from '../components/JobAttributes' import { selectScript } from '../selectors/scripting' import { DataDisplay } from '../components/DataDisplay' @@ -15,9 +15,18 @@ import { Notice } from '../components/Notice' import { Title } from '../components/Title' import { radius } from '../styling' import { Pre } from '../components/Pre' +import { HIDE_SIDEBAR_WIDTH } from '../constants' -export const JobDeviceDetailPage: React.FC = () => { +type Props = { + showBack?: boolean + showMenu?: boolean +} + +export const JobDeviceDetailPage: React.FC = ({ showBack, showMenu }) => { + const dispatch = useDispatch() + const history = useHistory() const { fileID, jobID, jobDeviceID } = useParams<{ fileID?: string; jobID?: string; jobDeviceID?: string }>() + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) const script = useSelector((state: State) => selectScript(state, undefined, fileID)) const job = script?.jobs.find(j => j.id === jobID) || script?.jobs[0] const jobDevice = job?.jobDevices.find(jd => jd.id === jobDeviceID) @@ -34,6 +43,22 @@ export const JobDeviceDetailPage: React.FC = () => { header={ <> + {showMenu && sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> + )} + {showBack && ( + history.push(`/script/${fileID}/${jobID || 'latest'}`)} + size="md" + title="Back" + /> + )} diff --git a/frontend/src/pages/ScriptDetailPage.tsx b/frontend/src/pages/ScriptDetailPage.tsx new file mode 100644 index 000000000..72d4e1d63 --- /dev/null +++ b/frontend/src/pages/ScriptDetailPage.tsx @@ -0,0 +1,204 @@ +import React, { useState, useEffect } from 'react' +import sleep from '../helpers/sleep' +import { useParams, useHistory, Redirect, Link as RouterLink } from 'react-router-dom' +import { useSelector, useDispatch } from 'react-redux' +import { Typography, List, ListItem, TextField, Button, Stack, Box, useMediaQuery } from '@mui/material' +import { State, Dispatch } from '../store' +import { HIDE_SIDEBAR_WIDTH } from '../constants' +import { selectScript } from '../selectors/scripting' +import { selectRole } from '../selectors/organizations' +import { initialForm } from '../models/files' +import { ArgumentDefinitionForm } from '../components/ArgumentDefinitionForm' +import { FileUpload } from '../components/FileUpload' +import { Container } from '../components/Container' +import { IconButton } from '../buttons/IconButton' +import { Gutters } from '../components/Gutters' + +type Props = { + showBack?: boolean + showMenu?: boolean +} + +export const ScriptDetailPage: React.FC = ({ showBack, showMenu }) => { + const dispatch = useDispatch() + const history = useHistory() + const { fileID, jobID } = useParams<{ fileID: string; jobID?: string }>() + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + + const role = useSelector(selectRole) + const manager = role.permissions.includes('MANAGE') + const script = useSelector((state: State) => selectScript(state, undefined, fileID)) + const fetching = useSelector((state: State) => state.files.fetching) + + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [form, setForm] = useState() + const [defaultForm, setDefaultForm] = useState() + + // Get existing arguments from the script's latest file version + const scriptArguments = script?.versions?.[0]?.arguments || [] + const existingDefinitions: IArgumentDefinition[] = scriptArguments.map(arg => ({ + name: arg.name, + type: arg.argumentType, + desc: arg.desc || '', + options: arg.options?.length ? arg.options : undefined, + })) + + // Initialize form + useEffect(() => { + if (!script) return + const setupForm: IFileForm = { + ...role, + ...initialForm, + fileId: script.id, + name: script.name, + description: script.shortDesc || '', + executable: script.executable, + argumentDefinitions: existingDefinitions.length ? existingDefinitions : [], + } + setDefaultForm(setupForm) + setForm(setupForm) + }, [fileID, script?.id]) + + // Download script content + useEffect(() => { + if (loading || !script || !form || !defaultForm || defaultForm.script) return + const download = async () => { + setLoading(true) + const fileVersionId = script.versions[0]?.id + if (fileVersionId) { + const result = await dispatch.files.download(fileVersionId) + setDefaultForm(prev => prev ? { ...prev, script: result } : prev) + setForm(prev => prev ? { ...prev, script: result } : prev) + } + await sleep(200) + setLoading(false) + } + download() + }, [script, form, defaultForm]) + + const handleSave = async () => { + if (!form || !script) return + setSaving(true) + + const metadataChanged = form.name !== defaultForm?.name || form.description !== defaultForm?.description + const contentChanged = form.script !== defaultForm?.script || + JSON.stringify(form.argumentDefinitions) !== JSON.stringify(defaultForm?.argumentDefinitions) + + if (manager) { + if (contentChanged) { + // Script content or arguments changed - upload new version (includes metadata) + form.file = new File([form.script ?? ''], form.name, { type: 'text/plain' }) + await dispatch.files.upload(form) + } else if (metadataChanged) { + // Only metadata changed - use updateMetadata mutation (no new version) + await dispatch.files.updateMetadata({ + fileId: script.id, + name: form.name, + shortDesc: form.description, + }) + } + } + + setSaving(false) + // Update default form to reflect saved state + setDefaultForm(form) + } + + const hasChanges = form && defaultForm && ( + form.script !== defaultForm.script || + form.description !== defaultForm.description || + form.name !== defaultForm.name || + JSON.stringify(form.argumentDefinitions) !== JSON.stringify(defaultForm.argumentDefinitions) + ) + + if (!script) { + return + } + + if (!form) return null + + return ( + + + {showMenu && sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> + )} + history.push(`/script/${fileID}`)} + size="md" + title="Back" + /> + + {script.name} + + {manager && ( + + )} + + + } + > + + + + setForm({ ...form, name: e.target.value })} + /> + + + setForm({ ...form, description: e.target.value })} + /> + + {manager && ( + + setForm({ ...form, argumentDefinitions })} + disabled={loading || saving} + /> + + )} + + setForm({ ...form, script, ...(file && { name: file.name, file }) })} + /> + + + + + ) +} diff --git a/frontend/src/pages/ScriptPage.tsx b/frontend/src/pages/ScriptPage.tsx index 46294efe8..f78dff7f3 100644 --- a/frontend/src/pages/ScriptPage.tsx +++ b/frontend/src/pages/ScriptPage.tsx @@ -1,33 +1,34 @@ import React, { useEffect } from 'react' -import { VALID_JOB_ID_LENGTH } from '../constants' +import { VALID_JOB_ID_LENGTH, HIDE_SIDEBAR_WIDTH } from '../constants' import { selectFile, selectJobs } from '../selectors/scripting' import { State, Dispatch } from '../store' -import { getJobAttribute } from '../components/JobAttributes' import { ListItemLocation } from '../components/ListItemLocation' -import { Redirect, useParams } from 'react-router-dom' +import { Redirect, useParams, useHistory } from 'react-router-dom' import { useSelector, useDispatch } from 'react-redux' -import { Box, Stack, List, Typography } from '@mui/material' -import { ScriptDeleteButton } from '../components/ScriptDeleteButton' +import { Box, Stack, List, Typography, Button, useMediaQuery } from '@mui/material' import { LinearProgress } from '../components/LinearProgress' import { JobStatusIcon } from '../components/JobStatusIcon' +import { IconButton } from '../buttons/IconButton' import { Container } from '../components/Container' import { Timestamp } from '../components/Timestamp' -import { RunButton } from '../buttons/RunButton' -import { Gutters } from '../components/Gutters' import { Notice } from '../components/Notice' import { Title } from '../components/Title' import { Icon } from '../components/Icon' -// import { Pre } from '../components/Pre' +import { spacing } from '../styling' +import { ScriptDeleteButton } from '../components/ScriptDeleteButton' -export const ScriptPage: React.FC<{ layout: ILayout }> = ({ layout }) => { +type Props = { + showMenu?: boolean +} + +export const ScriptPage: React.FC = ({ showMenu }) => { const dispatch = useDispatch() - const { fileID, jobID, jobDeviceID } = useParams<{ fileID?: string; jobID?: string; jobDeviceID?: string }>() + const history = useHistory() + const { fileID, jobID } = useParams<{ fileID?: string; jobID?: string }>() const file = useSelector((state: State) => selectFile(state, undefined, fileID)) const jobs = useSelector(selectJobs).filter(j => j.file?.id === fileID) - const job: IJob | undefined = jobs.find(j => j.id === jobID) || jobs[0] const fetching = useSelector((state: State) => state.files.fetching) - const noDevices = !job || !job?.jobDevices.length - const validJobID = jobID && jobID.length >= VALID_JOB_ID_LENGTH + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) // load jobs if not already loaded useEffect(() => { @@ -36,103 +37,122 @@ export const ScriptPage: React.FC<{ layout: ILayout }> = ({ layout }) => { if (!file) return - if ( - !layout.singlePanel && - jobDeviceID !== 'edit' && - (!jobDeviceID || (jobID === 'latest' && !job?.jobDevices.some(jd => jd.id === jobDeviceID))) - ) { - return ( - - ) - } - return ( - - {file.name}} - icon={} - exactMatch - > - - - - - - - {!!job?.tag.values.length && {getJobAttribute('jobTags').value({ job })}} - {job?.jobDevices[0]?.updated && ( - - {job?.status.toLowerCase()}   - - + + + {showMenu && sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> )} + history.push('/scripts')} + size="md" + /> + + history.push(`/script/${fileID}/latest/edit`)} + > + + + + {file.name} + + + {file.shortDesc && ( - + {file.shortDesc} )} - - - - +
- + } > - <> - - Devices - - - {job?.jobDevices.length || '-'} - - - - {job?.jobDevices.filter(d => d.status === 'SUCCESS').length || '-'} - - - - {job?.jobDevices.filter(d => d.status === 'FAILED' || d.status === 'CANCELLED').length || '-'} - - - + + + + + {/* Runs List */} + + Run History - {noDevices ? ( + {!jobs.length ? ( This script has not been run yet. ) : ( - - await dispatch.jobs.run({ jobId: job?.id, fileId: file.id })} - onRunAgain={async () => await dispatch.jobs.runAgain({ ...file, job })} - onCancel={async () => await dispatch.jobs.cancel(job?.id)} - fullWidth - /> - + + {jobs.map(job => { + const waiting = job.jobDevices.filter(d => d.status === 'WAITING').length + const running = job.jobDevices.filter(d => d.status === 'RUNNING').length + const success = job.jobDevices.filter(d => d.status === 'SUCCESS').length + const failed = job.jobDevices.filter(d => d.status === 'FAILED' || d.status === 'CANCELLED').length + + return ( + + + + {job.jobDevices.length} + + 0 ? 'info.main' : 'text.secondary'} sx={{ minWidth: 16 }}>{waiting > 0 ? waiting : '-'} + + 0 ? 'primary.main' : 'text.secondary'} sx={{ minWidth: 16 }}>{running > 0 ? running : '-'} + + 0 ? 'primary.main' : 'text.secondary'} sx={{ minWidth: 16 }}>{success > 0 ? success : '-'} + + 0 ? 'error.main' : 'text.secondary'} sx={{ minWidth: 16 }}>{failed > 0 ? failed : '-'} + + {job.jobDevices[0]?.updated && ( + + + + )} + + } + icon={} + selected={job.id === jobID} + /> + ) + })} + )} - - {job?.jobDevices.map(jd => ( - } - /> - ))} - - + ) } diff --git a/frontend/src/pages/ScriptRunPage.tsx b/frontend/src/pages/ScriptRunPage.tsx new file mode 100644 index 000000000..ac2688e74 --- /dev/null +++ b/frontend/src/pages/ScriptRunPage.tsx @@ -0,0 +1,252 @@ +import React, { useState, useEffect, useRef } from 'react' +import structuredClone from '@ungap/structured-clone' +import { useParams, useHistory } from 'react-router-dom' +import { useSelector, useDispatch } from 'react-redux' +import { Typography, List, ListItem, Button, Stack, Box, Divider, useMediaQuery } from '@mui/material' +import { State, Dispatch } from '../store' +import { HIDE_SIDEBAR_WIDTH } from '../constants' +import { selectScript } from '../selectors/scripting' +import { selectRole } from '../selectors/organizations' +import { initialForm } from '../models/files' +import { ArgumentsValueForm } from '../components/ArgumentsValueForm' +import { IconButton } from '../buttons/IconButton' +import { TagFilter } from '../components/TagFilter' +import { Container } from '../components/Container' +import { Gutters } from '../components/Gutters' +import { Notice } from '../components/Notice' + +type Props = { + showClose?: boolean + showMenu?: boolean +} + +export const ScriptRunPage: React.FC = ({ showClose, showMenu }) => { + const dispatch = useDispatch() + const history = useHistory() + const { fileID, jobID } = useParams<{ fileID: string; jobID?: string }>() + const sidebarHidden = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`) + + const role = useSelector(selectRole) + const script = useSelector((state: State) => selectScript(state, undefined, fileID, jobID)) + const selectedIds = useSelector((state: State) => state.ui.selected) + const scriptFormFromState = useSelector((state: State) => state.ui.scriptForm) + const fetching = useSelector((state: State) => state.files.fetching) + + const [running, setRunning] = useState(false) + const [unauthorized, setUnauthorized] = useState() + const [form, setForm] = useState({ + ...role, + ...initialForm, + fileId: fileID, + access: 'NONE', + }) + const hasConsumedScriptForm = useRef(false) + + // Get arguments from the script's latest file version + const scriptArguments = script?.versions?.[0]?.arguments || [] + + // Get previous job's device selection + const defaultDeviceIds = script?.job?.jobDevices.map(d => d.device.id) || [] + const tagValues = script?.job?.tag?.values || [] + + // Ensure files are loaded (needed for FileSelect arguments) + useEffect(() => { + dispatch.files.fetchIfEmpty() + }, []) + + // Initialize form from scriptFormFromState (if coming from "Run with Updated Selections") or previous job + useEffect(() => { + if (!script) return + + // Check if we have a scriptForm in state (from "Run with Updated Selections") + if (scriptFormFromState && scriptFormFromState.fileId === script.id && !hasConsumedScriptForm.current) { + hasConsumedScriptForm.current = true + setForm(prev => ({ + ...prev, + ...scriptFormFromState, + })) + // Clear the scriptForm from state after using it + dispatch.ui.set({ scriptForm: undefined }) + } else if (!hasConsumedScriptForm.current) { + // Otherwise use default from last job + const access = tagValues.length ? 'TAG' : defaultDeviceIds.length ? 'CUSTOM' : selectedIds.length ? 'SELECTED' : 'NONE' + setForm(prev => ({ + ...prev, + fileId: script.id, + jobId: script.job?.status === 'READY' ? script.job?.id : undefined, + deviceIds: defaultDeviceIds, + tag: script.job?.tag ?? initialForm.tag, + access, + argumentValues: [], + })) + } + }, [fileID, script?.id]) + + // Update access when selectedIds change + useEffect(() => { + if (selectedIds.length) { + setForm(prev => ({ ...prev, access: 'SELECTED' })) + } + }, [selectedIds]) + + // Check authorization for selected devices + useEffect(() => { + if (!selectedIds.length && form.access !== 'CUSTOM') return + const deviceIds = form.access === 'SELECTED' ? selectedIds : form.deviceIds + + async function checkSelected() { + const unauthorized = await dispatch.jobs.unauthorized(deviceIds) + setUnauthorized(unauthorized.length ? unauthorized : undefined) + } + checkSelected() + }, [selectedIds, form.deviceIds, form.access]) + + const handleRun = async () => { + setRunning(true) + + const runForm = { ...form } + if (form.access === 'SELECTED') runForm.deviceIds = selectedIds + + await dispatch.jobs.saveRun(runForm) + dispatch.ui.set({ selected: [] }) + + setRunning(false) + } + + const handlePrepare = async () => { + setRunning(true) + + const prepareForm = { ...form } + if (form.access === 'SELECTED') prepareForm.deviceIds = selectedIds + + await dispatch.jobs.save(prepareForm) + dispatch.ui.set({ selected: [] }) + + setRunning(false) + } + + const clearUnauthorized = () => { + if (unauthorized) { + dispatch.ui.set({ selected: selectedIds.filter(id => !unauthorized.find(u => u.id === id)) }) + } + } + + const canRun = + !unauthorized && + ((form.access === 'SELECTED' && selectedIds.length > 0) || + (form.access === 'TAG' && (form.tag?.values.length ?? 0) > 0) || + (form.access === 'CUSTOM' && form.deviceIds.length > 0)) + + // Check if all required arguments have values + const requiredArgsFilled = scriptArguments.length === 0 || + scriptArguments.every(arg => form.argumentValues?.find(v => v.name === arg.name)?.value) + + if (!script) return null + + return ( + + + {showMenu && sidebarHidden && ( + dispatch.ui.set({ sidebarMenu: true })} + /> + )} + {showClose && ( + history.push(`/script/${fileID}`)} + size="md" + title="Close" + /> + )} + + Run {script?.name || 'Script'} + + + + } + > + + {/* Device Selection */} + + Target Devices + + + setForm({ ...form, ...structuredClone(f) })} + selectedIds={selectedIds} + onSelectIds={() => { + dispatch.ui.set({ scriptForm: form }) + history.push('/devices/select/scripts') + }} + /> + + + {unauthorized && ( + + You are not allowed to run scripts on + + {unauthorized.map(d => ( + + {d.name} + + ))} + + + + )} + + {/* Script Arguments */} + {scriptArguments.length > 0 && ( + <> + + setForm({ ...form, argumentValues })} + disabled={running} + /> + + )} + + {/* Run Buttons */} + + + + + + + ) +} diff --git a/frontend/src/pages/ScriptsWithDetailPage.tsx b/frontend/src/pages/ScriptsWithDetailPage.tsx new file mode 100644 index 000000000..451f1de2d --- /dev/null +++ b/frontend/src/pages/ScriptsWithDetailPage.tsx @@ -0,0 +1,263 @@ +import React from 'react' +import { Switch, Route, useLocation, useParams } from 'react-router-dom' +import { useSelector } from 'react-redux' +import { Box } from '@mui/material' +import { makeStyles } from '@mui/styles' +import { FilesPage } from './FilesPage' +import { ScriptPage } from './ScriptPage' +import { ScriptDetailPage } from './ScriptDetailPage' +import { JobDetailPage } from './JobDetailPage' +import { ScriptRunPage } from './ScriptRunPage' +import { JobDeviceDetailPage } from './JobDeviceDetailPage' +import { State } from '../store' +import { useContainerWidth } from '../hooks/useContainerWidth' +import { useResizablePanel } from '../hooks/useResizablePanel' + +const MIN_WIDTH = 250 +const FOUR_PANEL_WIDTH = 1200 +const THREE_PANEL_WIDTH = 900 +const TWO_PANEL_WIDTH = 600 +const DEFAULT_PANEL_WIDTH = 300 + +export const ScriptsWithDetailPage: React.FC = () => { + const css = useStyles() + const location = useLocation() + const { fileID } = useParams<{ fileID?: string }>() + const layout = useSelector((state: State) => state.ui.layout) + + const { containerRef, containerWidth } = useContainerWidth() + const panel1 = useResizablePanel(DEFAULT_PANEL_WIDTH, containerRef, { minWidth: MIN_WIDTH }) + const panel2 = useResizablePanel(DEFAULT_PANEL_WIDTH, containerRef, { minWidth: MIN_WIDTH }) + const panel3 = useResizablePanel(DEFAULT_PANEL_WIDTH, containerRef, { minWidth: MIN_WIDTH }) + + // Analyze URL to determine which panels should be shown + // URL patterns: + // /script/:fileID - Panel 2 only (script summary with runs list) + // /script/:fileID/:jobID/edit - Panel 3 (edit page) + // /script/:fileID/:jobID - Panel 3 (job detail) + // /script/:fileID/:jobID/:jobDeviceID - Panels 3 + 4 (job detail + device details) + // /script/:fileID/run - Panel 3 (configure & run) + const pathParts = location.pathname.split('/').filter(Boolean) + + const hasFileID = pathParts.length >= 2 && pathParts[0] === 'script' + const hasJobID = pathParts.length >= 3 && pathParts[2] !== 'run' + const isEditRoute = pathParts.includes('edit') + const isRunRoute = pathParts[pathParts.length - 1] === 'run' + const isJobDeviceRoute = pathParts.length >= 4 && pathParts[3] !== 'edit' && pathParts[3] !== 'run' + + // Panel visibility based on URL + const showPanel1 = true // Always available (scripts list) + const showPanel2 = hasFileID // Script summary with runs list + const showPanel3 = hasJobID || isEditRoute || isRunRoute // Job detail, edit, or configure & run + const showPanel4 = isJobDeviceRoute // Device details (4th panel) + + // Determine max panels based on container width + const maxPanels = layout.singlePanel ? 1 : + containerWidth < TWO_PANEL_WIDTH ? 1 : + containerWidth < THREE_PANEL_WIDTH ? 2 : + containerWidth < FOUR_PANEL_WIDTH ? 3 : 4 + + // Calculate which panels to actually show based on available space + // When Panel 4 is needed, shift left (hide Panel 1) to make room + let visiblePanel1 = false + let visiblePanel2 = false + let visiblePanel3 = false + let visiblePanel4 = false + + if (showPanel4) { + // 4-panel mode: shift left, hiding Panel 1 to show Panels 2, 3, 4 + if (maxPanels >= 4) { + visiblePanel1 = true + visiblePanel2 = true + visiblePanel3 = true + visiblePanel4 = true + } else if (maxPanels === 3) { + // Hide Panel 1 to fit Panels 2, 3, 4 + visiblePanel2 = true + visiblePanel3 = true + visiblePanel4 = true + } else if (maxPanels === 2) { + // Show Panels 3 and 4 (job detail + device detail) + visiblePanel3 = true + visiblePanel4 = true + } else { + // Single panel - show rightmost (device details) + visiblePanel4 = true + } + } else if (maxPanels >= 3) { + visiblePanel1 = showPanel1 + visiblePanel2 = showPanel2 + visiblePanel3 = showPanel3 + } else if (maxPanels === 2) { + // Show rightmost 2 panels + if (showPanel3 && showPanel2) { + visiblePanel2 = true + visiblePanel3 = true + } else if (showPanel2) { + visiblePanel1 = showPanel1 + visiblePanel2 = true + } else { + visiblePanel1 = showPanel1 + } + } else { + // Single panel - show rightmost active + if (showPanel3) visiblePanel3 = true + else if (showPanel2) visiblePanel2 = true + else visiblePanel1 = showPanel1 + } + + // Count visible panels for sizing logic + const visiblePanelCount = [visiblePanel1, visiblePanel2, visiblePanel3, visiblePanel4].filter(Boolean).length + + return ( + + + {/* Panel 1 - Scripts List */} + {visiblePanel1 && ( + <> + 1 ? css.panel : css.flexPanel} + style={ + visiblePanelCount > 1 + ? { width: panel1.width, minWidth: panel1.width } + : undefined + } + ref={visiblePanelCount > 1 ? panel1.panelRef : undefined} + > + + + {visiblePanelCount > 1 && ( + + + + + + )} + + )} + + {/* Panel 2 - Script Summary with runs list */} + {visiblePanel2 && ( + <> + + + + {(visiblePanel3 || visiblePanel4) && ( + + + + + + )} + + )} + + {/* Panel 3 - Job Detail, Edit, or Configure & Run */} + {visiblePanel3 && ( + <> + + + + + + + + + + + + + + {visiblePanel4 && ( + + + + + + )} + + )} + + {/* Panel 4 - Job Device Details */} + {visiblePanel4 && ( + + + + + + )} + + + ) +} + +const useStyles = makeStyles(({ palette }) => ({ + wrapper: { + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + }, + container: { + display: 'flex', + flexDirection: 'row', + flex: 1, + overflow: 'hidden', + }, + panel: { + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + }, + flexPanel: { + flex: 1, + height: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + minWidth: MIN_WIDTH, + }, + anchor: { + position: 'relative', + height: '100%', + }, + handle: { + zIndex: 8, + position: 'absolute', + height: '100%', + marginLeft: -5, + padding: '0 3px', + cursor: 'col-resize', + '& > div': { + width: 1, + marginLeft: 1, + marginRight: 1, + height: '100%', + backgroundColor: palette.grayLighter.main, + transition: 'background-color 100ms 200ms, width 100ms 200ms, margin 100ms 200ms', + }, + '&:hover > div, & .active': { + width: 3, + marginLeft: 0, + marginRight: 0, + backgroundColor: palette.primary.main, + }, + }, +})) diff --git a/frontend/src/routers/Router.tsx b/frontend/src/routers/Router.tsx index fe2dfe18b..52bac9871 100644 --- a/frontend/src/routers/Router.tsx +++ b/frontend/src/routers/Router.tsx @@ -265,7 +265,7 @@ export const Router: React.FC<{ layout: ILayout }> = ({ layout }) => { {/* Scripting */} - + {/* Settings */} diff --git a/frontend/src/routers/ScriptingRouter.tsx b/frontend/src/routers/ScriptingRouter.tsx index 1632393a0..57cb4bc3c 100644 --- a/frontend/src/routers/ScriptingRouter.tsx +++ b/frontend/src/routers/ScriptingRouter.tsx @@ -1,13 +1,13 @@ import React from 'react' import { useSelector } from 'react-redux' import { Switch, Route, useLocation } from 'react-router-dom' -import { JobDeviceDetailPage } from '../pages/JobDeviceDetailPage' import { selectPermissions } from '../selectors/organizations' -import { ScriptEditPage } from '../pages/ScriptEditPage' +import { ScriptsWithDetailPage } from '../pages/ScriptsWithDetailPage' +import { FilesWithDetailPage } from '../pages/FilesWithDetailPage' import { ScriptAddPage } from '../pages/ScriptAddPage' +import { FileAddPage } from '../pages/FileAddPage' import { DynamicPanel } from '../components/DynamicPanel' import { FilesPage } from '../pages/FilesPage' -import { ScriptPage } from '../pages/ScriptPage' import { JobsPage } from '../pages/JobsPage' import { Notice } from '../components/Notice' import { Panel } from '../components/Panel' @@ -32,42 +32,63 @@ export const ScriptingRouter: React.FC<{ layout: ILayout }> = ({ layout }) => { ) const locationParts = location.pathname.split('/') - if ((locationParts[1] === 'scripts' && locationParts.length === 2) || locationParts[1] === 'runs') + + // Use single panel for scripts/files list and runs + if ((locationParts[1] === 'scripts' && locationParts.length === 2) || + (locationParts[1] === 'files' && locationParts.length === 2) || + locationParts[1] === 'runs') layout = { ...layout, singlePanel: true } return ( - - - - - - - - - - - - } - secondary={ - - - - - - - - - - - - - - - } - layout={layout} - root={['/script/:fileID?/:jobID?', '/scripts', '/runs/:jobID?']} - /> + + {/* Scripts list with add panel */} + + } + secondary={} + layout={layout} + root="/scripts" + /> + + {/* Files list with add panel */} + + } + secondary={} + layout={layout} + root="/files" + /> + + {/* Script detail routes - multi-panel layout */} + + + + + + {/* File detail routes - multi-panel layout */} + + + + + + {/* Jobs/runs page */} + + + + + + {/* Files list */} + + + + + + {/* Scripts list */} + + + + + + ) } diff --git a/frontend/src/services/graphQLMutation.ts b/frontend/src/services/graphQLMutation.ts index 8c2b901cc..bf8634d3f 100644 --- a/frontend/src/services/graphQLMutation.ts +++ b/frontend/src/services/graphQLMutation.ts @@ -586,6 +586,15 @@ export async function graphQLDeleteFile(fileId: string) { ) } +export async function graphQLModifyFile(params: { fileId: string; name?: string; shortDesc?: string; longDesc?: string }) { + return await graphQLBasicRequest( + ` mutation ModifyFile($fileId: String!, $name: String, $shortDesc: String, $longDesc: String) { + modifyFile(fileId: $fileId, name: $name, shortDesc: $shortDesc, longDesc: $longDesc) + }`, + params + ) +} + export async function graphQLSetJob(params: { fileId: string jobId?: string @@ -619,6 +628,15 @@ export async function graphQLCancelJob(jobId?: string) { ) } +export async function graphQLDeleteJob(jobId: string) { + return await graphQLBasicRequest( + ` mutation DeleteJob($jobId: String!) { + deleteJob(jobId: $jobId) + }`, + { jobId } + ) +} + export async function graphQLRentANode(data: string[]) { return await graphQLBasicRequest( ` mutation RentANode($data: [String!]!) { diff --git a/frontend/src/services/graphQLRequest.ts b/frontend/src/services/graphQLRequest.ts index 2dcd83d79..8a3c40e83 100644 --- a/frontend/src/services/graphQLRequest.ts +++ b/frontend/src/services/graphQLRequest.ts @@ -74,6 +74,15 @@ export async function graphQLJobs(accountId: string, fileIds?: string[], ids?: s id name } + arguments { + id + name + desc + order + argumentType + value + created + } jobDevices { id status diff --git a/frontend/src/services/post.ts b/frontend/src/services/post.ts index b6e63c52e..4dc25bb9b 100644 --- a/frontend/src/services/post.ts +++ b/frontend/src/services/post.ts @@ -48,7 +48,9 @@ export async function postFile(file: File, data: ILookup = {}, path const form = new FormData() form.append('file', file) - Object.entries(data).forEach(([key, value]) => form.append(key, value)) + Object.entries(data).forEach(([key, value]) => { + form.append(key, value) + }) return await post(form, path) } diff --git a/types.d.ts b/types.d.ts index ac9fc168c..01b6bb757 100644 --- a/types.d.ts +++ b/types.d.ts @@ -636,6 +636,20 @@ declare global { type IFileArgumentType = 'FileSelect' | 'StringSelect' | 'StringEntry' + // For defining arguments when creating/editing scripts (sent to upload API) + type IArgumentDefinition = { + name: string + type: IFileArgumentType + desc: string + options?: string[] + } + + // For passing argument values when running scripts (sent to setJob mutation) + type IArgumentValue = { + name: string + value: string + } + type IFileForm = { name: string description: string @@ -647,6 +661,8 @@ declare global { jobId?: string tag?: ITagFilter file?: File + argumentDefinitions?: IArgumentDefinition[] // For script creation/edit + argumentValues?: IArgumentValue[] // For running scripts } type IJob = { @@ -662,6 +678,17 @@ declare global { name: string } jobDevices: IJobDevice[] + arguments: IJobArgument[] + } + + type IJobArgument = { + id: string + name: string + desc: string + order: number + argumentType: 'File' | 'String' + value?: string + created: ISOTimestamp } type IJobDevice = {