diff --git a/libs/domains/service-logs/feature/src/index.ts b/libs/domains/service-logs/feature/src/index.ts index 9d500187475..4932f0aba81 100644 --- a/libs/domains/service-logs/feature/src/index.ts +++ b/libs/domains/service-logs/feature/src/index.ts @@ -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' diff --git a/libs/domains/service-logs/feature/src/lib/deployment-logs-placeholder/deployment-logs-placeholder.spec.tsx b/libs/domains/service-logs/feature/src/lib/deployment-logs-placeholder/deployment-logs-placeholder.spec.tsx new file mode 100644 index 00000000000..fdbed37547b --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/deployment-logs-placeholder/deployment-logs-placeholder.spec.tsx @@ -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( + + ) + + expect(screen.getByText('Last deployment logs')).toBeInTheDocument() + expect(screen.getByText('d941d...-10')).toBeInTheDocument() + }) + + it('should render "No history deployment available"', () => { + props.itemsLength = 1 + renderWithProviders() + + expect(screen.getByText('No history deployment available for this service.')).toBeInTheDocument() + }) + + it('should render spinner', () => { + renderWithProviders( + + ) + + expect(screen.getByTestId('spinner')).toBeInTheDocument() + }) + + it('should render no logs placeholder', () => { + renderWithProviders( + + ) + + expect( + screen.getByText('This service was deployed more than 30 days ago and thus no deployment logs are available.') + ).toBeInTheDocument() + }) +}) diff --git a/libs/domains/service-logs/feature/src/lib/deployment-logs-placeholder/deployment-logs-placeholder.tsx b/libs/domains/service-logs/feature/src/lib/deployment-logs-placeholder/deployment-logs-placeholder.tsx new file mode 100644 index 00000000000..6dce6e27173 --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/deployment-logs-placeholder/deployment-logs-placeholder.tsx @@ -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 ( +
+
+

+ {serviceName} service was not deployed within this deployment + execution. +

+

+ Below the list of executions where this service was deployed. +

+
+
+
+ Last deployment logs +
+
+ {deploymentsByServiceId.length > 0 ? ( + deploymentsByServiceId.map((deploymentHistory: DeploymentService) => ( +
+ + + + {trimId(deploymentHistory.execution_id || '')} + + {dateFullFormat(deploymentHistory.created_at)} + +
+ )) + ) : ( +

No history deployment available for this service.

+ )} +
+
+

+ Only the last 20 deployments of the environment over the last 30 days are available. +

