([])
+
+ useSubscription({
+ channel: 'process',
+ types: ['send-process', 'send-process-finished'],
+ onLiveEvent: ({ channel, payload, type }) => {
+ if (channel === 'process' && payload?.data?.action === ri.id) {
+ if (type === 'send-process' && payload?.data?.data) {
+ setStreams(payload.data.data)
+ }
+
+ if (type === 'send-process-finished') {
+ sessionStorage.setItem(ri.id, JSON.stringify(payload.data.data))
+ }
+ }
+ },
+ })
+
+ useEffect(() => {
+ if (ri.status === 'finished' || ri.status === 'error') {
+ const storedData = sessionStorage.getItem(ri.id)
+ if (storedData) {
+ setStreams(JSON.parse(storedData))
+ }
+ }
+ }, [ri.status, ri.id])
+
+ 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/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 = ({
@@ -35,6 +36,7 @@ export const ThemedLayoutV2: FC = ({
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 = ({
{Footer && }
{OffLayoutArea && }
+
)
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(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({
+ appState: { runningActions: [] },
+ addAction: (action: IAction) => {
+ console.warn('addAction function not yet implemented. Action:', action)
+ },
+})
+interface AppProviderProps {
+ children: ReactNode
+}
+
+const AppProvider: FC = ({ children }) => {
+ const [appState, setAppState] = useState({
+ 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 (
+
+ {children}
+
+ )
+}
+
+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 | 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..2e23fa6
--- /dev/null
+++ b/client/src/live-provider/index.ts
@@ -0,0 +1,82 @@
+import { 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 setupWebSocketHandlers = () => {
+ actionsSocket.onopen = handleOpen
+ actionsSocket.onclose = handleClose
+ actionsSocket.onerror = handleError
+}
+
+setupWebSocketHandlers()
+
+export const liveProvider: LiveProvider = {
+ subscribe: async ({ channel, callback }) => {
+ const handleMessage = (e: IMessageEvent) => {
+ const data = JSON.parse(e.data.toString())
+ const event = {
+ channel,
+ type: data.message,
+ payload: { data },
+ date: new Date(),
+ }
+ callback(event)
+ }
+
+ actionsSocket.onmessage = handleMessage
+
+ return () => {
+ // TODO: Add unsubscribe.
+ }
+ },
+ unsubscribe: (unsubscribe) => {
+ unsubscribe.then((f: () => void) => f())
+ },
+ publish: async ({ channel, payload }) => {
+ const message = {
+ message: channel === 'processes' ? 'get-processes' : 'get-streams',
+ ...payload,
+ }
+ actionsSocket.send(JSON.stringify(message))
+ },
+}
diff --git a/client/src/pages/actions/Show.tsx b/client/src/pages/actions/Show.tsx
index 25f15fb..b57de31 100644
--- a/client/src/pages/actions/Show.tsx
+++ b/client/src/pages/actions/Show.tsx
@@ -5,97 +5,110 @@ import {
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({
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
- // e: FormEvent,
- ) => {
- 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) => {
+ 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
+ },
+ })
+
+ 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)
+ }
}
- const onActionRunFinished = async () => {
+ const onActionRunFinished = () => {
setActionRunning(false)
open?.({
type: 'success',
@@ -111,11 +124,7 @@ export const ActionShow: FC = () => {
actionRunning={actionRunning}
onActionRunFinished={onActionRunFinished}
/>
-
+
{jsonschema && (
)}
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..de4dc8f 100644
--- a/go.mod
+++ b/go.mod
@@ -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..9b3c6af 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=
@@ -120,6 +129,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 +146,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 +184,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/server.go b/server/server.go
index d2a872b..7db919a 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 = 3
+
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,176 @@ 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-streams":
+ 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
+ }
+
+ if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil {
+ log.Println(err)
+ }
+
+ for _, ri := range runningActions {
+ if ri.Status == "running" {
+ anyProccessRunning = true
+ }
+ }
+
+ completeMessage := map[string]interface{}{
+ "message": "send-processes-finished",
+ }
+
+ finalCompleteResponse, err := json.Marshal(completeMessage)
+ if err != nil {
+ log.Printf("Error marshaling final response: %v", err)
+ return
+ }
+
+ if !anyProccessRunning {
+ if err := ws.WriteMessage(websocket.TextMessage, finalCompleteResponse); err != nil {
+ log.Println(err)
+ }
+ 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{}{
+ "message": "send-process",
+ "action": msg.Action,
+ "data": sd,
+ }
+
+ finalResponse, err := json.Marshal(responseMessage)
+ if err != nil {
+ log.Printf("Error marshaling response: %v", err)
+ return
+ }
+
+ if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil {
+ log.Println(err)
+ }
+ }
+
+ // Send the final message indicating streams have finished with the last stream data
+ finalMessage := map[string]interface{}{
+ "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
+ }
+
+ if err := ws.WriteMessage(websocket.TextMessage, finalResponse); err != nil {
+ log.Println(err)
+ }
+}