diff --git a/web/src/framework/strings/stringTypes.ts b/web/src/framework/strings/stringTypes.ts index d16e6f61c..f7eb80d4d 100644 --- a/web/src/framework/strings/stringTypes.ts +++ b/web/src/framework/strings/stringTypes.ts @@ -171,9 +171,12 @@ export interface StringsMap { error404Text: string 'executions.completedTime': string 'executions.description': string + 'executions.failed': string 'executions.name': string + 'executions.newExecution': string 'executions.newExecutionButton': string 'executions.noData': string + 'executions.started': string 'executions.time': string executor: string existingAccount: string diff --git a/web/src/hooks/usePipelineEventStream.tsx b/web/src/hooks/usePipelineEventStream.tsx new file mode 100644 index 000000000..6e36d088d --- /dev/null +++ b/web/src/hooks/usePipelineEventStream.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react' + +type UsePipelineEventStreamProps = { + space: string + onEvent: (data: any) => void + onError?: (event: Event) => void + shouldRun?: boolean +} + +const usePipelineEventStream = ({ space, onEvent, onError, shouldRun = true }: UsePipelineEventStreamProps) => { + //TODO - this is not working right - need to get to the bottom of too many streams being opened and closed... can miss events! + const eventSourceRef = useRef(null) + + useEffect(() => { + // Conditionally establish the event stream - don't want to open on a finished execution + if (shouldRun) { + if (!eventSourceRef.current) { + eventSourceRef.current = new EventSource(`/api/v1/spaces/${space}/stream`) + + const handleMessage = (event: MessageEvent) => { + const data = JSON.parse(event.data) + onEvent(data) + } + + const handleError = (event: Event) => { + if (onError) onError(event) + eventSourceRef?.current?.close() + } + + eventSourceRef.current.addEventListener('message', handleMessage) + eventSourceRef.current.addEventListener('error', handleError) + + return () => { + eventSourceRef.current?.removeEventListener('message', handleMessage) + eventSourceRef.current?.removeEventListener('error', handleError) + eventSourceRef.current?.close() + eventSourceRef.current = null + } + } + } else { + // If shouldRun is false, close and cleanup any existing stream + if (eventSourceRef.current) { + eventSourceRef.current.close() + eventSourceRef.current = null + } + } + }, [space, shouldRun, onEvent, onError]) +} + +export default usePipelineEventStream diff --git a/web/src/i18n/strings.en.yaml b/web/src/i18n/strings.en.yaml index 4f0c5d966..e8a14d5c4 100644 --- a/web/src/i18n/strings.en.yaml +++ b/web/src/i18n/strings.en.yaml @@ -633,6 +633,9 @@ executions: description: Description time: Time completedTime: completed {{timeString}} ago + started: Execution started + failed: Failed to start build + newExecution: Run Pipeline selectRange: Shift-click to select a range allCommits: All Commits secrets: diff --git a/web/src/pages/Execution/Execution.tsx b/web/src/pages/Execution/Execution.tsx index 3c069a0c0..e762a058a 100644 --- a/web/src/pages/Execution/Execution.tsx +++ b/web/src/pages/Execution/Execution.tsx @@ -1,5 +1,5 @@ import { Container, PageBody } from '@harnessio/uicore' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import cx from 'classnames' import { useParams } from 'react-router-dom' import { useGet } from 'restful-react' @@ -13,6 +13,7 @@ import { useStrings } from 'framework/strings' import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner' import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata' import { ExecutionPageHeader } from 'components/ExecutionPageHeader/ExecutionPageHeader' +import usePipelineEventStream from 'hooks/usePipelineEventStream' import noExecutionImage from '../RepositoriesListing/no-repo.svg' import css from './Execution.module.scss' @@ -20,18 +21,44 @@ const Execution = () => { const { pipeline, execution: executionNum } = useParams() const { getString } = useStrings() - const { repoMetadata, error, loading, refetch } = useGetRepositoryMetadata() + const { repoMetadata, error, loading, refetch, space } = useGetRepositoryMetadata() const { data: execution, error: executionError, - loading: executionLoading + loading: executionLoading, + refetch: executionRefetch } = useGet({ path: `/api/v1/repos/${repoMetadata?.path}/+/pipelines/${pipeline}/executions/${executionNum}`, lazy: !repoMetadata }) + //TODO remove null type here? const [selectedStage, setSelectedStage] = useState(1) + //TODO - do not want to show load between refetchs - remove if/when we move to event stream method + const [isInitialLoad, setIsInitialLoad] = useState(true) + + useEffect(() => { + if (execution) { + setIsInitialLoad(false) + } + }, [execution]) + + usePipelineEventStream({ + space, + onEvent: (data: any) => { + if ( + (data.type === 'execution_updated' || data.type === 'execution_completed') && + data.data?.repo_id === execution?.repo_id && + data.data?.pipeline_id === execution?.pipeline_id && + data.data?.number === execution?.number + ) { + //TODO - revisit full refresh - can I use the message to update the execution? + executionRefetch() + } + }, + shouldRun: execution?.status === 'running' + }) return ( @@ -72,7 +99,7 @@ const Execution = () => { message: getString('executions.noData') // button: NewExecutionButton }}> - + {execution && ( { const [page, setPage] = usePageIndex(pageInit) const { showError, showSuccess } = useToaster() - const { repoMetadata, error, loading, refetch } = useGetRepositoryMetadata() + const { repoMetadata, error, loading, refetch, space } = useGetRepositoryMetadata() const { data: executions, error: executionsError, - loading: executionsLoading, response, refetch: executionsRefetch } = useGet({ @@ -60,19 +60,40 @@ const ExecutionList = () => { lazy: !repoMetadata }) - //TODO - this should NOT be hardcoded to master branch - need a modal to insert branch + //TODO - do not want to show load between refetchs - remove if/when we move to event stream method + const [isInitialLoad, setIsInitialLoad] = useState(true) + + useEffect(() => { + if (executions) { + setIsInitialLoad(false) + } + }, [executions]) + + usePipelineEventStream({ + space, + onEvent: (data: any) => { + // ideally this would include number - so we only check for executions on the page - but what if new executions are kicked off? - could check for ids that are higher than the lowest id on the page? + if ( + executions?.some( + execution => execution.repo_id === data.data?.repo_id && execution.pipeline_id === data.data?.pipeline_id + ) + ) { + //TODO - revisit full refresh - can I use the message to update the execution? + executionsRefetch() + } + } + }) + const { mutate, loading: mutateLoading } = useMutate({ verb: 'POST', - path: `/api/v1/repos/${repoMetadata?.path}/+/pipelines/${pipeline}/executions`, - queryParams: { branch: 'master' } + path: `/api/v1/repos/${repoMetadata?.path}/+/pipelines/${pipeline}/executions` }) const handleClick = async () => { try { - //TODO - really this should be handled by the event bus - await mutate(null) + //TODO - this should NOT be hardcoded to master branch - need a modal to insert branch - but useful for testing until then + await mutate({ branch: 'master' }) showSuccess('Build started') - executionsRefetch() } catch { showError('Failed to start build') } @@ -140,7 +161,7 @@ const ExecutionList = () => { - {timeDistance(record.created, Date.now())} ago + {timeDistance(record.started, Date.now())} ago @@ -177,7 +198,7 @@ const ExecutionList = () => { message: getString('executions.noData'), button: NewExecutionButton }}> - + diff --git a/web/src/pages/PipelineList/PipelineList.tsx b/web/src/pages/PipelineList/PipelineList.tsx index 2d6cebd0e..c2e583e8f 100644 --- a/web/src/pages/PipelineList/PipelineList.tsx +++ b/web/src/pages/PipelineList/PipelineList.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Avatar, Button, @@ -34,6 +34,7 @@ import { ExecutionStatus, ExecutionState } from 'components/ExecutionStatus/Exec import { getStatus } from 'utils/PipelineUtils' import { PipeSeparator } from 'components/PipeSeparator/PipeSeparator' import { useGetSpaceParam } from 'hooks/useGetSpaceParam' +import usePipelineEventStream from 'hooks/usePipelineEventStream' import noPipelineImage from '../RepositoriesListing/no-repo.svg' import css from './PipelineList.module.scss' @@ -52,8 +53,8 @@ const PipelineList = () => { const { data: pipelines, error: pipelinesError, - loading: pipelinesLoading, - response + response, + refetch: pipelinesRefetch } = useGet({ path: `/api/v1/repos/${repoMetadata?.path}/+/pipelines`, queryParams: { page, limit: LIST_FETCHING_LIMIT, query: searchTerm, latest: true }, @@ -61,6 +62,28 @@ const PipelineList = () => { debounce: 500 }) + //TODO - do not want to show load between refetchs - remove if/when we move to event stream method + const [isInitialLoad, setIsInitialLoad] = useState(true) + + useEffect(() => { + if (pipelines) { + setIsInitialLoad(false) + } + }, [pipelines]) + + usePipelineEventStream({ + space, + onEvent: (data: any) => { + // should I include pipeline id here? what if a new pipeline is created? coould check for ids that are higher than the lowest id on the page? + if ( + pipelines?.some(pipeline => pipeline.repo_id === data.data?.repo_id && pipeline.id === data.data?.pipeline_id) + ) { + //TODO - revisit full refresh - can I use the message to update the execution? + pipelinesRefetch() + } + } + }) + const NewPipelineButton = (