+
+
+
+ ) +} + +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 + } + + if (hideLogs && service) { + if (deploymentsByServiceId.length === 0 && outOfDateOrUpToDate) { + return ( +
+

+ No logs on this execution for {service.name}. +

+ {serviceDeploymentStatus !== ServiceDeploymentStatusEnum.NEVER_DEPLOYED && ( +

+ This service was deployed more than 30 days ago and thus no deployment logs are available. +

+ )} +
+ ) + } + + return + } + + return +} + +export default DeploymentLogsPlaceholder diff --git a/libs/domains/service-logs/feature/src/lib/hooks/use-deployment-logs/use-deployment-logs.ts b/libs/domains/service-logs/feature/src/lib/hooks/use-deployment-logs/use-deployment-logs.ts new file mode 100644 index 00000000000..6ea8921d1dd --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/hooks/use-deployment-logs/use-deployment-logs.ts @@ -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([]) + const [messageChunks, setMessageChunks] = useState([]) + + 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 diff --git a/libs/domains/service-logs/feature/src/lib/hooks/use-service-logs/use-service-logs.ts b/libs/domains/service-logs/feature/src/lib/hooks/use-service-logs/use-service-logs.ts index e4d1accc60f..08fd6cfc66b 100644 --- a/libs/domains/service-logs/feature/src/lib/hooks/use-service-logs/use-service-logs.ts +++ b/libs/domains/service-logs/feature/src/lib/hooks/use-service-logs/use-service-logs.ts @@ -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( diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.spec.tsx new file mode 100644 index 00000000000..ca7dd09bb66 --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.spec.tsx @@ -0,0 +1,74 @@ +import { StateEnum, type Status } from 'qovery-typescript-axios' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { FiltersStageStep, type FiltersStageStepProps } from './filters-stage-step' + +const mockToggleColumnFilter = jest.fn() +const mockIsFilterActive = jest.fn() + +const defaultProps: FiltersStageStepProps = { + serviceStatus: { + state: StateEnum.BUILDING, + steps: { + details: [ + { step_name: 'BUILD', status: 'SUCCESS', duration_sec: 60 }, + { step_name: 'DEPLOYMENT', status: 'SUCCESS', duration_sec: 120 }, + ], + }, + } as Status, + toggleColumnFilter: mockToggleColumnFilter, + isFilterActive: mockIsFilterActive, +} + +describe('FiltersStageStep', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('renders BUILD and DEPLOY buttons', () => { + renderWithProviders() + expect(screen.getByText('Build')).toBeInTheDocument() + expect(screen.getByText('Deploy')).toBeInTheDocument() + }) + + it('calls toggleColumnFilter with correct type when buttons are clicked', async () => { + const { userEvent } = renderWithProviders() + await userEvent.click(screen.getByText('Build')) + expect(mockToggleColumnFilter).toHaveBeenCalledWith('BUILD') + await userEvent.click(screen.getByText('Deploy')) + expect(mockToggleColumnFilter).toHaveBeenCalledWith('DEPLOY') + }) + + it('displays correct duration for build and deploy steps', () => { + renderWithProviders() + expect(screen.getByText('1m : 0s')).toBeInTheDocument() // BUILD duration + expect(screen.getByText('2m : 0s')).toBeInTheDocument() // DEPLOY duration + }) + + it('applies correct classes based on status and isFilterActive', () => { + mockIsFilterActive.mockImplementation((type) => type === 'BUILD') + renderWithProviders() + + const buildButton = screen.getByText('Build').closest('button') + const deployButton = screen.getByText('Deploy').closest('button') + + expect(buildButton).toHaveClass('border-brand-500', 'bg-neutral-500') + expect(deployButton).not.toHaveClass('border-neutral-300', 'bg-neutral-500') + }) + + it('handles different states correctly', () => { + const props = { + ...defaultProps, + serviceStatus: { + ...defaultProps.serviceStatus, + state: StateEnum.BUILDING, + }, + } + renderWithProviders() + + const buildButton = screen.getByText('Build').closest('button') + const deployButton = screen.getByText('Deploy').closest('button') + + expect(buildButton).toHaveClass('border-neutral-500', 'bg-neutral-650') + expect(deployButton).not.toHaveClass('border-brand-500', 'bg-neutral-500') + }) +}) diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.tsx new file mode 100644 index 00000000000..02038b814e4 --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/filters-stage-step/filters-stage-step.tsx @@ -0,0 +1,144 @@ +import clsx from 'clsx' +import { type ServiceStepMetric, type StateEnum, type Status } from 'qovery-typescript-axios' +import { type ServiceType } from 'qovery-ws-typescript-axios' +import { useEffect, useState } from 'react' +import { match } from 'ts-pattern' +import { StatusChip } from '@qovery/shared/ui' +import { twMerge, upperCaseFirstLetter } from '@qovery/shared/util-js' +import { type FilterType } from '../list-deployment-logs' + +type StepMetricType = { build: ServiceStepMetric[]; deploy: ServiceStepMetric[] } + +interface StageStepProps { + type: Extract + state: StateEnum + steps: ServiceStepMetric[] + toggleColumnFilter: (type: FilterType) => void + isFilterActive: (type: FilterType) => boolean +} + +function StageStep({ type, state, steps, toggleColumnFilter, isFilterActive }: StageStepProps) { + const totalDurationSec = steps.reduce((acc, step) => acc + (step.duration_sec || 0), 0) + + const buildStep = steps.find((s) => s.step_name === 'BUILD') + const deployStep = steps.find((s) => s.step_name === 'DEPLOYMENT') + + const status = match({ type, state, buildStep, deployStep }) + .with({ type: 'BUILD' }, () => { + if (state === 'BUILDING') return 'BUILDING' + return buildStep?.status + }) + .with({ type: 'DEPLOY' }, () => { + if (state === 'BUILDING') return 'READY' + if (state === 'DEPLOYING') return 'DEPLOYING' + return deployStep?.status + }) + .exhaustive() + + const [isFirstLoad, setIsFirstLoad] = useState(true) + useEffect(() => { + if (isFirstLoad) { + if (status === 'ERROR') { + toggleColumnFilter(type) + } + setIsFirstLoad(false) + } else if (status === 'ERROR') { + // Only toggle if status is 'ERROR' + toggleColumnFilter(type) + } + // On the first load, if status is 'ERROR', the column filter is toggled + // For all subsequent renders, the column filter is toggled only if the status is 'ERROR' + }, [status, toggleColumnFilter, isFirstLoad]) + + const isBuildingOrDeploying = + (type === 'BUILD' && status === 'BUILDING') || (type === 'DEPLOY' && status === 'DEPLOYING') + + const buttonClasses = clsx( + 'flex items-center gap-1.5 rounded-lg border border-neutral-500 bg-neutral-650 px-2.5 py-1 text-sm font-medium text-neutral-300 transition hover:border-neutral-300 hover:bg-neutral-500', + { + 'text-white hover:border-green-500': status === 'SUCCESS', + 'text-white hover:border-red-500': status === 'ERROR', + 'text-white hover:border-brand-500': isBuildingOrDeploying, + 'border-brand-500 bg-neutral-500': isBuildingOrDeploying && isFilterActive(type), + 'border-green-500': status === 'SUCCESS' && isFilterActive(type), + 'border-red-500 bg-neutral-500': status === 'ERROR' && isFilterActive(type), + } + ) + + return ( + + ) +} + +export interface FiltersStageStepProps { + serviceStatus: Status + toggleColumnFilter: (type: FilterType) => void + isFilterActive: (type: FilterType) => boolean + serviceType?: ServiceType +} + +export function FiltersStageStep({ + serviceStatus: { steps, state }, + toggleColumnFilter, + isFilterActive, + serviceType, +}: FiltersStageStepProps) { + if (!steps?.details) return
+ + const categorizedSteps = steps.details.reduce( + (acc, step) => { + if (!step.step_name) return acc + + match(step.step_name) + .with('BUILD', 'BUILD_QUEUEING', 'GIT_CLONE', 'REGISTRY_CREATE_REPOSITORY', () => acc.build.push(step)) + .with('DEPLOYMENT', 'DEPLOYMENT_QUEUEING', 'ROUTER_DEPLOYMENT', 'MIRROR_IMAGE', () => acc.deploy.push(step)) + .exhaustive() + + return acc + }, + { build: [], deploy: [] } as StepMetricType + ) + + return ( +
+ {serviceType !== 'CONTAINER' && ( + <> + + + + + + + + )} + +
+ ) +} + +export default FiltersStageStep diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/list-deployment-logs.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/list-deployment-logs.spec.tsx new file mode 100644 index 00000000000..764d91718a0 --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/list-deployment-logs.spec.tsx @@ -0,0 +1,166 @@ +import { type Status } from 'qovery-typescript-axios' +import { useDeploymentStatus } from '@qovery/domains/services/feature' +import { useServiceType } from '@qovery/domains/services/feature' +import { environmentFactoryMock } from '@qovery/shared/factories' +import { renderWithProviders, screen, waitFor } from '@qovery/shared/util-tests' +import { useDeploymentLogs } from '../hooks/use-deployment-logs/use-deployment-logs' +import { ListDeploymentLogs } from './list-deployment-logs' + +window.HTMLElement.prototype.scroll = jest.fn() + +jest.mock('../hooks/use-deployment-logs/use-deployment-logs') +jest.mock('@qovery/domains/services/feature') + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + organizationId: '0', + projectId: '1', + environmentId: '2', + serviceId: '3', + versionId: '4', + }), +})) + +describe('ListDeploymentLogs', () => { + const mockEnvironment = environmentFactoryMock(1)[0] + + const mockDeploymentHistoryEnvironment = [{ id: '4', created_at: '2023-01-01T00:00:00Z' }] + + const mockServiceStatus: Status = { + id: '111', + state: 'DELETE_ERROR', + service_deployment_status: 'UP_TO_DATE', + last_deployment_date: '2024-09-18T07:03:29.819774Z', + is_part_last_deployment: true, + steps: { + total_duration_sec: 69, + total_computing_duration_sec: 64, + details: [ + { + step_name: 'BUILD_QUEUEING', + status: 'SUCCESS', + duration_sec: 0, + }, + { + step_name: 'REGISTRY_CREATE_REPOSITORY', + status: 'SUCCESS', + duration_sec: 10, + }, + { + step_name: 'GIT_CLONE', + status: 'SUCCESS', + duration_sec: 1, + }, + { + step_name: 'BUILD', + status: 'SUCCESS', + duration_sec: 24, + }, + { + step_name: 'DEPLOYMENT_QUEUEING', + status: 'SUCCESS', + duration_sec: 0, + }, + { + step_name: 'DEPLOYMENT', + status: 'ERROR', + duration_sec: 29, + }, + ], + }, + } + + const mockLogs = [ + { + id: '1', + timestamp: '2023-01-01T00:00:00Z', + message: { safe_message: 'Log 1' }, + details: { stage: { step: 'BUILD' } }, + }, + { + id: '2', + timestamp: '2023-01-01T00:01:00Z', + message: { safe_message: 'Log 2' }, + details: { stage: { step: 'DEPLOY' } }, + }, + ] + + beforeEach(() => { + useDeploymentLogs.mockReturnValue({ + data: mockLogs, + pauseLogs: false, + setPauseLogs: jest.fn(), + newMessagesAvailable: false, + setNewMessagesAvailable: jest.fn(), + showPreviousLogs: false, + setShowPreviousLogs: jest.fn(), + }) + + useDeploymentStatus.mockReturnValue({ + data: { state: 'RUNNING' }, + }) + + useServiceType.mockReturnValue({ + data: { serviceType: 'APPLICATION' }, + }) + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders( + + ) + expect(baseElement).toBeTruthy() + }) + + it('should display logs', () => { + renderWithProviders( + + ) + + expect(screen.getByText('Log 1')).toBeInTheDocument() + expect(screen.getByText('Log 2')).toBeInTheDocument() + }) + + it('should filter logs by stage step', async () => { + const { userEvent } = renderWithProviders( + + ) + + const buildButton = screen.getByRole('button', { name: /build/i }) + await userEvent.click(buildButton) + + await waitFor(() => { + expect(screen.getByText('Log 1')).toBeInTheDocument() + expect(screen.queryByText('Log 2')).not.toBeInTheDocument() + }) + }) + + it('should show progress indicator when deployment is progressing', () => { + useDeploymentStatus.mockReturnValue({ + data: { state: 'BUILDING' }, + }) + + renderWithProviders( + + ) + + expect(screen.getByText('Streaming service logs')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/list-deployment-logs.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/list-deployment-logs.tsx new file mode 100644 index 00000000000..b01dd2d62ec --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/list-deployment-logs.tsx @@ -0,0 +1,249 @@ +import { + type ColumnFiltersState, + type FilterFn, + createColumnHelper, + getCoreRowModel, + getFilteredRowModel, + useReactTable, +} from '@tanstack/react-table' +import download from 'downloadjs' +import { + type DeploymentHistoryEnvironment, + type Environment, + type EnvironmentLogs, + type Status, +} from 'qovery-typescript-axios' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams } from 'react-router-dom' +import { match } from 'ts-pattern' +import { useDeploymentStatus, useServiceType } from '@qovery/domains/services/feature' +import { Button, Icon, TablePrimitives } from '@qovery/shared/ui' +import { DeploymentLogsPlaceholder } from '../deployment-logs-placeholder/deployment-logs-placeholder' +import { useDeploymentLogs } from '../hooks/use-deployment-logs/use-deployment-logs' +import { ProgressIndicator } from '../progress-indicator/progress-indicator' +import { ShowNewLogsButton } from '../show-new-logs-button/show-new-logs-button' +import { ShowPreviousLogsButton } from '../show-previous-logs-button/show-previous-logs-button' +import { FiltersStageStep } from './filters-stage-step/filters-stage-step' +import { RowDeployment } from './row-deployment/row-deployment' + +const { Table } = TablePrimitives + +const MemoizedRowDeployment = memo(RowDeployment) + +export type FilterType = 'ALL' | 'DEPLOY' | 'BUILD' + +export interface ListDeploymentLogsProps { + environment: Environment + deploymentHistoryEnvironment: DeploymentHistoryEnvironment[] + serviceStatus: Status +} + +export function ListDeploymentLogs({ + environment, + deploymentHistoryEnvironment, + serviceStatus, +}: ListDeploymentLogsProps) { + const { organizationId, projectId, serviceId, versionId } = useParams() + const refScrollSection = useRef(null) + + const { data: serviceType } = useServiceType({ environmentId: environment.id, serviceId }) + const { data: deploymentStatus } = useDeploymentStatus({ environmentId: environment.id, serviceId }) + const { + data: logs = [], + pauseLogs, + setPauseLogs, + newMessagesAvailable, + setNewMessagesAvailable, + showPreviousLogs, + setShowPreviousLogs, + } = useDeploymentLogs({ + organizationId, + projectId, + environmentId: environment.id, + serviceId, + versionId, + }) + + // `useEffect` used to scroll to the bottom of the logs when new logs are added or when the pauseLogs state changes + useEffect(() => { + const section = refScrollSection.current + if (!section) return + + !pauseLogs && section.scroll(0, section.scrollHeight) + }, [logs, pauseLogs]) + + const columnHelper = createColumnHelper() + + const customFilter: FilterFn = (row, columnId, filterValue) => { + if (filterValue === 'ALL') return true + + const rowValue = row.getValue(columnId) + if (typeof rowValue === 'string') { + return rowValue.toLowerCase().includes(filterValue.toLowerCase()) + } + return false + } + + const columns = useMemo( + () => [ + columnHelper.accessor('timestamp', { + filterFn: customFilter, + }), + columnHelper.accessor('message', { + filterFn: customFilter, + }), + columnHelper.accessor('details.stage.step', { + id: 'details.stage.step', + filterFn: customFilter, + }), + ], + [columnHelper] + ) + + const defaultColumnsFilters = useMemo( + () => [ + { + id: 'details.stage.step', + value: 'ALL', + }, + ], + [] + ) + + const [columnFilters, setColumnFilters] = useState(defaultColumnsFilters) + + const table = useReactTable({ + data: logs, + state: { columnFilters }, + onColumnFiltersChange: setColumnFilters, + columns, + getCoreRowModel: getCoreRowModel(), + enableFilters: true, + getFilteredRowModel: getFilteredRowModel(), + }) + + const toggleColumnFilter = useCallback( + (type: FilterType) => { + setColumnFilters((prevFilters) => { + const currentFilter = prevFilters.find((filter) => filter.id === 'details.stage.step') + if (currentFilter?.value === type) { + return defaultColumnsFilters + } else { + return [ + { + id: 'details.stage.step', + value: type, + }, + ] + } + }) + setTimeout(() => { + const section = refScrollSection.current + section?.scroll(0, section?.scrollHeight) + }, 10) + }, + [defaultColumnsFilters] + ) + + const isFilterActive = useMemo( + () => (type: FilterType) => columnFilters.some((f) => f.value === type), + [columnFilters] + ) + + const isLastVersion = deploymentHistoryEnvironment?.[0]?.id === versionId || !versionId + const isDeploymentProgressing = isLastVersion + ? match(deploymentStatus?.state) + .with( + 'BUILDING', + 'DEPLOYING', + 'CANCELING', + 'DELETING', + 'RESTARTING', + 'STOPPING', + 'QUEUED', + 'DELETE_QUEUED', + 'RESTART_QUEUED', + 'STOP_QUEUED', + 'DEPLOYMENT_QUEUED', + () => true + ) + .otherwise(() => false) + : false + + if (!logs || logs.length === 0 || !serviceStatus.is_part_last_deployment) { + return ( +
+
+ +
+
+ ) + } + return ( +
+
+
+ + +
+
{ + if ( + !pauseLogs && + refScrollSection.current && + refScrollSection.current.clientHeight !== refScrollSection.current.scrollHeight && + event.deltaY < 0 + ) { + setPauseLogs(true) + setNewMessagesAvailable(false) + } + }} + > + {logs.length >= 500 && ( + + )} + + + {table.getRowModel().rows.map((row) => ( + + ))} + + + {isDeploymentProgressing && ( + + )} +
+ +
+
+ ) +} + +export default ListDeploymentLogs diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/row-deployment/row-deployment.spec.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/row-deployment/row-deployment.spec.tsx new file mode 100644 index 00000000000..514cd1ac17f --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/row-deployment/row-deployment.spec.tsx @@ -0,0 +1,31 @@ +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { RowDeployment } from './row-deployment' + +describe('RowDeployment', () => { + const mockProps = { + index: 0, + original: { + timestamp: '2023-04-01T12:00:00Z', + message: { + safe_message: 'Test log message', + }, + details: { + stage: { + step: 'Building', + }, + }, + }, + } + + it('should render successfully', () => { + const { baseElement } = renderWithProviders() + expect(baseElement).toBeTruthy() + }) + + it('renders basic row content', () => { + renderWithProviders() + + expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('Test log message')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/service-logs/feature/src/lib/list-deployment-logs/row-deployment/row-deployment.tsx b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/row-deployment/row-deployment.tsx new file mode 100644 index 00000000000..621d071a1a1 --- /dev/null +++ b/libs/domains/service-logs/feature/src/lib/list-deployment-logs/row-deployment/row-deployment.tsx @@ -0,0 +1,52 @@ +import { type Row } from '@tanstack/react-table' +import clsx from 'clsx' +import { type EnvironmentLogs } from 'qovery-typescript-axios' +import { useContext } from 'react' +import { LogsType } from '@qovery/shared/enums' +import { Ansi, TablePrimitives } from '@qovery/shared/ui' +import { dateFullFormat, dateUTCString } from '@qovery/shared/util-dates' +import { UpdateTimeContext } from '../../update-time-context/update-time-context' + +const { Table } = TablePrimitives + +export interface RowDeploymentProps extends Row {} + +export function RowDeployment({ index, original }: RowDeploymentProps) { + const { utc } = useContext(UpdateTimeContext) + + const step = original.details?.stage?.step + const type = original.type + + const success = step === 'Deployed' + const error = type === LogsType.ERROR || step === 'DeployedError' + + return ( + + + {index + 1} + + + + {dateFullFormat(original.timestamp, utc ? 'UTC' : undefined, 'dd MMM, HH:mm:ss.SS')} + + + + + {type === LogsType.ERROR ? ( + {original.error?.user_log_message} + ) : ( + {original.message?.safe_message} + )} + + + + ) +} + +export default RowDeployment diff --git a/libs/domains/service-logs/feature/src/lib/list-service-logs/list-service-logs.tsx b/libs/domains/service-logs/feature/src/lib/list-service-logs/list-service-logs.tsx index 4a83b7697cd..61bbd591007 100644 --- a/libs/domains/service-logs/feature/src/lib/list-service-logs/list-service-logs.tsx +++ b/libs/domains/service-logs/feature/src/lib/list-service-logs/list-service-logs.tsx @@ -306,10 +306,7 @@ export function ListServiceLogs({ clusterId }: ListServiceLogsProps) { setShowPreviousLogs={setShowPreviousLogs} setPauseLogs={setPauseLogs} /> - + {table.getRowModel().rows.map((row) => { if (row.original.type === 'INFRA') { diff --git a/libs/domains/service-logs/feature/src/lib/progress-indicator/progress-indicator.tsx b/libs/domains/service-logs/feature/src/lib/progress-indicator/progress-indicator.tsx index b563244a07e..c63908d2907 100644 --- a/libs/domains/service-logs/feature/src/lib/progress-indicator/progress-indicator.tsx +++ b/libs/domains/service-logs/feature/src/lib/progress-indicator/progress-indicator.tsx @@ -1,20 +1,26 @@ +import { twMerge } from '@qovery/shared/util-js' + export interface ProgressIndicatorProps { message: string pauseLogs: boolean + className?: string } -export function ProgressIndicator({ pauseLogs, message }: ProgressIndicatorProps) { +export function ProgressIndicator({ pauseLogs, message, className }: ProgressIndicatorProps) { return (
{pauseLogs ? 'Streaming paused' : message} {!pauseLogs && ( <> - - - + + + )}
diff --git a/libs/pages/logs/environment/src/lib/feature/service-stage-ids-context/service-stage-ids-context.spec.tsx b/libs/domains/service-logs/feature/src/lib/service-stage-ids-context/service-stage-ids-context.spec.tsx similarity index 100% rename from libs/pages/logs/environment/src/lib/feature/service-stage-ids-context/service-stage-ids-context.spec.tsx rename to libs/domains/service-logs/feature/src/lib/service-stage-ids-context/service-stage-ids-context.spec.tsx diff --git a/libs/pages/logs/environment/src/lib/feature/service-stage-ids-context/service-stage-ids-context.tsx b/libs/domains/service-logs/feature/src/lib/service-stage-ids-context/service-stage-ids-context.tsx similarity index 100% rename from libs/pages/logs/environment/src/lib/feature/service-stage-ids-context/service-stage-ids-context.tsx rename to libs/domains/service-logs/feature/src/lib/service-stage-ids-context/service-stage-ids-context.tsx diff --git a/libs/pages/logs/environment/src/lib/feature/deployment-logs-feature/deployment-logs-feature.spec.tsx b/libs/pages/logs/environment/src/lib/feature/deployment-logs-feature/deployment-logs-feature.spec.tsx index 9f4f7135c80..da88139bc3e 100644 --- a/libs/pages/logs/environment/src/lib/feature/deployment-logs-feature/deployment-logs-feature.spec.tsx +++ b/libs/pages/logs/environment/src/lib/feature/deployment-logs-feature/deployment-logs-feature.spec.tsx @@ -1,11 +1,11 @@ -import { render } from '__tests__/utils/setup-jest' import { type DeploymentStageWithServicesStatuses, ServiceDeploymentStatusEnum, StateEnum, } from 'qovery-typescript-axios' import { Route, Routes } from 'react-router-dom' -import { LogsType } from '@qovery/shared/enums' +import { environmentFactoryMock } from '@qovery/shared/factories' +import { renderWithProviders } from '@qovery/shared/util-tests' import DeploymentLogsFeature, { type DeploymentLogsFeatureProps, getServiceStatusesById, @@ -44,25 +44,12 @@ const services: DeploymentStageWithServicesStatuses[] = [ describe('DeploymentLogsFeature', () => { const props: DeploymentLogsFeatureProps = { - logs: [ - { - type: LogsType.INFO, - timestamp: new Date().toString(), - details: { - stage: { - step: 'Deployed', - }, - }, - message: { - safe_message: 'Log 1', - }, - }, - ], + environment: environmentFactoryMock(1)[0], statusStages: services, } it('should render successfully', () => { - const { baseElement } = render( + const { baseElement } = renderWithProviders( Date.now(), []) - const [showPreviousLogs, setShowPreviousLogs] = useState(false) - const [newMessagesAvailable, setNewMessagesAvailable] = useState(false) - const [logs, setLogs] = useState([]) - const [loadingStatusDeploymentLogs, setLoadingStatusDeploymentLogs] = useState('not loaded') - const [messageChunks, setMessageChunks] = useState([]) - const [debounceTime, setDebounceTime] = useState(1000) - const [pauseStatusLogs, setPauseStatusLogs] = useState(false) + const { organizationId = '', projectId = '', serviceId = '' } = useParams() - useDocumentTitle( - `Deployment logs ${loadingStatusDeploymentLogs === 'loaded' ? `- ${service?.name}` : '- Loading...'}` - ) + const { data: service, isFetched: isFetchedService } = useService({ environmentId: environment.id, serviceId }) + const { data: deploymentHistoryEnvironment = [] } = useDeploymentHistory({ environmentId: environment.id }) - const chunkSize = 500 + useDocumentTitle(`Deployment logs - ${service?.name ?? 'Loading...'}`) - const messageHandler = useCallback( - (_: QueryClient, message: EnvironmentLogs[]) => { - setNewMessagesAvailable(true) - setLoadingStatusDeploymentLogs('loaded') - setMessageChunks((prevChunks) => { - const lastChunk = prevChunks[prevChunks.length - 1] || [] - if (lastChunk.length < chunkSize) { - return [...prevChunks.slice(0, -1), [...lastChunk, ...message]] - } else { - return [...prevChunks, [...message]] - } - }) - }, - [setLoadingStatusDeploymentLogs, setMessageChunks] - ) + const serviceStatus = getServiceStatusesById(statusStages, serviceId) as Status - 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 || pauseStatusLogs) return - - const timerId = setTimeout(() => { - if (!pauseStatusLogs) { - 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, pauseStatusLogs]) - - const serviceStatus = getServiceStatusesById(statusStages, serviceId) - const hideDeploymentLogsBoolean = !(serviceStatus as Status)?.is_part_last_deployment - - // 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] - ) - - const errors = logsByServiceId - .map((log: EnvironmentLogs, index: number) => ({ - index: index, - errors: log.error, - })) - .filter((log) => log.errors) - - const isLastVersion = (deploymentHistory && deploymentHistory[0]?.id === versionId) || !versionId - const isDeploymentProgressing: boolean = isLastVersion - ? match(deploymentStatus?.state) - .with( - 'BUILDING', - 'DEPLOYING', - 'CANCELING', - 'DELETING', - 'RESTARTING', - 'STOPPING', - 'QUEUED', - 'DELETE_QUEUED', - 'RESTART_QUEUED', - 'STOP_QUEUED', - 'DEPLOYMENT_QUEUED', - () => true - ) - .otherwise(() => false) - : false + if (!serviceStatus && isFetchedService) + return ( +
+
+
+ ) return ( - serviceStatus && ( - +
+ + db.mode === 'CONTAINER') + .otherwise(() => true)} + /> +
+ - ) +
) } diff --git a/libs/pages/logs/environment/src/lib/feature/pod-logs-feature/pod-logs-feature.tsx b/libs/pages/logs/environment/src/lib/feature/pod-logs-feature/pod-logs-feature.tsx index dc97875e040..38cbdd23db0 100644 --- a/libs/pages/logs/environment/src/lib/feature/pod-logs-feature/pod-logs-feature.tsx +++ b/libs/pages/logs/environment/src/lib/feature/pod-logs-feature/pod-logs-feature.tsx @@ -10,7 +10,7 @@ export interface PodLogsFeatureProps { clusterId: string } -function LinkLogs({ title, url, statusChip = true }: { title: string; url: string; statusChip?: boolean }) { +export function LinkLogs({ title, url, statusChip = true }: { title: string; url: string; statusChip?: boolean }) { const { environmentId = '', serviceId = '' } = useParams() const location = useLocation() diff --git a/libs/pages/logs/environment/src/lib/page-environment-logs.tsx b/libs/pages/logs/environment/src/lib/page-environment-logs.tsx index 4311a83be3c..b34e36cb17c 100644 --- a/libs/pages/logs/environment/src/lib/page-environment-logs.tsx +++ b/libs/pages/logs/environment/src/lib/page-environment-logs.tsx @@ -3,6 +3,7 @@ import { type DeploymentStageWithServicesStatuses, type EnvironmentStatus } from import { useCallback, useState } from 'react' import { Route, Routes, matchPath, useLocation, useParams } from 'react-router-dom' import { useEnvironment } from '@qovery/domains/environments/feature' +import { ServiceStageIdsProvider } from '@qovery/domains/service-logs/feature' import { useServices } from '@qovery/domains/services/feature' import { DEPLOYMENT_LOGS_URL, @@ -16,7 +17,6 @@ import { QOVERY_WS } from '@qovery/shared/util-node-env' import { useReactQueryWsSubscription } from '@qovery/state/util-queries' import DeploymentLogsFeature from './feature/deployment-logs-feature/deployment-logs-feature' import PodLogsFeature from './feature/pod-logs-feature/pod-logs-feature' -import { ServiceStageIdsProvider } from './feature/service-stage-ids-context/service-stage-ids-context' import Sidebar from './ui/sidebar/sidebar' export function PageEnvironmentLogs() { diff --git a/libs/pages/logs/environment/src/lib/ui/deployment-logs/deployment-logs.spec.tsx b/libs/pages/logs/environment/src/lib/ui/deployment-logs/deployment-logs.spec.tsx deleted file mode 100644 index 3b1b5e0ffec..00000000000 --- a/libs/pages/logs/environment/src/lib/ui/deployment-logs/deployment-logs.spec.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { ClusterLogsStepEnum, ServiceDeploymentStatusEnum, StateEnum } from 'qovery-typescript-axios' -import { applicationFactoryMock, deploymentLogFactoryMock } from '@qovery/shared/factories' -import { dateFullFormat } from '@qovery/shared/util-dates' -import { trimId } from '@qovery/shared/util-js' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import DeploymentLogs, { type DeploymentLogsProps } from './deployment-logs' - -const mockLogs = deploymentLogFactoryMock(1) -const mockApplication = applicationFactoryMock(1)[0] - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ organizationId: '1', projectId: '2', environmentId: '3', serviceId: '4', versionId: '5' }), -})) - -describe('DeploymentLogs', () => { - const props: DeploymentLogsProps = { - loadingStatus: 'loaded', - logs: mockLogs, - errors: [ - { - index: 0, - error: {}, - timeAgo: '20', - step: ClusterLogsStepEnum.DELETE_ERROR, - }, - ], - hideDeploymentLogs: false, - serviceStatus: { - id: '', - state: StateEnum.READY, - service_deployment_status: ServiceDeploymentStatusEnum.NEVER_DEPLOYED, - }, - setPauseStatusLogs: jest.fn(), - pauseStatusLogs: false, - service: mockApplication, - } - - beforeEach(() => { - window.HTMLElement.prototype.scroll = jest.fn() - }) - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - - const message = mockLogs[0].message?.safe_message || '' - screen.getByText(message) - }) - - it('should render a placeholder with a deployment history', () => { - props.hideDeploymentLogs = true - props.loadingStatus = 'loaded' - props.serviceStatus = { - id: '', - state: StateEnum.DEPLOYED, - service_deployment_status: ServiceDeploymentStatusEnum.OUT_OF_DATE, - } - - const name = mockApplication.name - - props.dataDeploymentHistory = [ - { - id: 'deployment-id', - created_at: new Date().toString(), - applications: [ - { - id: '4', - created_at: new Date().toString(), - name: 'my-app', - }, - ], - }, - ] - - renderWithProviders() - - screen.getByText( - (_, element) => element?.textContent === `${name} service was not deployed within this deployment execution.` - ) - screen.getByText(trimId(props.dataDeploymentHistory[0].id)) - screen.getByText(dateFullFormat(props.dataDeploymentHistory[0].created_at)) - }) -}) diff --git a/libs/pages/logs/environment/src/lib/ui/deployment-logs/deployment-logs.tsx b/libs/pages/logs/environment/src/lib/ui/deployment-logs/deployment-logs.tsx deleted file mode 100644 index d616b83e015..00000000000 --- a/libs/pages/logs/environment/src/lib/ui/deployment-logs/deployment-logs.tsx +++ /dev/null @@ -1,152 +0,0 @@ -import { - type DeploymentHistoryEnvironment, - type EnvironmentLogs, - type ServiceDeploymentStatusEnum, - type Status, -} from 'qovery-typescript-axios' -import { type Dispatch, type SetStateAction, useMemo } from 'react' -import { Link, useParams } from 'react-router-dom' -import { type AnyService } from '@qovery/domains/services/data-access' -import { type ErrorLogsProps, LayoutLogs } from '@qovery/shared/console-shared' -import { type DeploymentService, type LoadingStatus } from '@qovery/shared/interfaces' -import { DEPLOYMENT_LOGS_VERSION_URL, ENVIRONMENT_LOGS_URL } from '@qovery/shared/routes' -import { Icon, StatusChip } from '@qovery/shared/ui' -import { dateFullFormat } from '@qovery/shared/util-dates' -import { mergeDeploymentServices, trimId } from '@qovery/shared/util-js' -import RowDeployment from '../row-deployment/row-deployment' - -export interface DeploymentLogsProps { - loadingStatus: LoadingStatus - logs: EnvironmentLogs[] - pauseStatusLogs: boolean - setPauseStatusLogs: (pause: boolean) => void - errors: ErrorLogsProps[] - hideDeploymentLogs?: boolean - serviceDeploymentStatus?: ServiceDeploymentStatusEnum - dataDeploymentHistory?: DeploymentHistoryEnvironment[] - service?: AnyService - serviceStatus: Status - isDeploymentProgressing?: boolean - setShowPreviousLogs?: (showPreviousLogs: boolean) => void - showPreviousLogs?: boolean - newMessagesAvailable: boolean - setNewMessagesAvailable: Dispatch> -} - -export function DeploymentLogs({ - logs, - errors, - hideDeploymentLogs, - pauseStatusLogs, - setPauseStatusLogs, - loadingStatus, - dataDeploymentHistory, - service, - serviceStatus: { service_deployment_status: serviceDeploymentStatus, state: serviceState }, - isDeploymentProgressing, - setShowPreviousLogs, - showPreviousLogs, - newMessagesAvailable, - setNewMessagesAvailable, -}: DeploymentLogsProps) { - const { organizationId = '', projectId = '', environmentId = '', serviceId = '', versionId = '' } = useParams() - - const memoRow = useMemo( - () => - logs - // Hide Environment deployment error if selected service is the one in error - .filter(({ details: { transmitter, stage } }) => - transmitter?.type === 'Environment' && stage?.step === 'DeployedError' - ? serviceState !== 'DEPLOYMENT_ERROR' - : true - ) - .map((log: EnvironmentLogs, index: number) => ), - [logs, serviceDeploymentStatus] - ) - - const deploymentsByServiceId = mergeDeploymentServices(dataDeploymentHistory).filter( - (deploymentHistory) => deploymentHistory.id === serviceId - ) - - const placeholderDeploymentHistory = deploymentsByServiceId.length !== 0 && ( -
-
-

- {service?.name} service was not deployed within this deployment - execution. -

-

- Below the list of executions where this service was deployed. -

-
-
-
- Last deployment logs -
-
- {deploymentsByServiceId?.map((deploymentHistory: DeploymentService) => ( -
- - - - {trimId(deploymentHistory.execution_id || '')} - - {dateFullFormat(deploymentHistory.created_at)} - -
- ))} -
-
-

- Only the last 20 deployments of the environment over the last 30 days are available. -

-
-
-
- ) - - return ( - - {logs.length >= 500 && showPreviousLogs === false && ( - - )} -
{memoRow}
-
- ) -} - -export default DeploymentLogs diff --git a/libs/pages/logs/environment/src/lib/ui/row-deployment/__snapshots__/row-deployment.spec.tsx.snap b/libs/pages/logs/environment/src/lib/ui/row-deployment/__snapshots__/row-deployment.spec.tsx.snap deleted file mode 100644 index 06d667eb6a6..00000000000 --- a/libs/pages/logs/environment/src/lib/ui/row-deployment/__snapshots__/row-deployment.spec.tsx.snap +++ /dev/null @@ -1,159 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RowDeployment should have cell error message 1`] = ` -
-
-
-
- 2 -
-
-
-
- - - - error message - - - - - - -
-
-
-`; - -exports[`RowDeployment should have cell message with ANSI colors and links 1`] = ` -
-
-
-
- 2 -
-
-
-
- - - - my message - - https://qovery.com - - - - - - - -
-
-
-`; - -exports[`RowDeployment should have cell success message 1`] = ` -
-
-
-
- 2 -
-
-
-
- - - - message - - - - - - -
-
-
-`; diff --git a/libs/pages/logs/environment/src/lib/ui/row-deployment/row-deployment.spec.tsx b/libs/pages/logs/environment/src/lib/ui/row-deployment/row-deployment.spec.tsx deleted file mode 100644 index 4bdd685f6bb..00000000000 --- a/libs/pages/logs/environment/src/lib/ui/row-deployment/row-deployment.spec.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { LogsType } from '@qovery/shared/enums' -import { deploymentLogFactoryMock } from '@qovery/shared/factories' -import { renderWithProviders, screen } from '@qovery/shared/util-tests' -import RowDeployment, { type RowDeploymentProps } from './row-deployment' - -jest.mock('date-fns-tz', () => ({ - format: jest.fn(() => '20 Sept, 19:44:44:44'), - utcToZonedTime: jest.fn(), -})) - -describe('RowDeployment', () => { - const props: RowDeploymentProps = { - data: deploymentLogFactoryMock(1)[0], - index: 1, - } - - it('should render successfully', () => { - const { baseElement } = renderWithProviders() - expect(baseElement).toBeTruthy() - }) - - it('should have success index color', () => { - props.data = { - type: LogsType.INFO, - timestamp: '2024-02-15T13:51:01.685342358Z', - details: { - stage: { - step: 'Deployed', - }, - }, - } - - renderWithProviders() - - const index = screen.getByTestId('index') - - expect(index).toHaveClass('text-green-500 bg-neutral-550 group-hover:bg-neutral-650') - }) - - it('should have error index color', () => { - props.data = { - type: LogsType.ERROR, - timestamp: '2024-02-15T13:51:01.685342358Z', - details: { - stage: { - step: 'DeployedError', - }, - }, - } - - renderWithProviders() - - const index = screen.getByTestId('index') - - expect(index).toHaveClass('text-red-500 bg-neutral-550 group-hover:bg-neutral-650') - }) - - it('should have error cell date color', () => { - props.data = { - type: LogsType.ERROR, - timestamp: '2024-02-15T13:51:01.685342358Z', - details: { - stage: { - step: 'DeployedError', - }, - }, - } - - renderWithProviders() - - const cellDate = screen.getByTestId('cell-date') - - expect(cellDate).toHaveClass('py-1 pl-2 pr-3 font-code shrink-0 w-[158px] text-red-500') - }) - - it('should have success cell date color', () => { - props.data = { - type: LogsType.INFO, - timestamp: new Date().toString(), - details: { - stage: { - step: 'Deployed', - }, - }, - } - - renderWithProviders() - - const cellDate = screen.getByTestId('cell-date') - - expect(cellDate).toHaveClass('py-1 pl-2 pr-3 font-code shrink-0 w-[158px] text-green-500') - }) - - it('should have cell success message', () => { - props.data = { - type: LogsType.INFO, - timestamp: '2024-02-15T13:51:01.685342358Z', - details: { - stage: { - step: 'Deployed', - }, - transmitter: { - name: 'message', - }, - }, - message: { - safe_message: 'message', - }, - } - - const { container } = renderWithProviders() - expect(container).toMatchSnapshot() - }) - - it('should have cell error message', () => { - props.data = { - type: LogsType.ERROR, - timestamp: '2024-02-15T13:51:01.685342358Z', - details: { - stage: { - step: 'DeployedError', - }, - }, - error: { - user_log_message: 'error message', - }, - } - - const { container } = renderWithProviders() - expect(container).toMatchSnapshot() - }) - - it('should have cell message with ANSI colors and links', () => { - props.data = { - type: LogsType.INFO, - timestamp: '2024-02-15T13:51:01.685342358Z', - details: { - stage: { - step: 'Deployed', - }, - transmitter: { - name: 'message', - }, - }, - message: { - safe_message: '\x1b[F\x1b[31;1mmy message https://qovery.com\x1b[m\x1b[E', - }, - } - - const { container } = renderWithProviders() - expect(container).toMatchSnapshot() - }) -}) diff --git a/libs/pages/logs/environment/src/lib/ui/row-deployment/row-deployment.tsx b/libs/pages/logs/environment/src/lib/ui/row-deployment/row-deployment.tsx deleted file mode 100644 index c6073e33460..00000000000 --- a/libs/pages/logs/environment/src/lib/ui/row-deployment/row-deployment.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { type EnvironmentLogs } from 'qovery-typescript-axios' -import { useContext } from 'react' -import { UpdateTimeContext } from '@qovery/domains/service-logs/feature' -import { LogsType } from '@qovery/shared/enums' -import { Ansi, CopyToClipboardButtonIcon } from '@qovery/shared/ui' -import { dateFullFormat, dateUTCString } from '@qovery/shared/util-dates' - -export interface RowDeploymentProps { - data: EnvironmentLogs - index: number -} - -export function RowDeployment(props: RowDeploymentProps) { - const { index, data } = props - - const { utc } = useContext(UpdateTimeContext) - - const type = (data as EnvironmentLogs).type - const step = (data as EnvironmentLogs).details?.stage?.step - - const success = step === 'Deployed' - const error = type === LogsType.ERROR || step === 'DeployedError' - - const indexClassName = `${ - error - ? 'text-red-500 bg-neutral-550 group-hover:bg-neutral-650' - : success - ? 'text-green-500 bg-neutral-550 group-hover:bg-neutral-650' - : 'bg-neutral-700 text-neutral-400 group-hover:bg-neutral-550' - }` - - const colorsCellClassName = (date?: boolean) => - `${error ? 'text-red-500' : success ? 'text-green-500' : `${date ? 'text-neutral-100' : 'text-neutral-350'}`}` - - return ( -
-
-
{index + 1}
-
-
- {dateFullFormat(data.timestamp, utc ? 'UTC' : undefined, 'dd MMM, HH:mm:ss.SS')} -
-
- - {type === LogsType.ERROR ? ( - {data.error?.user_log_message} - ) : ( - {data.message?.safe_message} - )} - - -
-
- ) -} - -export default RowDeployment diff --git a/libs/pages/logs/environment/src/lib/ui/sidebar-pipeline-item/sidebar-pipeline-item.tsx b/libs/pages/logs/environment/src/lib/ui/sidebar-pipeline-item/sidebar-pipeline-item.tsx index 35031ef7733..8524cd245d3 100644 --- a/libs/pages/logs/environment/src/lib/ui/sidebar-pipeline-item/sidebar-pipeline-item.tsx +++ b/libs/pages/logs/environment/src/lib/ui/sidebar-pipeline-item/sidebar-pipeline-item.tsx @@ -1,11 +1,11 @@ import { type DeploymentStageWithServicesStatuses, type Status } from 'qovery-typescript-axios' import { useContext, useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' +import { ServiceStageIdsContext } from '@qovery/domains/service-logs/feature' import { type AnyService } from '@qovery/domains/services/data-access' import { ServiceAvatar } from '@qovery/domains/services/feature' import { DEPLOYMENT_LOGS_VERSION_URL, ENVIRONMENT_LOGS_URL } from '@qovery/shared/routes' import { BadgeDeploymentOrder, Icon, StatusChip } from '@qovery/shared/ui' -import { ServiceStageIdsContext } from '../../feature/service-stage-ids-context/service-stage-ids-context' export function mergeServices( applications?: Status[], diff --git a/libs/shared/console-shared/src/lib/layout-logs/buttons-actions-logs/buttons-actions-logs.tsx b/libs/shared/console-shared/src/lib/layout-logs/buttons-actions-logs/buttons-actions-logs.tsx index 027556ad109..13af6465c5b 100644 --- a/libs/shared/console-shared/src/lib/layout-logs/buttons-actions-logs/buttons-actions-logs.tsx +++ b/libs/shared/console-shared/src/lib/layout-logs/buttons-actions-logs/buttons-actions-logs.tsx @@ -6,11 +6,10 @@ import { type LayoutLogsDataProps } from '../layout-logs' export interface ButtonsActionsLogsProps { refScrollSection: RefObject data: LayoutLogsDataProps - pauseLogs?: boolean } export function ButtonsActionsLogs(props: ButtonsActionsLogsProps) { - const { refScrollSection, data, pauseLogs } = props + const { refScrollSection, data } = props const downloadJSON = () => { download(JSON.stringify(data?.items), `data-${Date.now()}.json`, 'text/json;charset=utf-8') @@ -29,7 +28,7 @@ export function ButtonsActionsLogs(props: ButtonsActionsLogsProps) { useEffect(() => { // auto scroll when we add data - !pauseLogs && forcedScroll && forcedScroll(true) + forcedScroll && forcedScroll(true) }, [data]) return ( diff --git a/libs/shared/console-shared/src/lib/layout-logs/layout-logs.spec.tsx b/libs/shared/console-shared/src/lib/layout-logs/layout-logs.spec.tsx index 054c39af7ea..210cb453544 100644 --- a/libs/shared/console-shared/src/lib/layout-logs/layout-logs.spec.tsx +++ b/libs/shared/console-shared/src/lib/layout-logs/layout-logs.spec.tsx @@ -5,7 +5,7 @@ import { LayoutLogs, type LayoutLogsProps } from './layout-logs' describe('LayoutLogs', () => { const props: LayoutLogsProps = { - type: 'deployment', + type: 'infra', data: { loadingStatus: 'loaded', items: clusterLogFactoryMock(2, true), @@ -88,55 +88,4 @@ describe('LayoutLogs', () => { expect(tabsLogs).toBeInTheDocument() }) - - it('should have a navigation', () => { - props.data = { - loadingStatus: 'loaded', - items: [ - { - created_at: 1667834316521, - message: 'message', - pod_name: 'app-z9d11ee4f-7d754477b6-k9sl7', - version: '53deb16f853aef759b8be84fbeec96e9727', - }, - ], - } - props.withLogsNavigation = true - - const { getByText } = renderWithProviders() - - getByText('Deployment logs') - getByText('Service logs') - }) - - it('should have debug checkbox when debug is true', () => { - props.data = { - loadingStatus: 'loaded', - items: [ - { - created_at: 1667834316521, - message: 'message', - pod_name: 'app-z9d11ee4f-7d754477b6-k9sl7', - version: '53deb16f853aef759b8be84fbeec96e9727', - }, - { - created_at: 1667834316521, - message: 'message', - pod_name: ' NGINX', - version: '53deb16f853aef759b8be84fbeec96e9722', - }, - ], - } - props.enabledNginx = true - props.setEnabledNginx = jest.fn() - - renderWithProviders() - - screen.getByTestId('checkbox-debug') - }) - - it('should have progressing bar', () => { - renderWithProviders() - screen.getByRole('progressbar') - }) }) diff --git a/libs/shared/console-shared/src/lib/layout-logs/layout-logs.tsx b/libs/shared/console-shared/src/lib/layout-logs/layout-logs.tsx index 5870b22928e..a75c188ee5f 100644 --- a/libs/shared/console-shared/src/lib/layout-logs/layout-logs.tsx +++ b/libs/shared/console-shared/src/lib/layout-logs/layout-logs.tsx @@ -2,20 +2,13 @@ import { type ClusterLogs, type ClusterLogsError, type ClusterLogsStepEnum, - DatabaseModeEnum, type EnvironmentLogs, type EnvironmentLogsError, - type ServiceDeploymentStatusEnum, } from 'qovery-typescript-axios' import { type ServiceLogResponseDto } from 'qovery-ws-typescript-axios' -import { type Dispatch, type PropsWithChildren, type ReactNode, type SetStateAction, useRef, useState } from 'react' -import { Link, useLocation, useParams } from 'react-router-dom' -import { type AnyService, type Database } from '@qovery/domains/services/data-access' -import { ServiceStateChip } from '@qovery/domains/services/feature' +import { type PropsWithChildren, type ReactNode, useRef } from 'react' import { type LoadingStatus } from '@qovery/shared/interfaces' -// eslint-disable-next-line @nx/enforce-module-boundaries -import { DEPLOYMENT_LOGS_URL, ENVIRONMENT_LOGS_URL, SERVICE_LOGS_URL } from '@qovery/shared/routes' -import { Button, Checkbox, Icon, Tooltip } from '@qovery/shared/ui' +import { Icon } from '@qovery/shared/ui' import { scrollParentToChild } from '@qovery/shared/util-js' import ButtonsActionsLogs from './buttons-actions-logs/buttons-actions-logs' import { PlaceholderLogs } from './placeholder-logs/placeholder-logs' @@ -27,29 +20,16 @@ export interface LayoutLogsDataProps { items?: ClusterLogs[] | EnvironmentLogs[] | ServiceLogResponseDto[] } -export type logsType = 'infra' | 'live' | 'deployment' +export type logsType = 'infra' export interface LayoutLogsProps { type: logsType - service?: AnyService data?: LayoutLogsDataProps errors?: ErrorLogsProps[] tabInformation?: ReactNode withLogsNavigation?: boolean - pauseLogs?: boolean - setPauseLogs?: (pause: boolean) => void lineNumbers?: boolean - enabledNginx?: boolean - setEnabledNginx?: (debugMode: boolean) => void clusterBanner?: boolean - countNginx?: number - customPlaceholder?: string | ReactNode - serviceDeploymentStatus?: ServiceDeploymentStatusEnum - isProgressing?: boolean - progressingMsg?: string - newMessagesAvailable?: boolean - setNewMessagesAvailable?: Dispatch> - resetFilterPodName?: () => void } export interface ErrorLogsProps { @@ -66,27 +46,11 @@ export function LayoutLogs({ children, errors, withLogsNavigation, - pauseLogs, - setPauseLogs, lineNumbers, - enabledNginx, - setEnabledNginx, clusterBanner, - countNginx, - customPlaceholder, - service, - serviceDeploymentStatus, - isProgressing, - progressingMsg, - newMessagesAvailable, - setNewMessagesAvailable, - resetFilterPodName, }: PropsWithChildren) { - const location = useLocation() const refScrollSection = useRef(null) - const { organizationId = '', projectId = '', environmentId = '', serviceId = '' } = useParams() - const scrollToError = () => { const section = refScrollSection.current if (!section) return @@ -95,65 +59,14 @@ export function LayoutLogs({ if (row) scrollParentToChild(section, row, 100) } - const LinkNavigation = ( - name: string, - link: string, - environmentId?: string, - serviceId?: string, - displayStatusChip = true - ) => { - const isActive = location.pathname.includes(link) - return ( - - {displayStatusChip && ( - - )} - - {name} - - ) - } - return (
- {withLogsNavigation && ( -
- {LinkNavigation( - 'Deployment logs', - ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + DEPLOYMENT_LOGS_URL(serviceId), - undefined, - undefined, - false - )} - {LinkNavigation( - 'Service logs', - ENVIRONMENT_LOGS_URL(organizationId, projectId, environmentId) + SERVICE_LOGS_URL(serviceId), - environmentId, - serviceId, - (service as Database)?.mode !== DatabaseModeEnum.MANAGED - )} -
- )} {!data || data?.items?.length === 0 || data?.hideLogs ? ( - + ) : ( <>

)} - {setEnabledNginx && ( -
- -   - {enabledNginx && countNginx !== undefined ? ({countNginx}) : ''} - - - - - -
- )}
- +
{ - if ( - !pauseLogs && - setPauseLogs && - refScrollSection.current && - refScrollSection.current.clientHeight !== refScrollSection.current.scrollHeight && - event.deltaY < 0 - ) { - setPauseLogs(true) - setNewMessagesAvailable?.(false) - } - }} className={`mb-5 h-[calc(100%-20px)] w-full overflow-y-auto bg-neutral-700 pb-16 ${ lineNumbers ? 'before:absolute before:left-1 before:top-9 before:-z-[1] before:h-full before:w-10 before:bg-neutral-700' : '' } ${withLogsNavigation ? 'mt-[88px]' : 'mt-11'}`} > -
- {children} - {isProgressing && ( -
- {pauseLogs ? 'Streaming paused' : progressingMsg} - {!pauseLogs && ( - <> - - - - - )} -
- )} -
+
{children}
{tabInformation && ( )} )} - {pauseLogs && newMessagesAvailable && ( - - )}
) } diff --git a/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.spec.tsx b/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.spec.tsx index dda2b5e5737..69029217b1e 100644 --- a/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.spec.tsx +++ b/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.spec.tsx @@ -1,15 +1,10 @@ -import { ServiceDeploymentStatusEnum } from 'qovery-typescript-axios' import { renderWithProviders, screen } from '@qovery/shared/util-tests' import PlaceholderLogs, { type PlaceholderLogsProps } from './placeholder-logs' describe('PlaceholderLogs', () => { const props: PlaceholderLogsProps = { - type: 'deployment', - serviceName: 'my-app', + type: 'infra', loadingStatus: 'not loaded', - customPlaceholder:
custom placeholder
, - itemsLength: 0, - hideLogs: true, } it('should render successfully', () => { @@ -17,28 +12,6 @@ describe('PlaceholderLogs', () => { expect(baseElement).toBeTruthy() }) - it('Deployment - display loader screen', () => { - renderWithProviders() - screen.getByTestId('spinner') - }) - - it('Deployment - should render a custom placeholder', () => { - props.loadingStatus = 'loaded' - renderWithProviders() - - screen.getByText('custom placeholder') - }) - - it('Deployment - should render a no log placeholder', () => { - props.loadingStatus = 'loaded' - props.customPlaceholder = undefined - props.serviceDeploymentStatus = ServiceDeploymentStatusEnum.NEVER_DEPLOYED - renderWithProviders() - - screen.getByText(props.serviceName as string) - screen.getByText(/No logs on this execution for/i) - }) - it('Infra - should render a placeholder with spinner if logs not loaded', () => { props.type = 'infra' props.loadingStatus = 'not loaded' diff --git a/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.tsx b/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.tsx index 7b9b752e41d..9794d8c8352 100644 --- a/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.tsx +++ b/libs/shared/console-shared/src/lib/layout-logs/placeholder-logs/placeholder-logs.tsx @@ -1,84 +1,15 @@ -import { ServiceDeploymentStatusEnum } from 'qovery-typescript-axios' -import { type ReactNode } from 'react' -import { P, match } from 'ts-pattern' import { type LoadingStatus } from '@qovery/shared/interfaces' import { type logsType } from '../layout-logs' import { LoaderPlaceholder } from './loader-placeholder/loader-placeholder' export interface PlaceholderLogsProps { type: logsType - serviceName?: string loadingStatus?: LoadingStatus - customPlaceholder?: string | ReactNode - serviceDeploymentStatus?: ServiceDeploymentStatusEnum - itemsLength?: number - hideLogs?: boolean } -export function PlaceholderLogs({ - type, - serviceName, - loadingStatus, - customPlaceholder, - serviceDeploymentStatus, - itemsLength, - hideLogs, -}: PlaceholderLogsProps) { - const deploymentPlaceholder = () => { - const outOfDateOrUpToDate = - serviceDeploymentStatus === ServiceDeploymentStatusEnum.NEVER_DEPLOYED || - serviceDeploymentStatus === ServiceDeploymentStatusEnum.UP_TO_DATE - - const displaySpinner = match({ loadingStatus, itemsLength, hideLogs, serviceDeploymentStatus }) - .with( - { - loadingStatus: P.not('loaded'), - itemsLength: 0, - hideLogs: false, - serviceDeploymentStatus: P.nullish, - }, - () => true - ) - .otherwise(() => false) - - const displayPlaceholders = loadingStatus === 'loaded' && hideLogs - - if (displaySpinner) { - // Display loader spinner - return - } else { - if (displayPlaceholders) { - return ( -
- {!customPlaceholder && outOfDateOrUpToDate ? ( - <> - {/* Display message about no logs available */} -

- No logs on this execution for {serviceName}. -

- {serviceDeploymentStatus !== ServiceDeploymentStatusEnum.NEVER_DEPLOYED && ( -

- This service was deployed more than 30 days ago and thus no deployment logs are available. -

- )} - - ) : ( - // Display custom placeholder with list of history deployments - customPlaceholder - )} -
- ) - } - - // Return the default loader spinner - return - } - } - +export function PlaceholderLogs({ type, loadingStatus }: PlaceholderLogsProps) { return (
- {type === 'deployment' && deploymentPlaceholder()} - {type === 'infra' && (
{!loadingStatus || loadingStatus === 'not loaded' ? ( diff --git a/libs/shared/ui/src/lib/components/status-chip/__snapshots__/status-chip.spec.tsx.snap b/libs/shared/ui/src/lib/components/status-chip/__snapshots__/status-chip.spec.tsx.snap index 6cc898e3c54..b73a313682b 100644 --- a/libs/shared/ui/src/lib/components/status-chip/__snapshots__/status-chip.spec.tsx.snap +++ b/libs/shared/ui/src/lib/components/status-chip/__snapshots__/status-chip.spec.tsx.snap @@ -44,7 +44,7 @@ exports[`StatusChip should match snapshot for ERROR status 1`] = ` data-state="closed" > ) - .with('DEPLOYED', 'RUNNING', 'COMPLETED', () => ) + .with('DEPLOYED', 'RUNNING', 'COMPLETED', 'SUCCESS', () => ) .with('RESTARTED', () => ) // spinner .with( @@ -70,7 +70,7 @@ export function StatusChip(props: StatusChipProps) { .with('DELETING', () => ) // stopped .with('STOPPED', () => ) - .with('CANCELED', () => ) + .with('CANCELED', 'CANCEL', 'SKIP', () => ) .with('DELETED', () => ) // unknow / error / warning .with('UNKNOWN', () => ) @@ -85,7 +85,7 @@ export function StatusChip(props: StatusChipProps) { 'INVALID_CREDENTIALS', 'RECAP', () => ( - +