Skip to content

Commit

Permalink
feat(deployment-logs): add new design (#1683)
Browse files Browse the repository at this point in the history
  • Loading branch information
RemiBonnet authored Sep 30, 2024
1 parent 7c08874 commit 0b4e8c6
Show file tree
Hide file tree
Showing 32 changed files with 1,153 additions and 1,125 deletions.
2 changes: 2 additions & 0 deletions libs/domains/service-logs/feature/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from './lib/list-service-logs/list-service-logs'
export * from './lib/update-time-context/update-time-context'
export * from './lib/list-deployment-logs/list-deployment-logs'
export * from './lib/service-stage-ids-context/service-stage-ids-context'
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { applicationFactoryMock } from '@qovery/shared/factories'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { DeploymentLogsPlaceholder, type DeploymentLogsPlaceholderProps } from './deployment-logs-placeholder'

const mockApplication = applicationFactoryMock(1)[0]

jest.mock('@qovery/domains/services/feature', () => ({
useService: () => ({ data: mockApplication }),
}))

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({
organizationId: 'org-123',
projectId: 'proj-123',
environmentId: 'env-123',
serviceId: 'serv-123',
}),
}))

describe('DeploymentLogsPlaceholder', () => {
const props: DeploymentLogsPlaceholderProps = {
serviceStatus: undefined,
itemsLength: 0,
deploymentHistoryEnvironment: [],
}

it('should render deployment history', () => {
renderWithProviders(
<DeploymentLogsPlaceholder
itemsLength={1}
deploymentHistoryEnvironment={[
{
id: 'd941d6fa-d1e9-4389-9059-2c90e51780da-10',
created_at: '2024-09-18T07:02:14.324855Z',
updated_at: '2024-09-18T07:03:29.848720Z',
status: 'DEPLOYMENT_ERROR',
applications: [],
databases: [],
containers: [],
jobs: [
{
id: 'serv-123',
name: 'my-name',
created_at: '2024-09-18T07:02:14.356872Z',
updated_at: '2024-09-18T07:03:29.819774Z',
},
],
helms: [],
},
]}
/>
)

expect(screen.getByText('Last deployment logs')).toBeInTheDocument()
expect(screen.getByText('d941d...-10')).toBeInTheDocument()
})

it('should render "No history deployment available"', () => {
props.itemsLength = 1
renderWithProviders(<DeploymentLogsPlaceholder {...props} />)

expect(screen.getByText('No history deployment available for this service.')).toBeInTheDocument()
})

it('should render spinner', () => {
renderWithProviders(
<DeploymentLogsPlaceholder
itemsLength={0}
serviceStatus={{
id: '0',
state: 'DEPLOYED',
service_deployment_status: 'UP_TO_DATE',
is_part_last_deployment: true,
}}
/>
)

expect(screen.getByTestId('spinner')).toBeInTheDocument()
})

it('should render no logs placeholder', () => {
renderWithProviders(
<DeploymentLogsPlaceholder
itemsLength={0}
deploymentHistoryEnvironment={[]}
serviceStatus={{
id: '0',
state: 'DEPLOYED',
service_deployment_status: 'UP_TO_DATE',
is_part_last_deployment: false,
}}
/>
)

expect(
screen.getByText('This service was deployed more than 30 days ago and thus no deployment logs are available.')
).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { type DeploymentHistoryEnvironment, ServiceDeploymentStatusEnum, type Status } from 'qovery-typescript-axios'
import { Link, useParams } from 'react-router-dom'
import { match } from 'ts-pattern'
import { useService } from '@qovery/domains/services/feature'
import { type DeploymentService } from '@qovery/shared/interfaces'
import { DEPLOYMENT_LOGS_VERSION_URL, ENVIRONMENT_LOGS_URL } from '@qovery/shared/routes'
import { StatusChip } from '@qovery/shared/ui'
import { dateFullFormat } from '@qovery/shared/util-dates'
import { mergeDeploymentServices, trimId } from '@qovery/shared/util-js'
import { LoaderPlaceholder } from '../service-logs-placeholder/service-logs-placeholder'

function DeploymentHistoryPlaceholder({
serviceName,
deploymentsByServiceId,
}: {
serviceName: string
deploymentsByServiceId: DeploymentService[]
}) {
const { organizationId = '', projectId = '', environmentId = '', versionId = '' } = useParams()

return (
<div className="flex flex-col items-center text-center">
<div>
<p className="font-text-neutral-50 mb-1 font-medium text-neutral-50">
<span className="text-brand-400">{serviceName}</span> service was not deployed within this deployment
execution.
</p>
<p className="mb-10 text-sm font-normal text-neutral-300">
Below the list of executions where this service was deployed.
</p>
</div>
<div className="w-[484px] overflow-hidden rounded-lg border border-neutral-500 bg-neutral-700">
<div className="border-b border-neutral-500 bg-neutral-600 py-3 font-medium text-neutral-50">
Last deployment logs
</div>
<div className="max-h-96 overflow-y-auto p-2">
{deploymentsByServiceId.length > 0 ? (
deploymentsByServiceId.map((deploymentHistory: DeploymentService) => (
<div key={deploymentHistory.execution_id} className="flex items-center pb-2 last:pb-0">
<Link
className={`flex w-full justify-between rounded bg-neutral-550 p-3 transition hover:bg-neutral-600 ${
versionId === deploymentHistory.execution_id ? 'bg-neutral-600' : ''
}`}
to={
ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) +
DEPLOYMENT_LOGS_VERSION_URL(deploymentHistory.id, deploymentHistory.execution_id)
}
>
<span className="flex">
<StatusChip className="relative top-[2px] mr-3" status={deploymentHistory.status} />
<span className="text-ssm text-brand-300">{trimId(deploymentHistory.execution_id || '')}</span>
</span>
<span className="text-ssm text-neutral-300">{dateFullFormat(deploymentHistory.created_at)}</span>
</Link>
</div>
))
) : (
<p className="text-sm text-neutral-50">No history deployment available for this service.</p>
)}
</div>
<div className="flex h-9 items-center justify-center border-t border-neutral-500 bg-neutral-600">
<p className="text-xs font-normal text-neutral-350">
Only the last 20 deployments of the environment over the last 30 days are available.
</p>
</div>
</div>
</div>
)
}

export interface DeploymentLogsPlaceholderProps {
serviceStatus?: Status
itemsLength?: number
deploymentHistoryEnvironment?: DeploymentHistoryEnvironment[]
}

export function DeploymentLogsPlaceholder({
serviceStatus,
itemsLength,
deploymentHistoryEnvironment,
}: DeploymentLogsPlaceholderProps) {
const { environmentId = '', serviceId = '' } = useParams()

const { service_deployment_status: serviceDeploymentStatus, is_part_last_deployment: isPartLastDeployment } =
serviceStatus || {}

const { data: service } = useService({ environmentId, serviceId })
const hideLogs = !isPartLastDeployment

const outOfDateOrUpToDate =
serviceDeploymentStatus === ServiceDeploymentStatusEnum.NEVER_DEPLOYED ||
serviceDeploymentStatus === ServiceDeploymentStatusEnum.UP_TO_DATE

const displaySpinner = match({ itemsLength, hideLogs, serviceDeploymentStatus, service })
.with(
{
itemsLength: 0,
hideLogs: false,
serviceDeploymentStatus: undefined,
service: undefined,
},
() => true
)
.otherwise(() => false)

const deploymentsByServiceId = mergeDeploymentServices(deploymentHistoryEnvironment).filter(
(deploymentHistory) => deploymentHistory.id === serviceId
)

if (displaySpinner) {
return <LoaderPlaceholder />
}

if (hideLogs && service) {
if (deploymentsByServiceId.length === 0 && outOfDateOrUpToDate) {
return (
<div className="flex flex-col items-center">
<p className="mb-1 font-medium text-neutral-50">
No logs on this execution for <span className="text-brand-400">{service.name}</span>.
</p>
{serviceDeploymentStatus !== ServiceDeploymentStatusEnum.NEVER_DEPLOYED && (
<p className="text-sm font-normal text-neutral-300">
This service was deployed more than 30 days ago and thus no deployment logs are available.
</p>
)}
</div>
)
}

return <DeploymentHistoryPlaceholder serviceName={service.name} deploymentsByServiceId={deploymentsByServiceId} />
}

return <LoaderPlaceholder />
}

export default DeploymentLogsPlaceholder
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { type QueryClient } from '@tanstack/react-query'
import { type EnvironmentLogs } from 'qovery-typescript-axios'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useEnvironment } from '@qovery/domains/environments/feature'
import { QOVERY_WS } from '@qovery/shared/util-node-env'
import { useReactQueryWsSubscription } from '@qovery/state/util-queries'
import { ServiceStageIdsContext } from '../../service-stage-ids-context/service-stage-ids-context'

export interface UseDeploymentLogsProps {
organizationId?: string
projectId?: string
environmentId?: string
serviceId?: string
versionId?: string
}

const CHUNK_SIZE = 500

// This hook simplifies the process of fetching and managing deployment logs data
export function useDeploymentLogs({
organizationId,
projectId,
environmentId,
serviceId,
versionId,
}: UseDeploymentLogsProps) {
const { data: environment } = useEnvironment({ environmentId })

// States for controlling log actions, showing new, previous or paused logs
const [newMessagesAvailable, setNewMessagesAvailable] = useState(false)
const [showPreviousLogs, setShowPreviousLogs] = useState(false)
const [pauseLogs, setPauseLogs] = useState(false)
const [debounceTime, setDebounceTime] = useState(1000)

const [logs, setLogs] = useState<EnvironmentLogs[]>([])
const [messageChunks, setMessageChunks] = useState<EnvironmentLogs[][]>([])

const { stageId } = useContext(ServiceStageIdsContext)
const now = useMemo(() => Date.now(), [])

const messageHandler = useCallback(
(_: QueryClient, message: EnvironmentLogs[]) => {
setNewMessagesAvailable(true)
setMessageChunks((prevChunks) => {
const lastChunk = prevChunks[prevChunks.length - 1] || []
if (lastChunk.length < CHUNK_SIZE) {
return [...prevChunks.slice(0, -1), [...lastChunk, ...message]]
} else {
return [...prevChunks, [...message]]
}
})
},
[setMessageChunks]
)

useReactQueryWsSubscription({
url: QOVERY_WS + '/deployment/logs',
urlSearchParams: {
organization: organizationId,
cluster: environment?.cluster_id,
project: projectId,
environment: environmentId,
version: versionId,
},
enabled:
Boolean(organizationId) && Boolean(environment?.cluster_id) && Boolean(projectId) && Boolean(environmentId),
onMessage: messageHandler,
})

useEffect(() => {
if (messageChunks.length === 0 || pauseLogs) return

const timerId = setTimeout(() => {
if (!pauseLogs) {
setMessageChunks((prevChunks) => prevChunks.slice(1))
setLogs((prevLogs) => {
const combinedLogs = [...prevLogs, ...messageChunks[0]]
return [...new Map(combinedLogs.map((item) => [item['timestamp'], item])).values()]
})

if (logs.length > 1000) {
setDebounceTime(100)
}
}
}, debounceTime)

return () => {
clearTimeout(timerId)
}
}, [messageChunks, pauseLogs])

// Filter deployment logs by serviceId and stageId
// Display entries when the name is "delete" or stageId is empty or equal with current stageId
// Filter by the same transmitter ID and "Environment" or "TaskManager" type
const logsByServiceId = useMemo(
() =>
logs
.filter((currentData: EnvironmentLogs) => {
const { stage, transmitter } = currentData.details
const isDeleteStage = stage?.name === 'delete'
const isEmptyOrEqualStageId = !stage?.id || stage?.id === stageId
const isMatchingTransmitter =
transmitter?.type === 'Environment' || transmitter?.type === 'TaskManager' || transmitter?.id === serviceId

// Include the entry if any of the following conditions are true:
// 1. The stage name is "delete".
// 2. stageId is empty or equal with current stageId.
// 3. The transmitter matches serviceId and has a type of "Environment" or "TaskManager".
return (isDeleteStage || isEmptyOrEqualStageId) && isMatchingTransmitter
})
.filter((log, index, array) => (showPreviousLogs || index >= array.length - 500 ? true : +log.timestamp > now)),
[logs, stageId, serviceId, now, showPreviousLogs]
)

return {
data: logsByServiceId,
setNewMessagesAvailable,
newMessagesAvailable,
pauseLogs,
setPauseLogs,
showPreviousLogs,
setShowPreviousLogs,
}
}

export default useDeploymentLogs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function useServiceLogs({
return () => {
clearTimeout(handler)
}
}, [serviceMessagesMap.current.size, DEBOUNCE_TIME])
}, [serviceMessagesMap.current.size])
const now = useMemo(() => Date.now(), [])

const infraMessageHandler = useCallback(
Expand Down
Loading

0 comments on commit 0b4e8c6

Please sign in to comment.