diff --git a/client/.eslintrc.cjs b/client/.eslintrc.cjs index 209a8ec..8f46073 100644 --- a/client/.eslintrc.cjs +++ b/client/.eslintrc.cjs @@ -45,8 +45,10 @@ module.exports = { ignore: ['^(vite)*'], }, ], + 'unicorn/no-array-for-each': 0, 'unicorn/no-null': 0, 'unicorn/prevent-abbreviations': 0, + 'unicorn/prefer-add-event-listener': 0, 'unicorn/prefer-query-selector': 0, 'import/no-unresolved': [ 2, diff --git a/client/package.json b/client/package.json index 9a58df9..52789b3 100644 --- a/client/package.json +++ b/client/package.json @@ -42,13 +42,15 @@ "react-hook-form": "^7.51.5", "react-i18next": "^14.1.2", "react-router-dom": "^6.23.1", - "reactflow": "^11.11.3" + "reactflow": "^11.11.3", + "websocket": "^1.0.35" }, "devDependencies": { "@types/lodash": "^4", "@types/node": "^18.19.34", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/websocket": "^1.0.10", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^7.12.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/client/src/App.tsx b/client/src/App.tsx index 8e68e3e..67f0355 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,17 +3,23 @@ import { RefineKbar, RefineKbarProvider } from '@refinedev/kbar' import { ErrorComponent, useNotificationProvider } from '@refinedev/mui' import routerBindings, { DocumentTitleHandler, - NavigateToResource, UnsavedChangesNotifier, } from '@refinedev/react-router-v6' -import * as React from 'react' -import { BrowserRouter, Outlet, Route, Routes } from 'react-router-dom' +import { + BrowserRouter, + Navigate, + Outlet, + Route, + Routes, +} from 'react-router-dom' import { ThemedLayoutV2 } from './components/layout' import { ThemedHeaderV2 } from './components/layout/Header' import { ThemedSiderV2 } from './components/layout/Sider' import { ThemedTitleV2 } from './components/layout/Title' import { ActionProvider } from './context/ActionContext' +import AppProvider from './context/AppContext' +import { liveProvider } from './live-provider' import { ActionList, ActionShow } from './pages/actions' import { FlowShow } from './pages/flow' import { dataProvider as launchrDataProvider } from './rest-data-provider' @@ -23,73 +29,67 @@ const apiUrl = import.meta.env.VITE_API_URL export function App() { return ( - - - - - - - - - } + + + + + + - } - /> - - } /> - } /> - {/*} />*/} - {/*} />*/} - - + - - + + + } - /> - - } /> - - + > + } /> + + } /> + } /> + {/*} />*/} + {/*} />*/} + + + } /> + + } /> + + - - - - - - - + + + + + + + + + ) } diff --git a/client/src/components/AnimatedFab.tsx b/client/src/components/AnimatedFab.tsx new file mode 100644 index 0000000..66b9ea3 --- /dev/null +++ b/client/src/components/AnimatedFab.tsx @@ -0,0 +1,50 @@ +import TerminalIcon from '@mui/icons-material/Terminal' +import Badge from '@mui/material/Badge' +import Fab from '@mui/material/Fab' +import { styled } from '@mui/system' +import { useEffect, useState } from 'react' + +const AnimatedBadge = styled(Badge)(() => ({ + transition: 'transform 0.3s ease-in-out', + '&.animate': { + transform: 'scale(1.5)', + }, +})) + +interface IAnimatedFabProps { + badgeLength: number + handleOpen: () => void +} + +const AnimatedFab = ({ badgeLength, handleOpen }: IAnimatedFabProps) => { + const [animate, setAnimate] = useState(false) + const [prevBadgeLength, setPrevBadgeLength] = useState(badgeLength) + + useEffect(() => { + if (badgeLength !== prevBadgeLength && badgeLength > 0) { + setAnimate(true) + const timeout = setTimeout(() => setAnimate(false), 300) + return () => clearTimeout(timeout) + } + setPrevBadgeLength(badgeLength) + }, [badgeLength, prevBadgeLength]) + + return ( + + + + + + ) +} + +export { AnimatedFab } diff --git a/client/src/components/FormFlow.tsx b/client/src/components/FormFlow.tsx index 6a42f1e..f9be149 100644 --- a/client/src/components/FormFlow.tsx +++ b/client/src/components/FormFlow.tsx @@ -1,112 +1,199 @@ +import { Breadcrumbs } from '@mui/material' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Divider from '@mui/material/Divider' import Typography from '@mui/material/Typography' -import { useApiUrl, useCustomMutation, useOne } from '@refinedev/core' +import { + useApiUrl, + useCustomMutation, + useNotification, + useOne, + usePublish, +} from '@refinedev/core' import type { IChangeEvent } from '@rjsf/core' import { withTheme } from '@rjsf/core' import { Theme } from '@rjsf/mui' -import { - FormContextType, - RJSFSchema, - StrictRJSFSchema, - TitleFieldProps, -} from '@rjsf/utils' +import { DescriptionFieldProps, TitleFieldProps } from '@rjsf/utils' import validator from '@rjsf/validator-ajv8' -import { type FC, useState } from 'react' +import merge from 'lodash/merge' +import { type FC, useContext, useEffect, useState } from 'react' -import { useStartAction } from '../hooks/ActionHooks' +import { AppContext } from '../context/AppContext' import type { IActionData, IFormValues } from '../types' +import { + customizeUiSchema, + sentenceCase, + splitActionId, +} from '../utils/helpers' const Form = withTheme(Theme) -function TitleFieldTemplate< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T = any, - S extends StrictRJSFSchema = RJSFSchema, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - F extends FormContextType = any, ->({ id, title }: TitleFieldProps) { - return ( - - {title} - - - ) -} - export const FormFlow: FC<{ actionId: string }> = ({ actionId }) => { const [actionRunning, setActionRunning] = useState(false) - const startAction = useStartAction() const apiUrl = useApiUrl() + const publish = usePublish() + const { addAction } = useContext(AppContext) const { mutateAsync } = useCustomMutation() + const { open } = useNotification() + const { levels } = splitActionId(actionId) const queryResult = useOne({ resource: 'actions', id: actionId, }) + const { isFetching, data } = queryResult - const { isFetching } = queryResult - - const jsonschema = queryResult?.data?.data?.jsonschema || {} + const actionTitle = data?.data?.title - const uischema = queryResult?.data?.data?.uischema?.uiSchema || {} + // Fetch schema and customize uiSchema + const jsonschema = data?.data?.jsonschema + let uischema = { ...data?.data?.uischema?.uiSchema } if (jsonschema) { - // @todo I actually don't know for the moment how to overcome error - // "no schema with key or ref" produced when schema is defined. - // Maybe it's because the server returns "2020-12" and default is "draft-07" - // @see https://ajv.js.org/json-schema.html delete jsonschema.$schema + uischema = merge({}, uischema, customizeUiSchema(jsonschema)) } - const onSubmit = async ( - { formData }: IChangeEvent - // e: FormEvent, - ) => { - if (!formData) { - return + useEffect(() => { + if (!jsonschema && !isFetching && open) { + open({ + type: 'error', + message: 'Schema not found', + description: 'The action schema could not be retrieved.', + }) } + }, [jsonschema, isFetching, open]) - setActionRunning(true) + const onSubmit = async ({ formData }: IChangeEvent) => { + if (!formData) return - startAction(actionId) + setActionRunning(true) + publish?.({ + channel: 'processes', + type: 'get-processes', + payload: { action: actionId }, + date: new Date(), + }) - await mutateAsync( - { + try { + const result = await mutateAsync({ url: `${apiUrl}/actions/${actionId}`, method: 'post', values: formData, - }, - { - onError: () => { - console.log('error') + successNotification: { + message: 'Action successfully created.', + description: 'Success with no errors', + type: 'success', }, - onSuccess: (data) => { - console.log(data) + errorNotification: { + message: 'Error.', + description: 'Something went wrong', + type: 'error', }, + }) + + if (result && actionId) { + addAction({ + id: actionId.toString(), + title: jsonschema?.title, + description: jsonschema?.description, + }) + publish?.({ + channel: 'process', + type: 'get-process', + payload: { action: result.data.id }, + date: new Date(), + }) } + } catch (error) { + console.error('Error creating action:', error) + } finally { + setActionRunning(false) + } + } + + function TitleFieldTemplate(props: TitleFieldProps) { + const { id, title } = props + if (id === 'root__title') { + return ( + <> + + {levels.map((a, i) => ( + + theme.palette.mode === 'dark' ? '#fff' : '#667085', + fontSize: 11, + fontWeight: 600, + lineHeight: 1.45, + letterSpacing: '0.22px', + }} + > + {sentenceCase(a)} + + ))} + + + theme.palette.mode === 'dark' ? '#fff' : '#000', + }} + > + {actionTitle || title} + + + ) + } + return ( + + {title} + + ) + } + + function DescriptionFieldTemplate(props: DescriptionFieldProps) { + const { description, id } = props + return ( + + {description} + ) } return ( - <> + {!isFetching && (
- + + )} - +
) } diff --git a/client/src/components/RunningAction.tsx b/client/src/components/RunningAction.tsx deleted file mode 100644 index 57b37af..0000000 --- a/client/src/components/RunningAction.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import Accordion from '@mui/material/Accordion' -import AccordionDetails from '@mui/material/AccordionDetails' -import AccordionSummary from '@mui/material/AccordionSummary' -import { useApiUrl, useCustom } from '@refinedev/core' -import Ansi from 'ansi-to-react' -import type { FC } from 'react' -import { useEffect, useState } from 'react' - -interface IRunningActiontProps { - actionId: string | undefined - id: string | undefined - key: string | undefined - status: string | undefined -} - -export const RunningAction: FC = ({ - actionId, - id, - status, -}) => { - const apiUrl = useApiUrl() - const [output, setOutput] = useState('') - const queryRunning = useCustom({ - url: `${apiUrl}/actions/${actionId}/running/${id}/streams`, - method: 'get', - }) - - useEffect(() => { - if (status === 'running') { - const { refetch } = queryRunning - - const fetchData = async () => { - const { data } = await refetch() - setOutput(data?.data?.content) - } - - const intervalId = setInterval( - fetchData, - import.meta.env.VITE_API_POLL_INTERVAL - ) - - return () => clearInterval(intervalId) - } - }, [status, queryRunning]) - return ( - <> - - } - sx={{ - color: 'primary.contrastText', - backgroundColor: - status === 'finished' ? 'success.light' : 'info.light', - }} - > - {id} {status} - - - {output.length > 0 ? ( -
- {output} -
- ) : ( - 'No output' - )} -
-
- - ) -} diff --git a/client/src/components/RunningActionsList.tsx b/client/src/components/RunningActionsList.tsx deleted file mode 100644 index a3452db..0000000 --- a/client/src/components/RunningActionsList.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { BaseRecord } from '@refinedev/core' -import { useApiUrl, useCustom } from '@refinedev/core' -import type { FC } from 'react' -import { useEffect, useState } from 'react' - -import { RunningAction } from './RunningAction' - -interface IRunInfo extends BaseRecord { - status: string -} - -interface IRunningActionsListProps { - actionRunning: boolean - actionId: string - onActionRunFinished: () => void -} - -export const RunningActionsList: FC = ({ - actionId, - actionRunning, - onActionRunFinished, -}) => { - const apiUrl = useApiUrl() - const [running, setRunning] = useState() - - const queryRunning = useCustom({ - url: `${apiUrl}/actions/${actionId}/running`, - method: 'get', - }) - - useEffect(() => { - if (actionRunning) { - const { refetch } = queryRunning - - const fetchData = async () => { - const { data } = await refetch() - - const runningActions = data?.data?.some((a) => a.status === 'running') - if (!runningActions) { - onActionRunFinished() - } - setRunning(data?.data) - } - const intervalId = setInterval( - fetchData, - import.meta.env.VITE_API_POLL_INTERVAL - ) - - return () => clearInterval(intervalId) - } - }, [actionRunning, queryRunning, onActionRunFinished]) - - return ( - <> - {running - ?.sort((a, b) => a.status.localeCompare(b.status)) - .map((info) => ( - - ))} - - ) -} diff --git a/client/src/components/SecondSidebarFlow.tsx b/client/src/components/SecondSidebarFlow.tsx index 1ba5a94..c9c847b 100644 --- a/client/src/components/SecondSidebarFlow.tsx +++ b/client/src/components/SecondSidebarFlow.tsx @@ -1,6 +1,9 @@ +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' +import ChevronRightIcon from '@mui/icons-material/ChevronRight' import CloseIcon from '@mui/icons-material/Close' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' +import { useTheme } from '@mui/system' import { GetListResponse } from '@refinedev/core' import { type FC, useEffect, useState } from 'react' @@ -30,6 +33,8 @@ export const SecondSidebarFlow: FC<{ const { flowClickedActionId, setFlowClickedActionId } = useFlowClickedActionID() const { handleUnselect } = useSidebarTreeItemClickStates() + const theme = useTheme() + const [expand, setExpand] = useState('25vw') useEffect(() => { if (actions.data && nodeId && !isAction(nodeId)) { @@ -65,6 +70,9 @@ export const SecondSidebarFlow: FC<{ } } + const onToggle = () => + expand === '25vw' ? setExpand('50vw') : setExpand('25vw') + if (!nodeId) { return null } @@ -72,7 +80,14 @@ export const SecondSidebarFlow: FC<{ return ( + onToggle()} + sx={{ + zIndex: 1, + mx: 1, + }} + > + {expand === '25vw' ? : } + {isAction(nodeId) ? ( ) : ( diff --git a/client/src/components/StatusBox.tsx b/client/src/components/StatusBox.tsx new file mode 100644 index 0000000..637eec7 --- /dev/null +++ b/client/src/components/StatusBox.tsx @@ -0,0 +1,101 @@ +import CloseIcon from '@mui/icons-material/Close' +import { TabContext, TabList, TabPanel } from '@mui/lab' +import { Box, IconButton, Modal, Tab } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { FC, SyntheticEvent, useContext, useState } from 'react' + +import { AnimatedFab } from '../components/AnimatedFab' +import { AppContext } from '../context/AppContext' +import StatusBoxAction from './StatusBoxAction' + +const StatusBox: FC = () => { + const { appState } = useContext(AppContext) + const [open, setOpen] = useState(false) + const [selectedActionIndex, setSelectedActionIndex] = useState('1') + const theme = useTheme() + + const handleOpen = () => setOpen(true) + const handleClose = () => setOpen(false) + const handleChange = (event: SyntheticEvent, newValue: string) => { + setSelectedActionIndex(newValue) + } + + const renderAnimatedFab = appState.runningActions.length > 0 + + if (appState.runningActions.length === 0) { + return null + } + + return ( + <> + {renderAnimatedFab && ( + + )} + + + + + + {appState.runningActions.map((action, index) => ( + + ))} + + + + + + {appState.runningActions.map((action, index) => ( + + + + ))} + + + + + ) +} + +export default StatusBox diff --git a/client/src/components/StatusBoxAction.tsx b/client/src/components/StatusBoxAction.tsx new file mode 100644 index 0000000..5bd244d --- /dev/null +++ b/client/src/components/StatusBoxAction.tsx @@ -0,0 +1,176 @@ +import { Box, Stack, Tab, Tabs, Typography } from '@mui/material' +import { useApiUrl, useCustom, useSubscription } from '@refinedev/core' +import { FC, SyntheticEvent, useCallback, useEffect, useState } from 'react' + +import { ACTION_STATE_COLORS } from '../constants' +import { ActionState, IAction, IActionProcess } from '../types' +import { extractDateTimeFromId } from '../utils/helpers' +import StatusBoxProcess from './StatusBoxProcess' + +interface IStatusBoxActionProps { + action: IAction +} + +interface TabPanelProps { + children?: React.ReactNode + index: number + value: number +} + +const TabPanel: FC = ({ children, value, index, ...other }) => ( + +) + +const transformPayload = (payload: { + processes: [{ ID: string; Status: string }] +}): IActionProcess[] => { + return payload.processes.map((process) => ({ + id: process.ID, + status: process.Status as ActionState, + })) +} + +const StatusBoxAction: FC = ({ action }) => { + const apiUrl = useApiUrl() + const [running, setRunning] = useState([]) + const [value, setValue] = useState(0) + + const { refetch: queryRunning } = useCustom({ + url: `${apiUrl}/actions/${action.id}/running`, + method: 'get', + }) + + useEffect(() => { + queryRunning().then((response) => { + if (response?.data?.data) { + setRunning(response.data.data) + } + }) + }, [action.id, queryRunning]) + + useSubscription({ + channel: 'processes', + types: ['send-processes', 'send-processes-finished'], + onLiveEvent: ({ payload, type }) => { + if ( + action.id === payload?.data?.action && + type === 'send-processes' && + payload?.data?.processes?.length > 0 + ) { + const newData = transformPayload(payload.data) + setRunning((prevRunning) => { + if (JSON.stringify(newData) !== JSON.stringify(prevRunning)) { + return newData + } + return prevRunning + }) + } + if (type === 'send-processes-finished') { + queryRunning().then((response) => { + if (response?.data) { + setRunning(response?.data?.data) + } + }) + } + }, + }) + + const handleChange = useCallback( + (event: SyntheticEvent, newValue: number) => { + if (running && Array.isArray(running) && newValue < running.length) { + setValue(newValue) + } + }, + [running] + ) + + return ( + + + {Array.isArray(running) && running.length > 0 + ? running + .sort((a, b) => a.status.localeCompare(b.status)) + .map((info, idx) => ( + + + + )) + : 'No processes running'} + + + + {Array.isArray(running) && running.length > 0 + ? running + .sort((a, b) => a.status.localeCompare(b.status)) + .map((info) => ( + + + {info.id} + + + started: {extractDateTimeFromId(info.id)} + + + } + sx={{ alignItems: 'start', minHeight: 10 }} + /> + )) + : null} + + + + ) +} + +export default StatusBoxAction diff --git a/client/src/components/StatusBoxProcess.tsx b/client/src/components/StatusBoxProcess.tsx new file mode 100644 index 0000000..f597471 --- /dev/null +++ b/client/src/components/StatusBoxProcess.tsx @@ -0,0 +1,82 @@ +import { + HttpError, + useApiUrl, + useCustom, + useSubscription, +} from '@refinedev/core' +import { FC, useEffect, useState } from 'react' + +import { IActionProcess } from '../types' +import TerminalBox from './TerminalBox' + +interface IStatusBoxProcessProps { + ri: IActionProcess + actionId: string +} + +interface StreamData { + content: string + count: number + offset: number + type: 'stdOut' | 'stdErr' +} + +const StatusBoxProcess: FC = ({ ri, actionId }) => { + const [streams, setStreams] = useState([]) + const apiUrl = useApiUrl() + + const { refetch: queryRunning } = useCustom({ + url: `${apiUrl}/actions/${actionId}/running/${ri.id}/streams`, + method: 'get', + }) + + useSubscription({ + channel: 'process', + types: ['send-process', 'send-process-finished'], + onLiveEvent: ({ payload, type }) => { + if (payload?.data?.action === ri.id) { + if (type === 'send-process' && payload?.data?.data) { + setStreams(payload.data.data) + } + + if (type === 'send-process-finished') { + queryRunning().then((response) => { + if (response?.data?.data) { + setStreams(response.data.data) + } + }) + } + } + }, + }) + + useEffect(() => { + if (ri.status === 'finished' || ri.status === 'error') { + queryRunning().then((response) => { + if (response?.data?.data) { + setStreams(response.data.data) + } + }) + } + }, [ri.status, ri.id, queryRunning]) + + return ( + <> + {streams.length > 0 ? ( + streams.map((stream, index) => ( +
+ {stream?.content.length > 0 ? ( + + ) : ( + '' + )} +
+ )) + ) : ( + <>loading + )} + + ) +} + +export default StatusBoxProcess diff --git a/client/src/components/TerminalBox.tsx b/client/src/components/TerminalBox.tsx new file mode 100644 index 0000000..5b936d0 --- /dev/null +++ b/client/src/components/TerminalBox.tsx @@ -0,0 +1,12 @@ +import Ansi from 'ansi-to-react' +import { FC } from 'react' + +interface ITerminalProps { + text: string +} + +const TerminalBox: FC = ({ text }) => { + return {text} +} + +export default TerminalBox diff --git a/client/src/components/layout/Header.tsx b/client/src/components/layout/Header.tsx index 46dffde..b83178d 100644 --- a/client/src/components/layout/Header.tsx +++ b/client/src/components/layout/Header.tsx @@ -1,18 +1,15 @@ -import AddToPhotosIcon from '@mui/icons-material/AddToPhotos' import GitHubIcon from '@mui/icons-material/GitHub' +import ListIcon from '@mui/icons-material/List' import AppBar from '@mui/material/AppBar' import Box from '@mui/material/Box' -import List from '@mui/material/List' -import ListItem from '@mui/material/ListItem' -import ListItemButton from '@mui/material/ListItemButton' -import ListItemIcon from '@mui/material/ListItemIcon' -import ListItemText from '@mui/material/ListItemText' +import IconButton from '@mui/material/IconButton' +import Stack from '@mui/material/Stack' import Toolbar from '@mui/material/Toolbar' -import type { RefineThemedLayoutV2HeaderProps } from '@refinedev/mui' -import type { FC } from 'react' +import Tooltip from '@mui/material/Tooltip' // Added for tooltips +import { RefineThemedLayoutV2HeaderProps } from '@refinedev/mui' +import { FC } from 'react' import { DarkModeSwitcher } from './DarkModeSwitcher' -// import { HamburgerMenu } from './HamburgerMenu' import { ThemedTitleV2 as Title } from './Title' export const ThemedHeaderV2: FC = () => ( @@ -26,46 +23,27 @@ export const ThemedHeaderV2: FC = () => ( > - <Box - sx={{ - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-end', - gap: '6px', - flexGrow: 1, - }} - > - <List - sx={{ - display: 'flex', - alignItems: 'center', - flexGrow: 0, - width: 480, - }} - > - <ListItem disablePadding disableGutters> - <ListItemButton href="/flow"> - <ListItemIcon> - <AddToPhotosIcon /> - </ListItemIcon> - <ListItemText primary="Flow (Experimental)" /> - </ListItemButton> - </ListItem> - <ListItem disablePadding disableGutters> - <ListItemButton - href="https://github.com/launchrctl/web/issues/new" - target={'_blank'} - > - <ListItemIcon> - <GitHubIcon /> - </ListItemIcon> - <ListItemText primary="Report bug" /> - </ListItemButton> - </ListItem> - </List> + <Box sx={{ flexGrow: 1 }} /> + <Stack direction="row" spacing={1} alignItems="center"> + <Tooltip title="Actions list"> + <IconButton href="/actions" size="small" color="inherit"> + <ListIcon /> + </IconButton> + </Tooltip> + + <Tooltip title="Report bug"> + <IconButton + href="https://github.com/launchrctl/web/issues/new" + target="_blank" + size="small" + color="inherit" + > + <GitHubIcon /> + </IconButton> + </Tooltip> + <DarkModeSwitcher /> - {/* <HamburgerMenu /> */} - </Box> + </Stack> </Toolbar> </AppBar> ) diff --git a/client/src/components/layout/index.tsx b/client/src/components/layout/index.tsx index dadd8a7..e818b1d 100644 --- a/client/src/components/layout/index.tsx +++ b/client/src/components/layout/index.tsx @@ -3,6 +3,7 @@ import type { RefineThemedLayoutV2Props } from '@refinedev/mui' import { ThemedLayoutContextProvider } from '@refinedev/mui' import type { FC } from 'react' +import StatusBox from '../StatusBox' import { ThemedHeaderV2 as DefaultHeader } from './Header' export const ThemedLayoutV2: FC<RefineThemedLayoutV2Props> = ({ @@ -35,6 +36,7 @@ export const ThemedLayoutV2: FC<RefineThemedLayoutV2Props> = ({ sx={{ flexGrow: 1, bgcolor: (theme) => theme.palette.background.default, + paddingBlockEnd: '100px', '& > .MuiPaper-root, & > div:not([class]) > .MuiPaper-root': { borderRadius: { xs: 0, @@ -48,6 +50,7 @@ export const ThemedLayoutV2: FC<RefineThemedLayoutV2Props> = ({ {Footer && <Footer />} </Box> {OffLayoutArea && <OffLayoutArea />} + <StatusBox /> </Box> </ThemedLayoutContextProvider> ) diff --git a/client/src/constants.tsx b/client/src/constants.tsx new file mode 100644 index 0000000..bf13ae3 --- /dev/null +++ b/client/src/constants.tsx @@ -0,0 +1,8 @@ +import { green, grey, red, yellow } from '@mui/material/colors' + +export const ACTION_STATE_COLORS = { + created: grey[500], + running: yellow[500], + finished: green[500], + error: red[500], +} diff --git a/client/src/context/ActionContext.tsx b/client/src/context/ActionContext.tsx index 5a54ee1..70a0862 100644 --- a/client/src/context/ActionContext.tsx +++ b/client/src/context/ActionContext.tsx @@ -1,19 +1,7 @@ import { createContext, Dispatch, FC, ReactNode, useReducer } from 'react' - -type ActionState = 'running' | 'finished' | 'error' -type TerminalOutput = string[] - -interface RunningActionDetails { - id: string - runId: string - state: ActionState - output: TerminalOutput -} - export interface State { id: string type?: 'set-actions-sidebar' | '' - runningActions?: RunningActionDetails[] } export interface Action { @@ -29,7 +17,6 @@ interface Props { const initialState: State = { id: '', type: '', - runningActions: [], } export const ActionContext = createContext<State>(initialState) @@ -44,57 +31,9 @@ const reducer = (state: State, action: Action): State => { id: action.id || '', } } - case 'start-action': { - return { - ...state, - runningActions: [ - ...(state.runningActions ?? []), - { - id: action.id as string, - state: 'running', - output: [], - runId: '', - }, - ], - } - } - case 'finish-action': { - return { - ...state, - runningActions: state.runningActions - ? state.runningActions.map((act) => - act.id === action.id ? { ...act, state: 'finished' } : act - ) - : [], - } - } - case 'error-action': { - return { - ...state, - runningActions: state.runningActions - ? state.runningActions.map((act) => - act.id === action.id ? { ...act, state: 'error' } : act - ) - : [], - } - } - case 'update-output': { - return { - ...state, - runningActions: state.runningActions - ? state.runningActions.map((act) => - act.id === action.id - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - { ...act, output: [...act.output, action.output!] } - : act - ) - : [], - } - } case 'clear-actions': { return { ...state, - runningActions: [], } } default: { diff --git a/client/src/context/AppContext.tsx b/client/src/context/AppContext.tsx new file mode 100644 index 0000000..211b647 --- /dev/null +++ b/client/src/context/AppContext.tsx @@ -0,0 +1,61 @@ +import { createContext, FC, ReactNode, useEffect, useState } from 'react' + +import { IAction } from '../types' + +interface AppState { + runningActions: IAction[] +} + +interface AppContextValue { + appState: AppState + addAction: (action: IAction) => void +} + +export const AppContext = createContext<AppContextValue>({ + appState: { runningActions: [] }, + addAction: (action: IAction) => { + console.warn('addAction function not yet implemented. Action:', action) + }, +}) +interface AppProviderProps { + children: ReactNode +} + +const AppProvider: FC<AppProviderProps> = ({ children }) => { + const [appState, setAppState] = useState<AppState>({ + runningActions: [], + }) + + useEffect(() => { + const storedState = sessionStorage.getItem('appState') + if (storedState) { + setAppState(JSON.parse(storedState)) + } + }, []) + + const addAction = (action: IAction) => { + setAppState((prevState) => { + const existingActionIndex = prevState.runningActions.findIndex( + (a) => a.id === action.id + ) + if (existingActionIndex === -1) { + const updatedRunningActions = [...prevState.runningActions, action] + const updatedState = { + ...prevState, + runningActions: updatedRunningActions, + } + sessionStorage.setItem('appState', JSON.stringify(updatedState)) + return updatedState + } + return prevState + }) + } + + return ( + <AppContext.Provider value={{ appState, addAction }}> + {children} + </AppContext.Provider> + ) +} + +export default AppProvider diff --git a/client/src/hooks/ActionHooks.ts b/client/src/hooks/ActionHooks.ts index e17890c..eb344fa 100644 --- a/client/src/hooks/ActionHooks.ts +++ b/client/src/hooks/ActionHooks.ts @@ -11,47 +11,6 @@ export const useAction = (): State => useContext(ActionContext) export const useActionDispatch = (): Dispatch<Action> | null => useContext(ActionDispatchContext) -// Custom hooks for easier usage -export const useStartAction = () => { - const dispatch = useActionDispatch() - return useCallback( - (id: string) => { - dispatch?.({ type: 'start-action', id }) - }, - [dispatch] - ) -} - -export const useFinishAction = () => { - const dispatch = useActionDispatch() - return useCallback( - (id: string) => { - dispatch?.({ type: 'finish-action', id }) - }, - [dispatch] - ) -} - -export const useErrorAction = () => { - const dispatch = useActionDispatch() - return useCallback( - (id: string) => { - dispatch?.({ type: 'error-action', id }) - }, - [dispatch] - ) -} - -export const useUpdateOutput = () => { - const dispatch = useActionDispatch() - return useCallback( - (id: string, output: string) => { - dispatch?.({ type: 'update-output', id, output }) - }, - [dispatch] - ) -} - export const useClearActions = () => { const dispatch = useActionDispatch() return useCallback(() => { diff --git a/client/src/live-provider/index.ts b/client/src/live-provider/index.ts new file mode 100644 index 0000000..fa06500 --- /dev/null +++ b/client/src/live-provider/index.ts @@ -0,0 +1,105 @@ +import { LiveEvent, LiveProvider } from '@refinedev/core' +import { + ICloseEvent, + IMessageEvent, + w3cwebsocket as W3CWebSocket, +} from 'websocket' + +const websocketUrl = 'ws://localhost:8080/ws' +let actionsSocket = new W3CWebSocket(websocketUrl) + +const reconnectInterval = 5000 +let reconnectAttempts = 0 +const maxReconnectAttempts = 10 + +const handleOpen = () => { + console.log('WebSocket connection opened') + reconnectAttempts = 0 +} + +const handleClose = (event: ICloseEvent) => { + if (event.wasClean) { + console.log( + `WebSocket connection closed cleanly, code=${event.code}, reason=${event.reason}` + ) + } else { + console.log('WebSocket connection lost unexpectedly') + + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++ + setTimeout(() => { + console.log(`Attempting to reconnect (attempt ${reconnectAttempts})...`) + actionsSocket = new W3CWebSocket(websocketUrl) + setupWebSocketHandlers() + }, reconnectInterval) + } else { + console.error('Max reconnect attempts reached') + } + } +} + +const handleError = (error: Error) => { + console.error('WebSocket error:', error) +} + +const messageHandlers = new Map< + string, + Map<string, Set<(event: LiveEvent) => void>> +>() + +const handleMessage = (e: IMessageEvent) => { + try { + const data = JSON.parse(e.data.toString()) + const event = { + channel: data.channel, + type: data.message, + payload: { data }, + date: new Date(), + } + + messageHandlers + .get(event.channel) + ?.get(event.type) + ?.forEach((callback) => callback(event)) + } catch (error) { + console.error('Error parsing WebSocket message:', error) + } +} + +const setupWebSocketHandlers = () => { + actionsSocket.onopen = handleOpen + actionsSocket.onclose = handleClose + actionsSocket.onerror = handleError + actionsSocket.onmessage = handleMessage +} + +setupWebSocketHandlers() + +export const liveProvider: LiveProvider = { + subscribe: async ({ channel, callback, types }) => { + if (!messageHandlers.has(channel)) { + messageHandlers.set(channel, new Map()) + } + + for (const type of types) { + if (!messageHandlers.get(channel)?.has(type)) { + messageHandlers.get(channel)?.set(type, new Set()) + } + messageHandlers.get(channel)?.get(type)?.add(callback) + } + + return () => { + // TODO: Add unsubscribe. + } + }, + unsubscribe: (unsubscribe) => { + unsubscribe.then((f: () => void) => f()) + }, + publish: async ({ payload, type }) => { + const message = { + message: type, + ...payload, + } + actionsSocket.send(JSON.stringify(message)) + }, +} diff --git a/client/src/pages/actions/Show.tsx b/client/src/pages/actions/Show.tsx index 25f15fb..aa0342b 100644 --- a/client/src/pages/actions/Show.tsx +++ b/client/src/pages/actions/Show.tsx @@ -1,121 +1,113 @@ import Button from '@mui/material/Button' -import Divider from '@mui/material/Divider' import { useApiUrl, useCustomMutation, useNotification, useOne, + usePublish, useResource, } from '@refinedev/core' import { Show } from '@refinedev/mui' -import type { IChangeEvent } from '@rjsf/core' -import { withTheme } from '@rjsf/core' +import { IChangeEvent, withTheme } from '@rjsf/core' import { Theme } from '@rjsf/mui' import validator from '@rjsf/validator-ajv8' -import type { FC } from 'react' -import { useState } from 'react' +import merge from 'lodash/merge' +import { FC, useContext, useEffect, useState } from 'react' -import { RunningActionsList } from '../../components/RunningActionsList' -import type { IActionData, IFormValues } from '../../types' +import { AppContext } from '../../context/AppContext' +import { IActionData, IFormValues } from '../../types' import { customizeUiSchema } from '../../utils/helpers' -// Make modifications to the theme with your own fields and widgets const Form = withTheme(Theme) export const ActionShow: FC = () => { - // @todo const translate = useTranslate(); - const { - // resource, - id: idFromRoute, - // action: actionFromRoute, - identifier, - } = useResource() - + const { id: idFromRoute, identifier } = useResource() + const publish = usePublish() + const { addAction } = useContext(AppContext) const { open } = useNotification() - const [actionRunning, setActionRunning] = useState(false) const queryResult = useOne<IActionData>({ resource: identifier, id: idFromRoute, }) + const { isFetching, data } = queryResult + const apiUrl = useApiUrl() + const { mutateAsync } = useCustomMutation() - const { isFetching } = queryResult - - const jsonschema = queryResult?.data?.data?.jsonschema - let uischema = { - ...queryResult?.data?.data?.uischema?.uiSchema, - } - + // Fetch schema and customize uiSchema + const jsonschema = data?.data?.jsonschema + let uischema = { ...data?.data?.uischema?.uiSchema } if (jsonschema) { - // @todo I actually don't know for the moment how to overcome error - // "no schema with key or ref" produced when schema is defined. - // Maybe it's because the server returns "2020-12" and default is "draft-07" - // @see https://ajv.js.org/json-schema.html delete jsonschema.$schema - - uischema = { - ...uischema, - ...customizeUiSchema(jsonschema), - } + uischema = merge({}, uischema, customizeUiSchema(jsonschema)) } - const apiUrl = useApiUrl() - - const { mutateAsync } = useCustomMutation() - - const onSubmit = async ( - { formData }: IChangeEvent<IFormValues> - // e: FormEvent<IFormValues>, - ) => { - if (!formData) { - return + useEffect(() => { + if (!jsonschema && !isFetching) { + open?.({ + type: 'error', + message: 'Schema not found', + description: 'The action schema could not be retrieved.', + }) } + }, [jsonschema, open, isFetching]) + + // Handle form submission + const onSubmit = async ({ formData }: IChangeEvent<IFormValues>) => { + if (!formData) return setActionRunning(true) + publish?.({ + channel: 'processes', + type: 'get-processes', + payload: { action: idFromRoute }, + date: new Date(), + }) - await mutateAsync({ - url: `${apiUrl}/actions/${idFromRoute}`, - method: 'post', - values: formData, - // @todo more informative messages. - successNotification: () => ({ - message: 'Action successfully started.', - description: 'Success with no errors', - type: 'success', - }), - errorNotification() { - return { + try { + const result = await mutateAsync({ + url: `${apiUrl}/actions/${idFromRoute}`, + method: 'post', + values: formData, + successNotification: { + message: 'Action successfully created.', + description: 'Success with no errors', + type: 'success', + }, + errorNotification: { message: 'Error.', - description: 'Something goes wrong', + description: 'Something went wrong', type: 'error', - } - }, - }) - // @todo redirect somewhere - } - - const onActionRunFinished = async () => { - setActionRunning(false) - open?.({ - type: 'success', - message: 'All actions runs finished', - description: 'Success!', - }) + }, + }) + + if (result && idFromRoute) { + addAction({ + id: idFromRoute.toString(), + title: jsonschema?.title, + description: jsonschema?.description, + }) + publish?.({ + channel: 'process', + type: 'get-process', + payload: { action: result.data.id }, + date: new Date(), + }) + } + } catch (error) { + console.error('Error creating action:', error) + open?.({ + type: 'error', + message: 'Action creation failed', + }) + } finally { + setActionRunning(false) + } } return ( <Show isLoading={isFetching} title=""> - <RunningActionsList - actionId={idFromRoute ? idFromRoute.toString() : ''} - actionRunning={actionRunning} - onActionRunFinished={onActionRunFinished} - /> - <Divider - sx={{ - my: 4, - }} - /> {jsonschema && ( <Form schema={jsonschema} @@ -123,11 +115,9 @@ export const ActionShow: FC = () => { validator={validator} onSubmit={onSubmit} > - <div> - <Button variant="contained" type="submit" disabled={actionRunning}> - Submit - </Button> - </div> + <Button variant="contained" type="submit" disabled={actionRunning}> + Submit + </Button> </Form> )} </Show> diff --git a/client/src/pages/flow/Show.tsx b/client/src/pages/flow/Show.tsx index 47dfd52..0a65f2d 100644 --- a/client/src/pages/flow/Show.tsx +++ b/client/src/pages/flow/Show.tsx @@ -1,4 +1,5 @@ import Grid from '@mui/material/Grid' +import { Box } from '@mui/system' import { GetListResponse, useList } from '@refinedev/core' import { type FC, useEffect, useState } from 'react' @@ -59,17 +60,20 @@ export const FlowShow: FC = () => { <Grid item xs={7} sx={{ height: 'calc(100vh - 68px)' }}> <SidebarFlow actions={dataReceived} /> </Grid> - <Grid - item - xs={renderEndSidebar ? 21 : 29} - sx={{ height: 'calc(100vh - 68px)' }} - > + <Grid item xs={29} sx={{ height: 'calc(100vh - 68px)' }}> <ActionsFlow actions={dataReceived} /> </Grid> {renderEndSidebar && ( - <Grid item xs={8} sx={{ height: 'calc(100vh - 68px)' }}> + <Box + sx={{ + height: 'calc(100vh - 68px)', + position: 'fixed', + right: 0, + top: 68, + }} + > <SecondSidebarFlow actions={dataReceived} nodeId={nodeId} /> - </Grid> + </Box> )} </> )} diff --git a/client/src/types.ts b/client/src/types.ts index dc7fab4..9e77f1e 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -1,13 +1,24 @@ import type { BaseRecord } from '@refinedev/core' import type { RJSFSchema, UiSchema } from '@rjsf/utils' -type IFlowNodeType = 'node-start' | 'node-wrapper' | 'node-action' +type ActionState = 'created' | 'running' | 'finished' | 'error' +type IFlowNodeType = 'node-start' | 'node-wrapper' | 'node-action' interface IAction { id: string title?: string description?: string } + +interface IActionProcess { + id: string + status: ActionState +} + +interface IActionWithRunInfo extends IAction { + processes: IActionProcess[] +} + interface IActionData extends BaseRecord { jsonschema: RJSFSchema uischema: UiSchema @@ -17,4 +28,12 @@ interface IFormValues { id: string } -export type { IAction, IActionData, IFlowNodeType, IFormValues } +export type { + ActionState, + IAction, + IActionData, + IActionProcess, + IActionWithRunInfo, + IFlowNodeType, + IFormValues, +} diff --git a/client/src/utils/helpers.tsx b/client/src/utils/helpers.tsx index 7b90cd4..165bc37 100644 --- a/client/src/utils/helpers.tsx +++ b/client/src/utils/helpers.tsx @@ -37,3 +37,12 @@ export const customizeUiSchema = ( return uiSchema } + +export const extractDateTimeFromId = (id: string) => { + const [timestampStr] = id.split('-') + const timestamp = Number.parseInt(timestampStr, 10) + const date = new Date(timestamp * 1000) + const formattedDate = date.toLocaleString() + + return formattedDate +} diff --git a/client/yarn.lock b/client/yarn.lock index 138c6b0..fe741c1 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -3054,6 +3054,15 @@ __metadata: languageName: node linkType: hard +"@types/websocket@npm:^1.0.10": + version: 1.0.10 + resolution: "@types/websocket@npm:1.0.10" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/5950b8d01d1178c67c049f482fcab182085c59c2f98edda5980721f6eb512439ff91534e50ca7262720d75fc42ea6c8f8e5e7739442feea8f3cc0e320ebe2c74 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^5.62.0": version: 5.62.0 resolution: "@typescript-eslint/eslint-plugin@npm:5.62.0" @@ -3754,6 +3763,16 @@ __metadata: languageName: node linkType: hard +"bufferutil@npm:^4.0.1": + version: 4.0.8 + resolution: "bufferutil@npm:4.0.8" + dependencies: + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.3.0" + checksum: 10c0/36cdc5b53a38d9f61f89fdbe62029a2ebcd020599862253fefebe31566155726df9ff961f41b8c97b02b4c12b391ef97faf94e2383392654cf8f0ed68f76e47c + languageName: node + linkType: hard + "builtin-modules@npm:^3.3.0": version: 3.3.0 resolution: "builtin-modules@npm:3.3.0" @@ -4309,6 +4328,16 @@ __metadata: languageName: node linkType: hard +"d@npm:1, d@npm:^1.0.1, d@npm:^1.0.2": + version: 1.0.2 + resolution: "d@npm:1.0.2" + dependencies: + es5-ext: "npm:^0.10.64" + type: "npm:^2.7.2" + checksum: 10c0/3e6ede10cd3b77586c47da48423b62bed161bf1a48bdbcc94d87263522e22f5dfb0e678a6dba5323fdc14c5d8612b7f7eb9e7d9e37b2e2d67a7bf9f116dabe5a + languageName: node + linkType: hard + "data-view-buffer@npm:^1.0.1": version: 1.0.1 resolution: "data-view-buffer@npm:1.0.1" @@ -4365,7 +4394,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:2.6.9": +"debug@npm:2.6.9, debug@npm:^2.2.0": version: 2.6.9 resolution: "debug@npm:2.6.9" dependencies: @@ -4806,6 +4835,39 @@ __metadata: languageName: node linkType: hard +"es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.62, es5-ext@npm:^0.10.63, es5-ext@npm:^0.10.64, es5-ext@npm:~0.10.14": + version: 0.10.64 + resolution: "es5-ext@npm:0.10.64" + dependencies: + es6-iterator: "npm:^2.0.3" + es6-symbol: "npm:^3.1.3" + esniff: "npm:^2.0.1" + next-tick: "npm:^1.1.0" + checksum: 10c0/4459b6ae216f3c615db086e02437bdfde851515a101577fd61b19f9b3c1ad924bab4d197981eb7f0ccb915f643f2fc10ff76b97a680e96cbb572d15a27acd9a3 + languageName: node + linkType: hard + +"es6-iterator@npm:^2.0.3": + version: 2.0.3 + resolution: "es6-iterator@npm:2.0.3" + dependencies: + d: "npm:1" + es5-ext: "npm:^0.10.35" + es6-symbol: "npm:^3.1.1" + checksum: 10c0/91f20b799dba28fb05bf623c31857fc1524a0f1c444903beccaf8929ad196c8c9ded233e5ac7214fc63a92b3f25b64b7f2737fcca8b1f92d2d96cf3ac902f5d8 + languageName: node + linkType: hard + +"es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3": + version: 3.1.4 + resolution: "es6-symbol@npm:3.1.4" + dependencies: + d: "npm:^1.0.2" + ext: "npm:^1.7.0" + checksum: 10c0/777bf3388db5d7919e09a0fd175aa5b8a62385b17cb2227b7a137680cba62b4d9f6193319a102642aa23d5840d38a62e4784f19cfa5be4a2210a3f0e9b23d15d + languageName: node + linkType: hard + "esbuild@npm:^0.20.1": version: 0.20.2 resolution: "esbuild@npm:0.20.2" @@ -5148,6 +5210,18 @@ __metadata: languageName: node linkType: hard +"esniff@npm:^2.0.1": + version: 2.0.1 + resolution: "esniff@npm:2.0.1" + dependencies: + d: "npm:^1.0.1" + es5-ext: "npm:^0.10.62" + event-emitter: "npm:^0.3.5" + type: "npm:^2.7.2" + checksum: 10c0/7efd8d44ac20e5db8cb0ca77eb65eca60628b2d0f3a1030bcb05e71cc40e6e2935c47b87dba3c733db12925aa5b897f8e0e7a567a2c274206f184da676ea2e65 + languageName: node + linkType: hard + "espree@npm:^9.6.0, espree@npm:^9.6.1": version: 9.6.1 resolution: "espree@npm:9.6.1" @@ -5215,6 +5289,16 @@ __metadata: languageName: node linkType: hard +"event-emitter@npm:^0.3.5": + version: 0.3.5 + resolution: "event-emitter@npm:0.3.5" + dependencies: + d: "npm:1" + es5-ext: "npm:~0.10.14" + checksum: 10c0/75082fa8ffb3929766d0f0a063bfd6046bd2a80bea2666ebaa0cfd6f4a9116be6647c15667bea77222afc12f5b4071b68d393cf39fdaa0e8e81eda006160aff0 + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.0": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -5285,6 +5369,15 @@ __metadata: languageName: node linkType: hard +"ext@npm:^1.7.0": + version: 1.7.0 + resolution: "ext@npm:1.7.0" + dependencies: + type: "npm:^2.7.2" + checksum: 10c0/a8e5f34e12214e9eee3a4af3b5c9d05ba048f28996450975b369fc86e5d0ef13b6df0615f892f5396a9c65d616213c25ec5b0ad17ef42eac4a500512a19da6c7 + languageName: node + linkType: hard + "extend-shallow@npm:^2.0.1": version: 2.0.1 resolution: "extend-shallow@npm:2.0.1" @@ -6557,6 +6650,13 @@ __metadata: languageName: node linkType: hard +"is-typedarray@npm:^1.0.0": + version: 1.0.0 + resolution: "is-typedarray@npm:1.0.0" + checksum: 10c0/4c096275ba041a17a13cca33ac21c16bc4fd2d7d7eb94525e7cd2c2f2c1a3ab956e37622290642501ff4310601e413b675cf399ad6db49855527d2163b3eeeec + languageName: node + linkType: hard + "is-unicode-supported@npm:^0.1.0": version: 0.1.0 resolution: "is-unicode-supported@npm:0.1.0" @@ -6886,6 +6986,7 @@ __metadata: "@types/node": "npm:^18.19.34" "@types/react": "npm:^18.3.3" "@types/react-dom": "npm:^18.3.0" + "@types/websocket": "npm:^1.0.10" "@typescript-eslint/eslint-plugin": "npm:^5.62.0" "@typescript-eslint/parser": "npm:^7.12.0" "@vitejs/plugin-react": "npm:^4.3.0" @@ -6917,6 +7018,7 @@ __metadata: reactflow: "npm:^11.11.3" typescript: "npm:^4.9.5" vite: "npm:^5.2.12" + websocket: "npm:^1.0.35" languageName: unknown linkType: soft @@ -7631,6 +7733,13 @@ __metadata: languageName: node linkType: hard +"next-tick@npm:^1.1.0": + version: 1.1.0 + resolution: "next-tick@npm:1.1.0" + checksum: 10c0/3ba80dd805fcb336b4f52e010992f3e6175869c8d88bf4ff0a81d5d66e6049f89993463b28211613e58a6b7fe93ff5ccbba0da18d4fa574b96289e8f0b577f28 + languageName: node + linkType: hard + "node-dir@npm:^0.1.17": version: 0.1.17 resolution: "node-dir@npm:0.1.17" @@ -7673,6 +7782,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.3.0": + version: 4.8.1 + resolution: "node-gyp-build@npm:4.8.1" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 10c0/e36ca3d2adf2b9cca316695d7687207c19ac6ed326d6d7c68d7112cebe0de4f82d6733dff139132539fcc01cf5761f6c9082a21864ab9172edf84282bc849ce7 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 10.0.1 resolution: "node-gyp@npm:10.0.1" @@ -9770,6 +9890,13 @@ __metadata: languageName: node linkType: hard +"type@npm:^2.7.2": + version: 2.7.3 + resolution: "type@npm:2.7.3" + checksum: 10c0/dec6902c2c42fcb86e3adf8cdabdf80e5ef9de280872b5fd547351e9cca2fe58dd2aa6d2547626ddff174145db272f62d95c7aa7038e27c11315657d781a688d + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.2": version: 1.0.2 resolution: "typed-array-buffer@npm:1.0.2" @@ -9822,6 +9949,15 @@ __metadata: languageName: node linkType: hard +"typedarray-to-buffer@npm:^3.1.5": + version: 3.1.5 + resolution: "typedarray-to-buffer@npm:3.1.5" + dependencies: + is-typedarray: "npm:^1.0.0" + checksum: 10c0/4ac5b7a93d604edabf3ac58d3a2f7e07487e9f6e98195a080e81dbffdc4127817f470f219d794a843b87052cedef102b53ac9b539855380b8c2172054b7d5027 + languageName: node + linkType: hard + "typescript@npm:^4.9.5": version: 4.9.5 resolution: "typescript@npm:4.9.5" @@ -10022,6 +10158,16 @@ __metadata: languageName: node linkType: hard +"utf-8-validate@npm:^5.0.2": + version: 5.0.10 + resolution: "utf-8-validate@npm:5.0.10" + dependencies: + node-gyp: "npm:latest" + node-gyp-build: "npm:^4.3.0" + checksum: 10c0/23cd6adc29e6901aa37ff97ce4b81be9238d0023c5e217515b34792f3c3edb01470c3bd6b264096dd73d0b01a1690b57468de3a24167dd83004ff71c51cc025f + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -10185,6 +10331,20 @@ __metadata: languageName: node linkType: hard +"websocket@npm:^1.0.35": + version: 1.0.35 + resolution: "websocket@npm:1.0.35" + dependencies: + bufferutil: "npm:^4.0.1" + debug: "npm:^2.2.0" + es5-ext: "npm:^0.10.63" + typedarray-to-buffer: "npm:^3.1.5" + utf-8-validate: "npm:^5.0.2" + yaeti: "npm:^0.0.6" + checksum: 10c0/8be9a68dc0228f18058c9010d1308479f05050af8f6d68b9dbc6baebd9ab484c15a24b2521a5d742a9d78e62ee19194c532992f1047a9b9adf8c3eedb0b1fcdc + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" @@ -10342,6 +10502,13 @@ __metadata: languageName: node linkType: hard +"yaeti@npm:^0.0.6": + version: 0.0.6 + resolution: "yaeti@npm:0.0.6" + checksum: 10c0/4e88702d8b34d7b61c1c4ec674422b835d453b8f8a6232be41e59fc98bc4d9ab6d5abd2da55bab75dfc07ae897fdc0c541f856ce3ab3b17de1630db6161aa3f6 + languageName: node + linkType: hard + "yallist@npm:^3.0.2": version: 3.1.1 resolution: "yallist@npm:3.1.1" diff --git a/files.release.go b/files.release.go index 5aa29ae..5b58e3a 100644 --- a/files.release.go +++ b/files.release.go @@ -3,8 +3,9 @@ package web import ( - "github.com/launchrctl/web/server" "io/fs" + + "github.com/launchrctl/web/server" ) func prepareRunOption(p *Plugin, opts *server.RunOptions) { diff --git a/go.mod b/go.mod index ffe08ba..0269e1c 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/go-chi/chi/v5 v5.0.11 github.com/go-chi/cors v1.2.1 github.com/go-chi/render v1.0.3 - github.com/launchrctl/launchr v0.9.1 + github.com/launchrctl/launchr v0.13.0 github.com/oapi-codegen/nethttp-middleware v1.0.1 github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/cobra v1.8.0 @@ -34,9 +34,13 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/swag v0.22.9 // indirect + github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee // indirect + github.com/gobwas/pool v0.2.0 // indirect + github.com/gobwas/ws v1.0.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.2.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -55,6 +59,7 @@ require ( github.com/otiai10/copy v1.14.0 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pkgz/websocket v1.2.10 // indirect github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 5ddda2f..1352330 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,7 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/getkin/kin-openapi v0.123.0 h1:zIik0mRwFNLyvtXK274Q6ut+dPh6nlxBp0x7mNrPhs8= github.com/getkin/kin-openapi v0.123.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= +github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= @@ -57,6 +58,12 @@ github.com/go-openapi/swag v0.22.9 h1:XX2DssF+mQKM2DHsbgZK74y/zj4mo9I99+89xUmuZC github.com/go-openapi/swag v0.22.9/go.mod h1:3/OXnFfnMAwBD099SwYRk7GD3xOrr1iL7d/XNLXVVwE= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.0 h1:1WdyfgUcImUfVBvYbsW2krIsnko+1QU2t45soaF8v1M= +github.com/gobwas/ws v1.0.0/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= @@ -67,6 +74,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -86,6 +95,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/launchrctl/launchr v0.9.1 h1:sbIdgXvUpX6om8p83eVHfbffyh6NXkUDx1rURUxlN7g= github.com/launchrctl/launchr v0.9.1/go.mod h1:zgik46S8lFCqjdrw/BXek/hQUmvXLh/ULY7gm/vPYRc= +github.com/launchrctl/launchr v0.13.0 h1:i59AcDe4PUytVDKwpe7Y6AANv2N6Ie/zq2xba4zhU7w= +github.com/launchrctl/launchr v0.13.0/go.mod h1:zgik46S8lFCqjdrw/BXek/hQUmvXLh/ULY7gm/vPYRc= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/moby/moby v25.0.3+incompatible h1:Uzxm7JQOHBY8kZY2fa95a9kg0aTOt1cBidSZ+LXCxC4= @@ -120,6 +131,8 @@ github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkgz/websocket v1.2.10 h1:rmhfFPWIzOXEH1PgkmmKTsClKQRxdoR7qRYSm4xDa00= +github.com/pkgz/websocket v1.2.10/go.mod h1:d9K3VYbh0KuCRQM8hVUORlr2nFxZrUC1DB2762tLZkk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -135,6 +148,7 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= @@ -172,6 +186,7 @@ golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= diff --git a/plugin.go b/plugin.go index a7b9d00..2d8e1ae 100644 --- a/plugin.go +++ b/plugin.go @@ -3,6 +3,7 @@ package web import ( "fmt" + "github.com/launchrctl/launchr" "github.com/launchrctl/web/server" "github.com/spf13/cobra" diff --git a/server/api.go b/server/api.go index fc24809..49475b9 100644 --- a/server/api.go +++ b/server/api.go @@ -9,6 +9,9 @@ import ( "os" "path/filepath" "sort" + "strconv" + "sync" + "time" "github.com/launchrctl/launchr/pkg/log" @@ -23,6 +26,7 @@ type launchrServer struct { ctx context.Context baseURL string apiPrefix string + wsMutex sync.Mutex } type launchrWebConfig struct { @@ -117,10 +121,8 @@ func (l *launchrServer) GetRunningActionStreams(w http.ResponseWriter, _ *http.R sendError(w, http.StatusInternalServerError, "Error reading streams") } - // @todo: care about error file as well - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(sd[0]) + _ = json.NewEncoder(w).Encode(sd) } func (l *launchrServer) basePath() string { @@ -193,11 +195,7 @@ func (l *launchrServer) GetRunningActionsByID(w http.ResponseWriter, _ *http.Req runningActions := l.actionMngr.RunInfoByAction(id) sort.Slice(runningActions, func(i, j int) bool { - if runningActions[i].Status == runningActions[j].Status { - return runningActions[i].ID < runningActions[j].ID - } - - return runningActions[i].Status < runningActions[j].Status + return runningActions[i].ID < runningActions[j].ID }) var result = make([]ActionRunInfo, 0, len(runningActions)) @@ -226,9 +224,12 @@ func (l *launchrServer) RunAction(w http.ResponseWriter, r *http.Request, id str return } + // Generate custom runId. + runId := strconv.FormatInt(time.Now().Unix(), 10) + "-" + a.ID + // Prepare action for run. // Can we fetch directly json? - streams, err := createFileStreams(id) + streams, err := createFileStreams(runId) if err != nil { log.Debug(err.Error()) sendError(w, http.StatusInternalServerError, "Error preparing streams") @@ -250,7 +251,7 @@ func (l *launchrServer) RunAction(w http.ResponseWriter, r *http.Request, id str return } - ri, chErr := l.actionMngr.RunBackground(l.ctx, a) + ri, chErr := l.actionMngr.RunBackground(l.ctx, a, runId) go func() { // @todo handle error somehow. We cant notify client, but must save the status diff --git a/server/server.go b/server/server.go index d2a872b..5fe6bea 100644 --- a/server/server.go +++ b/server/server.go @@ -5,13 +5,16 @@ package server import ( "context" + "encoding/json" "fmt" "io/fs" + "log" "net/http" "net/http/httputil" "net/url" "os" "path" + "sort" "strings" "time" @@ -19,6 +22,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/cors" "github.com/go-chi/render" + "github.com/gorilla/websocket" middleware "github.com/oapi-codegen/nethttp-middleware" "github.com/launchrctl/launchr" @@ -39,6 +43,8 @@ type RunOptions struct { ProxyClient string } +const asyncTickerTime = 2 + const swaggerUIPath = "/swagger-ui" const swaggerJSONPath = "/swagger.json" @@ -73,6 +79,8 @@ func Run(ctx context.Context, app launchr.App, opts *RunOptions) error { serveSwaggerUI(swagger, r, opts) } + r.HandleFunc("/ws", wsHandler(store)) + // Serve frontend files. r.HandleFunc("/*", spaHandler(opts)) @@ -144,3 +152,189 @@ func serveSwaggerUI(swagger *openapi3.T, r chi.Router, opts *RunOptions) { render.JSON(w, r, &swagger) }) } + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +type Message struct { + Message string `json:"message"` + Action string `json:"action"` +} + +func wsHandler(l *launchrServer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Fatal(err) + } + defer ws.Close() + + for { + _, message, err := ws.ReadMessage() + if err != nil { + log.Println(err) + break + } + + var msg Message + if err := json.Unmarshal(message, &msg); err != nil { + log.Printf("Error unmarshaling message: %v", err) + continue + } + + log.Printf("Received command: %s", msg.Message) + log.Printf("Received params: %v", msg.Action) + + switch msg.Message { + case "get-processes": + go getProcesses(msg, ws, l) + case "get-process": + go getStreams(msg, ws, l) + default: + log.Printf("Unknown command: %s", msg.Message) + } + } + } +} + +func getProcesses(msg Message, ws *websocket.Conn, l *launchrServer) { + ticker := time.NewTicker(asyncTickerTime * time.Second) + defer ticker.Stop() + + // TODO: replace that code with some listener which + // will send messages when action started or finished instead of ticker + + for range ticker.C { + + anyProccessRunning := false + + runningActions := l.actionMngr.RunInfoByAction(msg.Action) + + if len(runningActions) == 0 { + break + } + + sort.Slice(runningActions, func(i, j int) bool { + return runningActions[i].Status < runningActions[j].Status + }) + + responseMessage := map[string]interface{}{ + "message": "send-processes", + "action": msg.Action, + "processes": runningActions, + } + + finalResponse, err := json.Marshal(responseMessage) + if err != nil { + log.Printf("Error marshaling final response: %v", err) + return + } + + l.wsMutex.Lock() + if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil { + log.Println(err) + } + l.wsMutex.Unlock() + + for _, ri := range runningActions { + if ri.Status == "running" { + anyProccessRunning = true + } + } + + completeMessage := map[string]interface{}{ + "channel": "processes", + "message": "send-processes-finished", + "action": msg.Action, + "processes": runningActions, + } + + finalCompleteResponse, err := json.Marshal(completeMessage) + if err != nil { + log.Printf("Error marshaling final response: %v", err) + return + } + + if !anyProccessRunning { + l.wsMutex.Lock() + if err := ws.WriteMessage(websocket.TextMessage, finalCompleteResponse); err != nil { + log.Println(err) + } + l.wsMutex.Unlock() + break + } + } +} + +func getStreams(msg Message, ws *websocket.Conn, l *launchrServer) { + ticker := time.NewTicker(asyncTickerTime * time.Second) + defer ticker.Stop() + + var lastStreamData interface{} + + for range ticker.C { + ri, _ := l.actionMngr.RunInfoByID(msg.Action) + + // Get the streams data + streams := ri.Action.GetInput().IO + fStreams, _ := streams.(fileStreams) + params := GetRunningActionStreamsParams{ + Offset: new(int), + Limit: new(int), + } + *params.Offset = 1 + *params.Limit = 1 + sd, _ := fStreams.GetStreamData(params) + + lastStreamData = sd + + if ri.Status != "running" { + break + } + + // Send the process data + responseMessage := map[string]interface{}{ + "channel": "process", + "message": "send-process", + "action": msg.Action, + "data": sd, + } + + finalResponse, err := json.Marshal(responseMessage) + if err != nil { + log.Printf("Error marshaling response: %v", err) + return + } + + l.wsMutex.Lock() + if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil { + log.Println(err) + } + l.wsMutex.Unlock() + } + + // Send the final message indicating streams have finished with the last stream data + finalMessage := map[string]interface{}{ + "channel": "process", + "message": "send-process-finished", + "action": msg.Action, + "data": lastStreamData, + } + + finalResponse, err := json.Marshal(finalMessage) + if err != nil { + log.Printf("Error marshaling final message: %v", err) + return + } + + l.wsMutex.Lock() + if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil { + log.Println(err) + } + l.wsMutex.Unlock() +} diff --git a/server/streams.go b/server/streams.go index 5a957b0..87a49f2 100644 --- a/server/streams.go +++ b/server/streams.go @@ -92,13 +92,13 @@ func (w *wrappedWriter) Write(p []byte) (int, error) { return w.w.Write(p) } -func createFileStreams(actionId ActionId) (*webCli, error) { - outfile, err := os.Create(fmt.Sprintf("%s-out.txt", actionId)) +func createFileStreams(runId string) (*webCli, error) { + outfile, err := os.Create(fmt.Sprintf("%s-out.txt", runId)) if err != nil { return nil, fmt.Errorf("error creating output file: %w", err) } - errfile, err := os.Create(fmt.Sprintf("%s-err.txt", actionId)) + errfile, err := os.Create(fmt.Sprintf("%s-err.txt", runId)) if err != nil { return nil, fmt.Errorf("error creating error file: %w", err) }