diff --git a/packages/app/src/components/ConditionalEntityWrapper/ConditionalEntityWrapper.tsx b/packages/app/src/components/ConditionalEntityWrapper/ConditionalEntityWrapper.tsx new file mode 100644 index 00000000..6d5882f8 --- /dev/null +++ b/packages/app/src/components/ConditionalEntityWrapper/ConditionalEntityWrapper.tsx @@ -0,0 +1,17 @@ +import { Entity } from '@backstage/catalog-model/index'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useMemo } from 'react'; + +const ConditionalEntityWrapper = ({ + children, + activationFn, +}: { + children: React.ReactNode; + activationFn: (entity: Entity) => boolean; +}) => { + const { entity } = useEntity(); + const isActive = useMemo(() => activationFn(entity), [entity, activationFn]); + return isActive ? children : null; +}; + +export default ConditionalEntityWrapper; diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index abd25d08..2e4ac344 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -63,6 +63,7 @@ import { RuntimeLogs, Builds, } from '@openchoreo/backstage-plugin'; +import ConditionalEntityWrapper from '../ConditionalEntityWrapper/ConditionalEntityWrapper'; const techdocsContent = ( @@ -150,107 +151,116 @@ const overviewContent = ( ); -const serviceEntityPage = ( - - - {overviewContent} - - - - - - - - - - - - - - {/* - {cicdContent} - */} +const overviewRoute = ( + + {overviewContent} + +); - - - +const coreRoutes = [ + + + , + + + , + + + , +]; + +const buildsRoute = ( + + + +); - - - - - - - - +const apiRoute = ( + + + + - - - - - - - - - - + + - - - - {techdocsContent} - - + + ); -const websiteEntityPage = ( - - - {overviewContent} - - - - - - - - - +const dependenciesRoute = ( + + + + + + + + + + +); - - - +const docsRoute = ( + + {techdocsContent} + +); - {/* - {cicdContent} - */} +const serviceImageEntityPage = ( + <> + + {overviewRoute} + {coreRoutes} + {apiRoute} + {dependenciesRoute} + {docsRoute} + + +); - - - +const serviceSourceEntityPage = ( + + {overviewRoute} + {buildsRoute} + {coreRoutes} + {apiRoute} + {dependenciesRoute} + {docsRoute} + +); - - - - - - - - - - +const websiteImageEntityPage = ( + <> + + {overviewRoute} + {coreRoutes} + {dependenciesRoute} + {docsRoute} + + +); - - {techdocsContent} - +const websiteSourceEntityPage = ( + + {overviewRoute} + {buildsRoute} + {coreRoutes} + {dependenciesRoute} + {docsRoute} ); @@ -276,13 +286,38 @@ const defaultEntityPage = ( const componentPage = ( - {serviceEntityPage} + + !e.metadata.annotations?.['backstage.io/source-location'] + } + > + {serviceImageEntityPage} + + + !!e.metadata.annotations?.['backstage.io/source-location'] + } + > + {serviceSourceEntityPage} + - {websiteEntityPage} + + !e.metadata.annotations?.['backstage.io/source-location'] + } + > + {websiteImageEntityPage} + + + !!e.metadata.annotations?.['backstage.io/source-location'] + } + > + {websiteSourceEntityPage} + - {defaultEntityPage} ); diff --git a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts index 5345b3fb..a560ec85 100644 --- a/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts +++ b/plugins/catalog-backend-module-openchoreo/src/provider/OpenChoreoEntityProvider.ts @@ -269,7 +269,6 @@ export class OpenChoreoEntityProvider implements EntityProvider { if (component.type === 'WebApplication') { backstageComponentType = 'website'; } - const componentEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', kind: 'Component', @@ -288,11 +287,11 @@ export class OpenChoreoEntityProvider implements EntityProvider { [CHOREO_ANNOTATIONS.ORGANIZATION]: orgName, [CHOREO_ANNOTATIONS.CREATED_AT]: component.createdAt, [CHOREO_ANNOTATIONS.STATUS]: component.status, - ...(component.repositoryUrl && { - 'backstage.io/source-location': `url:${component.repositoryUrl}`, + ...(component.buildConfig?.repoUrl && { + 'backstage.io/source-location': `url:${component.buildConfig?.repoUrl}`, }), - ...(component.branch && { - [CHOREO_ANNOTATIONS.BRANCH]: component.branch, + ...(component.buildConfig?.repoBranch && { + [CHOREO_ANNOTATIONS.BRANCH]: component.buildConfig?.repoBranch, }), }, labels: { diff --git a/plugins/openchoreo/src/components/Builds/BuildLogs.tsx b/plugins/openchoreo/src/components/Builds/BuildLogs.tsx index df497eda..023954c8 100644 --- a/plugins/openchoreo/src/components/Builds/BuildLogs.tsx +++ b/plugins/openchoreo/src/components/Builds/BuildLogs.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { Drawer, Typography, @@ -7,7 +7,7 @@ import { Divider, CircularProgress, } from '@material-ui/core'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; import Close from '@material-ui/icons/Close'; import { useApi, @@ -16,13 +16,15 @@ import { } from '@backstage/core-plugin-api'; import type { ModelsBuild, LogEntry } from '@openchoreo/backstage-plugin-api'; import { fetchBuildLogsForBuild } from '../../api/buildLogs'; +import { PageBanner } from '../CommonComponents'; +import { useTimerEffect } from '../../hooks/timerEffect'; +import { BuildStatus } from '../CommonComponents/BuildStatus'; const useStyles = makeStyles(theme => ({ logsContainer: { backgroundColor: theme.palette.background.default, - fontFamily: 'monospace', fontSize: '12px', - height: '90%', + height: 'calc(100vh - 250px)', overflow: 'auto', border: `1px solid ${theme.palette.divider}`, borderRadius: theme.shape.borderRadius, @@ -32,9 +34,19 @@ const useStyles = makeStyles(theme => ({ fontSize: '11px', color: theme.palette.text.secondary, }, + logLine: { + fontSize: '12px', + color: theme.palette.text.primary, + fontFamily: 'monospace', + padding: theme.spacing(0.5, 1), + '&:hover': { + backgroundColor: theme.palette.action.hover, + }, + }, logText: { fontSize: '12px', color: theme.palette.text.primary, + fontFamily: 'monospace', }, })); @@ -42,9 +54,42 @@ interface BuildLogsProps { open: boolean; onClose: () => void; build: ModelsBuild | null; + enableAutoRefresh?: boolean; } -export const BuildLogs = ({ open, onClose, build }: BuildLogsProps) => { +export const BuildDetails = ({ build }: { build: ModelsBuild }) => { + const theme = useTheme(); + return ( + + + Build Name: {build.name} + + Commit: {build.commit?.slice(0, 8) || 'N/A'} + + + CreatedAt: {new Date(build.createdAt).toLocaleString()} + + + + + + + ); +}; + +export const BuildLogs = ({ + open, + onClose, + build, + enableAutoRefresh = false, +}: BuildLogsProps) => { const classes = useStyles(); const discoveryApi = useApi(discoveryApiRef); const identityApi = useApi(identityApiRef); @@ -52,47 +97,44 @@ export const BuildLogs = ({ open, onClose, build }: BuildLogsProps) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - const fetchBuildLogs = async (selectedBuild: ModelsBuild) => { - setLoading(true); - setError(null); - setLogs([]); + useTimerEffect( + () => { + const fetchBuildLogs = async (selectedBuild: ModelsBuild) => { + setLoading(true); + setError(null); - try { - const logsData = await fetchBuildLogsForBuild( - discoveryApi, - identityApi, - selectedBuild, - ); - setLogs(logsData.logs || []); - } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to fetch build logs', - ); - } finally { - setLoading(false); - } - }; + try { + const logsData = await fetchBuildLogsForBuild( + discoveryApi, + identityApi, + selectedBuild, + ); + setLogs(logsData.logs || []); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to fetch build logs', + ); + } finally { + setLoading(false); + } + }; - if (open && build) { - fetchBuildLogs(build); - } - }, [discoveryApi, identityApi, open, build]); + if (open && build) { + fetchBuildLogs(build); + } + }, + enableAutoRefresh ? 5000 : 0, + [discoveryApi, identityApi, open, build], + ); - const renderLogsContent = () => { - if (loading) { + const renderLogsContent = useCallback(() => { + if (loading && !logs.length) { return ( - - - - Loading logs... - - + } + title="Loading logs..." + description="Please wait while we fetch the logs" + /> ); } @@ -106,23 +148,32 @@ export const BuildLogs = ({ open, onClose, build }: BuildLogsProps) => { if (logs.length === 0) { return ( - - No logs available for this build - + ); } return logs.map((logEntry, index) => ( - - - [{new Date(logEntry.timestamp).toLocaleTimeString()}] + + + [{new Date(logEntry.timestamp).toLocaleTimeString()}]   - + {logEntry.log} )); - }; + }, [loading, logs, error, classes]); return ( { alignItems="center" mb={2} > - - Build Logs - {build?.name || 'Unknown Build'} - + Build Details @@ -156,30 +205,21 @@ export const BuildLogs = ({ open, onClose, build }: BuildLogsProps) => { {build ? ( - - Build Name: {build.name} - - - Status: {build.status} - - - Commit: {build.commit?.slice(0, 8) || 'N/A'} - - - Created: {new Date(build.createdAt).toLocaleString()} - - - + + - Logs: + Build Logs - + {renderLogsContent()} ) : ( - No build selected + )} diff --git a/plugins/openchoreo/src/components/Builds/Builds.tsx b/plugins/openchoreo/src/components/Builds/Builds.tsx index d1ea6035..c482df66 100644 --- a/plugins/openchoreo/src/components/Builds/Builds.tsx +++ b/plugins/openchoreo/src/components/Builds/Builds.tsx @@ -6,15 +6,12 @@ import { } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import ErrorIcon from '@material-ui/icons/Error'; import { Progress, ResponseErrorPanel, Table, TableColumn, - StatusOK, - StatusError, - StatusPending, - StatusRunning, } from '@backstage/core-components'; import { Typography, @@ -34,33 +31,16 @@ import type { ModelsCompleteComponent, } from '@openchoreo/backstage-plugin-api'; import { formatRelativeTime } from '../../utils/timeUtils'; - -const BuildStatusComponent = ({ status }: { status?: string }) => { - if (!status) { - return Unknown; - } - - const normalizedStatus = status.toLowerCase(); - - if ( - normalizedStatus.includes('succeed') || - normalizedStatus.includes('success') - ) { - return Success; - } - - if (normalizedStatus.includes('fail') || normalizedStatus.includes('error')) { - return Failed; - } - - if ( - normalizedStatus.includes('running') || - normalizedStatus.includes('progress') - ) { - return Running; - } - - return {status}; +import { PageBanner } from '../CommonComponents'; +import { BuildStatus } from '../CommonComponents/BuildStatus'; + +const isInProgress = (status: string) => { + return !( + status.toLowerCase().includes('success') || + status.toLowerCase().includes('failed') || + status.toLowerCase().includes('error') || + status.toLowerCase().includes('completed') + ); }; export const Builds = () => { @@ -281,9 +261,7 @@ export const Builds = () => { { title: 'Status', field: 'status', - render: (row: any) => ( - - ), + render: (row: any) => , }, { title: 'Commit', @@ -333,6 +311,15 @@ export const Builds = () => { } }; + if (!entity.metadata.annotations?.['backstage.io/source-location']) { + return ( + } + /> + ); + } return ( {componentDetails && ( @@ -444,15 +431,19 @@ export const Builds = () => { setDrawerOpen(true); }} emptyContent={ - - No builds found for this component. - + } + /> } /> + {isInProgress(selectedBuild?.status || '') ? 'k' : 'y'} setDrawerOpen(false)} build={selectedBuild} + enableAutoRefresh={isInProgress(selectedBuild?.status || '')} /> ); diff --git a/plugins/openchoreo/src/components/CommonComponents/BuildStatus.tsx b/plugins/openchoreo/src/components/CommonComponents/BuildStatus.tsx new file mode 100644 index 00000000..8095fb8e --- /dev/null +++ b/plugins/openchoreo/src/components/CommonComponents/BuildStatus.tsx @@ -0,0 +1,35 @@ +import { Chip, CircularProgress } from '@material-ui/core'; +import { ModelsBuild } from '@openchoreo/backstage-plugin-api'; +import CheckIcon from '@material-ui/icons/Check'; +import ErrorIcon from '@material-ui/icons/Error'; + +export const BuildStatus = ({ build }: { build: ModelsBuild }) => { + const normalizedStatus = build.status?.toLowerCase() ?? ''; + if ( + normalizedStatus.includes('succeed') || + normalizedStatus.includes('success') || + normalizedStatus.includes('completed') + ) { + return ( + } /> + ); + } + if (normalizedStatus.includes('fail') || normalizedStatus.includes('error')) { + return ( + } + /> + ); + } + return ( + } + /> + ); +}; diff --git a/plugins/openchoreo/src/components/CommonComponents/PageBanner.tsx b/plugins/openchoreo/src/components/CommonComponents/PageBanner.tsx new file mode 100644 index 00000000..49843224 --- /dev/null +++ b/plugins/openchoreo/src/components/CommonComponents/PageBanner.tsx @@ -0,0 +1,36 @@ +import { Box, Typography } from '@material-ui/core'; +import ErrorIcon from '@material-ui/icons/Error'; + +export interface PageBannerProps { + title: string; + description: string; + icon?: React.ReactNode; + transparent?: boolean; +} +const PageBanner = ({ + title, + description, + icon, + transparent, +}: PageBannerProps) => { + return ( + + {icon || } + {title} + + {description} + + + ); +}; + +export default PageBanner; diff --git a/plugins/openchoreo/src/components/CommonComponents/index.ts b/plugins/openchoreo/src/components/CommonComponents/index.ts new file mode 100644 index 00000000..9ad92161 --- /dev/null +++ b/plugins/openchoreo/src/components/CommonComponents/index.ts @@ -0,0 +1 @@ +export { default as PageBanner } from './PageBanner'; diff --git a/plugins/openchoreo/src/components/Environments/EnvCard/EnvCard.tsx b/plugins/openchoreo/src/components/Environments/EnvCard/EnvCard.tsx new file mode 100644 index 00000000..721a7821 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/EnvCard/EnvCard.tsx @@ -0,0 +1,491 @@ +import React from 'react'; +import { + Card, + CardContent, + Typography, + Box, + Button, + IconButton, +} from '@material-ui/core'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; +import FileCopyIcon from '@material-ui/icons/FileCopy'; +import AccessTimeIcon from '@material-ui/icons/AccessTime'; +import Refresh from '@material-ui/icons/Refresh'; +import { Entity } from '@backstage/catalog-model'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + promoteToEnvironment, + updateComponentBinding, +} from '../../../api/environments'; +import { formatRelativeTime } from '../../../utils/timeUtils'; +import { Alert } from '@material-ui/lab'; + +const useStyles = makeStyles(theme => ({ + deploymentStatusBox: { + padding: theme.spacing(1), + borderRadius: theme.shape.borderRadius, + marginTop: theme.spacing(2), + }, + successStatus: { + backgroundColor: theme.palette.success.light, + color: theme.palette.success.dark, + }, + errorStatus: { + backgroundColor: theme.palette.error.light, + color: theme.palette.error.dark, + }, + warningStatus: { + backgroundColor: theme.palette.warning.light, + color: theme.palette.warning.dark, + }, + defaultStatus: { + backgroundColor: theme.palette.background.paper, + color: theme.palette.text.primary, + }, + imageContainer: { + backgroundColor: theme.palette.background.default, + padding: theme.spacing(1.5), + borderRadius: theme.spacing(0.5), + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[0], + marginTop: theme.spacing(0.5), + }, + endpointLink: { + color: theme.palette.primary.main, + textDecoration: 'underline', + display: 'block', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: '0.875rem', + }, + timeIcon: { + fontSize: '1rem', + color: theme.palette.text.secondary, + marginLeft: theme.spacing(1), + marginRight: theme.spacing(1), + }, +})); + +interface EndpointInfo { + name: string; + type: string; + url: string; + visibility: 'project' | 'organization' | 'public'; +} + +interface Environment { + name: string; + bindingName?: string; + deployment: { + status: 'success' | 'failed' | 'pending' | 'not-deployed' | 'suspended'; + lastDeployed?: string; + image?: string; + statusMessage?: string; + }; + endpoints: EndpointInfo[]; + promotionTargets?: { + name: string; + requiresApproval?: boolean; + isManualApprovalRequired?: boolean; + }[]; +} + +interface EnvCardProps { + env: Environment; + entity: Entity; + discovery: DiscoveryApi; + identityApi: IdentityApi; + promotingTo: string | null; + setPromotingTo: (value: string | null) => void; + updatingBinding: string | null; + setUpdatingBinding: (value: string | null) => void; + setEnvironmentsData: (data: Environment[]) => void; + setNotification: ( + notification: { message: string; type: 'success' | 'error' } | null, + ) => void; + fetchEnvironmentsData: () => Promise; +} + +export const EnvCard: React.FC = ({ + env, + entity, + discovery, + identityApi, + promotingTo, + setPromotingTo, + updatingBinding, + setUpdatingBinding, + setEnvironmentsData, + setNotification, + fetchEnvironmentsData, +}) => { + const classes = useStyles(); + const theme = useTheme(); + const getStatusLevel = (status: string) => { + switch (status) { + case 'success': + return 'success'; + case 'failed': + return 'error'; + case 'pending': + return 'warning'; + default: + return 'info'; + } + }; + + // if (env.deployment.status === 'success') return 'Active'; + // if (env.deployment.status === 'pending') return 'Pending'; + // if (env.deployment.status === 'not-deployed') return 'Not Deployed'; + // if (env.deployment.status === 'suspended') return 'Suspended'; + // return 'Failed'; + const getStatusMessage = (status: string) => { + switch (status) { + case 'success': + return 'Active'; + case 'pending': + return 'Pending'; + case 'suspended': + return 'Suspended'; + case 'not-deployed': + return 'Not Deployed'; + default: + return 'Failed'; + } + }; + + return ( + + + + + + {env.name} + + fetchEnvironmentsData()}> + + + + {/* add a line in the ui */} + + {env.deployment.lastDeployed && ( + + + Deployed + + + + {formatRelativeTime(env.deployment.lastDeployed)} + + + )} + {env.deployment.status && ( + + + Deployment Status:   + + {getStatusMessage(env.deployment.status)} + + + + )} + {env.deployment.statusMessage && ( + + + {env.deployment.statusMessage} + + + )} + + {env.deployment.image && ( + <> + + + Image + + + + + {env.deployment.image} + + + + )} + + {env.deployment.status === 'success' && env.endpoints.length > 0 && ( + <> + + + Endpoints + + + {env.endpoints.map((endpoint, index) => ( + + + + {endpoint.url} + + + + { + navigator.clipboard.writeText(endpoint.url); + // You could add a toast notification here + }} + > + + + + + ))} + + )} + + {/* Actions section - show if deployment is successful or suspended */} + {((env.deployment.status === 'success' && + env.promotionTargets && + env.promotionTargets.length > 0) || + ((env.deployment.status === 'success' || + env.deployment.status === 'suspended') && + env.bindingName)) && ( + + {/* Multiple promotion targets - stack vertically */} + {env.deployment.status === 'success' && + env.promotionTargets && + env.promotionTargets.length > 1 && + env.promotionTargets.map((target, index) => ( + { + if (index < env.promotionTargets!.length - 1) return 2; + if ( + (env.deployment.status === 'success' || + env.deployment.status === 'suspended') && + env.bindingName + ) + return 2; + return 0; + })()} + > + + + ))} + + {/* Single promotion target and suspend button - show in same row */} + {((env.deployment.status === 'success' && + env.promotionTargets && + env.promotionTargets.length === 1) || + ((env.deployment.status === 'success' || + env.deployment.status === 'suspended') && + env.bindingName)) && ( + + {/* Single promotion button */} + {env.deployment.status === 'success' && + env.promotionTargets && + env.promotionTargets.length === 1 && ( + + )} + + {/* Suspend/Re-deploy button */} + {(env.deployment.status === 'success' || + env.deployment.status === 'suspended') && + env.bindingName && ( + + )} + + )} + + )} + + + + ); +}; diff --git a/plugins/openchoreo/src/components/Environments/EnvCard/index.ts b/plugins/openchoreo/src/components/Environments/EnvCard/index.ts new file mode 100644 index 00000000..d7fa6b01 --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/EnvCard/index.ts @@ -0,0 +1 @@ +export { EnvCard } from './EnvCard'; diff --git a/plugins/openchoreo/src/components/Environments/Environments.tsx b/plugins/openchoreo/src/components/Environments/Environments.tsx index eb07ccc8..99b132c3 100644 --- a/plugins/openchoreo/src/components/Environments/Environments.tsx +++ b/plugins/openchoreo/src/components/Environments/Environments.tsx @@ -3,29 +3,21 @@ import { useCallback, useEffect, useState } from 'react'; import { useEntity } from '@backstage/plugin-catalog-react'; import { Content, Page } from '@backstage/core-components'; import { - Grid, Card, CardContent, Typography, Box, - Button, - IconButton, + Collapse, + useTheme, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; -import FileCopyIcon from '@material-ui/icons/FileCopy'; -import AccessTimeIcon from '@material-ui/icons/AccessTime'; import { discoveryApiRef, identityApiRef, useApi, } from '@backstage/core-plugin-api'; -import { - fetchEnvironmentInfo, - promoteToEnvironment, - updateComponentBinding, -} from '../../api/environments'; -import { formatRelativeTime } from '../../utils/timeUtils'; +import { fetchEnvironmentInfo } from '../../api/environments'; interface EndpointInfo { name: string; @@ -34,7 +26,9 @@ interface EndpointInfo { visibility: 'project' | 'organization' | 'public'; } import { Workload } from './Workload/Workload'; -import Refresh from '@material-ui/icons/Refresh'; +import { EnvCard } from './EnvCard'; +import { useTimerEffect } from '../../hooks/timerEffect'; +import { Alert } from '@material-ui/lab'; const useStyles = makeStyles(theme => ({ notificationBox: { @@ -59,50 +53,6 @@ const useStyles = makeStyles(theme => ({ padding: theme.spacing(1), borderRadius: theme.shape.borderRadius, }, - deploymentStatusBox: { - padding: theme.spacing(1), - borderRadius: theme.shape.borderRadius, - marginTop: theme.spacing(2), - }, - successStatus: { - backgroundColor: theme.palette.success.light, - color: theme.palette.success.dark, - }, - errorStatus: { - backgroundColor: theme.palette.error.light, - color: theme.palette.error.dark, - }, - warningStatus: { - backgroundColor: theme.palette.warning.light, - color: theme.palette.warning.dark, - }, - defaultStatus: { - backgroundColor: theme.palette.background.paper, - color: theme.palette.text.primary, - }, - imageContainer: { - backgroundColor: theme.palette.background.paper, - padding: theme.spacing(1.5), - borderRadius: theme.spacing(3), - border: `1px solid ${theme.palette.divider}`, - boxShadow: theme.shadows[2], - marginTop: theme.spacing(1), - }, - endpointLink: { - color: theme.palette.primary.main, - textDecoration: 'underline', - display: 'block', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - fontSize: '0.875rem', - }, - timeIcon: { - fontSize: '1rem', - color: theme.palette.text.secondary, - marginLeft: theme.spacing(1), - marginRight: theme.spacing(1), - }, })); interface Environment { @@ -129,13 +79,14 @@ export const Environments = () => { const [loading, setLoading] = useState(true); const [promotingTo, setPromotingTo] = useState(null); const [updatingBinding, setUpdatingBinding] = useState(null); + const [isWorkloadEditorOpen, setIsWorkloadEditorOpen] = useState(false); const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error'; } | null>(null); const discovery = useApi(discoveryApiRef); const identityApi = useApi(identityApiRef); - + const theme = useTheme(); const fetchEnvironmentsData = useCallback(async () => { try { setLoading(true); @@ -159,21 +110,11 @@ export const Environments = () => { env => env.deployment.status === 'pending', ); - useEffect(() => { - let intervalId: NodeJS.Timeout; - - if (isPending) { - intervalId = setInterval(() => { - fetchEnvironmentsData(); - }, 10000); // 10 seconds - } - - return () => { - if (intervalId) { - clearInterval(intervalId); - } - }; - }, [isPending, fetchEnvironmentsData]); + useTimerEffect( + fetchEnvironmentsData, + isWorkloadEditorOpen || !isPending ? 0 : 10000, + [isPending, fetchEnvironmentsData], + ); if (loading && !isPending) { return ( @@ -193,425 +134,74 @@ export const Environments = () => { } return ( - - - {notification && ( - - - {notification.type === 'success' ? '✓ ' : '✗ '} - {notification.message} - - - )} - - - - {/* Make this card color different from the others */} - - - - Set up - - - - - View and manage deployment environments - - {isWorkloadEditorSupported && !loading && ( - - )} - - - - - {environments.map(env => ( - - - - - - {env.name} - - fetchEnvironmentsData()}> - - - - {/* add a line in the ui */} - + + + {notification?.message} + + + + + + {/* Make this card color different from the others */} + + + + Set up + + + + + View and manage deployment environments + + {isWorkloadEditorSupported && !loading && ( + - {env.deployment.lastDeployed && ( - - - Deployed - - - - {formatRelativeTime(env.deployment.lastDeployed)} - - - )} - - - Deployment Status:{' '} - - {env.deployment.status === 'success' - ? 'Active' - : env.deployment.status === 'pending' - ? 'Pending' - : env.deployment.status === 'not-deployed' - ? 'Not Deployed' - : env.deployment.status === 'suspended' - ? 'Suspended' - : 'Failed'} - - - - {env.deployment.statusMessage && ( - - - {env.deployment.statusMessage} - - - )} - - {env.deployment.image && ( - <> - - - Image - - - - - {env.deployment.image} - - - - )} - - {env.deployment.status === 'success' && - env.endpoints.length > 0 && ( - <> - - - Endpoints - - - {env.endpoints.map((endpoint, index) => ( - - - - {endpoint.url} - - - - { - navigator.clipboard.writeText(endpoint.url); - // You could add a toast notification here - }} - > - - - - - ))} - - )} - - {/* Actions section - show if deployment is successful or suspended */} - {((env.deployment.status === 'success' && - env.promotionTargets && - env.promotionTargets.length > 0) || - ((env.deployment.status === 'success' || - env.deployment.status === 'suspended') && - env.bindingName)) && ( - - {/* Multiple promotion targets - stack vertically */} - {env.deployment.status === 'success' && - env.promotionTargets && - env.promotionTargets.length > 1 && - env.promotionTargets.map((target, index) => ( - - - - ))} - - {/* Single promotion target and suspend button - show in same row */} - {((env.deployment.status === 'success' && - env.promotionTargets && - env.promotionTargets.length === 1) || - ((env.deployment.status === 'success' || - env.deployment.status === 'suspended') && - env.bindingName)) && ( - - {/* Single promotion button */} - {env.deployment.status === 'success' && - env.promotionTargets && - env.promotionTargets.length === 1 && ( - - )} - - {/* Suspend/Re-deploy button */} - {(env.deployment.status === 'success' || - env.deployment.status === 'suspended') && - env.bindingName && ( - - )} - - )} - - )} - - - - ))} - - - + )} + + + + + {environments.map(env => ( + + ))} + + + // + // ); }; diff --git a/plugins/openchoreo/src/components/Environments/Workload/Workload.tsx b/plugins/openchoreo/src/components/Environments/Workload/Workload.tsx index d4fdb574..1a6a152b 100644 --- a/plugins/openchoreo/src/components/Environments/Workload/Workload.tsx +++ b/plugins/openchoreo/src/components/Environments/Workload/Workload.tsx @@ -6,6 +6,7 @@ import { useTheme, IconButton, CircularProgress, + Divider, } from '@material-ui/core'; import { useEffect, useState } from 'react'; import { WorkloadEditor } from './WorkloadEditor'; @@ -22,15 +23,18 @@ import { WorkloadProvider } from './WorkloadContext'; export function Workload({ onDeployed, isWorking, + isOpen, + onOpenChange, }: { onDeployed: () => Promise; isWorking: boolean; + isOpen: boolean; + onOpenChange: (open: boolean) => void; }) { const discovery = useApi(discoveryApiRef); const identity = useApi(identityApiRef); const { entity } = useEntity(); const theme = useTheme(); - const [open, setOpen] = useState(false); const [workloadSpec, setWorkloadSpec] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isDeploying, setIsDeploying] = useState(false); @@ -40,7 +44,6 @@ export function Workload({ useEffect(() => { const fetchWorkload = async () => { try { - setIsLoading(true); const response = await fetchWorkloadInfo(entity, discovery, identity); setWorkloadSpec(response); } catch (e) { @@ -50,6 +53,7 @@ export function Workload({ }; fetchWorkload(); return () => { + setIsLoading(true); setWorkloadSpec(null); setError(null); }; @@ -99,7 +103,7 @@ export function Workload({ }, [entity.metadata.name, entity.metadata.annotations, identity, discovery]); const toggleDrawer = () => { - setOpen(!open); + onOpenChange(!isOpen); }; const handleDeploy = async () => { @@ -111,7 +115,7 @@ export function Workload({ await applyWorkload(entity, discovery, identity, workloadSpec); setTimeout(async () => { await onDeployed(); - setOpen(false); + onOpenChange(false); }, 3000); } catch (e) { setIsDeploying(false); @@ -121,7 +125,7 @@ export function Workload({ const enableDeploy = (workloadSpec || builds.some(build => build.image)) && !isLoading; - const hasBuils = builds.length > 0 || workloadSpec; + const hasBuilds = builds.length > 0 || workloadSpec; return ( <> @@ -139,9 +143,9 @@ export function Workload({ > {isLoading && !error && } - {!enableDeploy && ( - - {!hasBuils ? error : 'Build your application first.'} + {!enableDeploy && !isWorking && !isDeploying && ( + + {!hasBuilds ? error : 'Build your application first.'} )} diff --git a/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/index.ts b/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/index.ts index 32e39532..a303ca1e 100644 --- a/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/index.ts +++ b/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/index.ts @@ -2,3 +2,4 @@ export { WorkloadEditor } from './WorkloadEditor'; export { ContainerSection } from './ContainerSection'; export { EndpointSection } from './EndpointSection'; export { ConnectionSection } from './ConnectionSection'; +export { useWorkloadEditorStyles } from './styles'; diff --git a/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/styles.ts b/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/styles.ts new file mode 100644 index 00000000..ac004f3c --- /dev/null +++ b/plugins/openchoreo/src/components/Environments/Workload/WorkloadEditor/styles.ts @@ -0,0 +1,44 @@ +import { makeStyles } from '@material-ui/core/styles'; + +export const useWorkloadEditorStyles = makeStyles(theme => ({ + // Accordion styles + accordion: { + // No margin bottom for all accordions + '&.Mui-expanded': { + margin: 0, + }, + }, + + // Container styles for dynamic fields (cards) + dynamicFieldContainer: { + padding: theme.spacing(2), + marginBottom: theme.spacing(2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + }, + + // Environment variable container styles + envVarContainer: { + padding: theme.spacing(1), + border: `1px dashed ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + marginBottom: theme.spacing(1), + }, + + // Button styles + addButton: { + marginTop: theme.spacing(1), + }, + + // Common layout utilities + fullWidth: { + width: '100%', + }, + + // Flex utilities + flexBetween: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, +})); diff --git a/plugins/openchoreo/src/components/RuntimeLogs/LogEntry.tsx b/plugins/openchoreo/src/components/RuntimeLogs/LogEntry.tsx index 3feb8eb2..ccbcbad0 100644 --- a/plugins/openchoreo/src/components/RuntimeLogs/LogEntry.tsx +++ b/plugins/openchoreo/src/components/RuntimeLogs/LogEntry.tsx @@ -3,16 +3,16 @@ import { TableRow, TableCell, Typography, - Chip, Box, Collapse, IconButton, Tooltip, + Chip, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import ExpandMore from '@material-ui/icons/ExpandMore'; import ExpandLess from '@material-ui/icons/ExpandLess'; -import FileCopy from '@material-ui/icons/FileCopy'; +import FileCopy from '@material-ui/icons/FileCopyOutlined'; import { LogEntry as LogEntryType } from './types'; const useStyles = makeStyles(theme => ({ @@ -22,45 +22,45 @@ const useStyles = makeStyles(theme => ({ }, cursor: 'pointer', }, + cellText: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + width: theme.spacing(9), + display: 'block', + fontFamily: 'monospace', + fontSize: '0.8rem', + }, expandedRow: { backgroundColor: theme.palette.action.selected, }, - timestampCell: { - fontFamily: 'monospace', - fontSize: '0.85rem', - whiteSpace: 'nowrap', - width: '140px', - }, - logLevelChip: { - fontSize: '0.75rem', - fontWeight: 'bold', - minWidth: '60px', + errorRow: { + color: theme.palette.error.dark, }, - errorChip: { - backgroundColor: theme.palette.error.main, - color: theme.palette.error.contrastText, + warnRow: { + color: theme.palette.warning.dark, }, - warnChip: { - backgroundColor: theme.palette.warning.main, - color: theme.palette.warning.contrastText, + infoRow: { + color: theme.palette.info.dark, }, - infoChip: { - backgroundColor: theme.palette.info.main, - color: theme.palette.info.contrastText, + debugRow: { + color: theme.palette.text.primary, }, - debugChip: { - backgroundColor: theme.palette.action.disabled, + undefinedRow: { color: theme.palette.text.secondary, }, - undefinedChip: { - backgroundColor: theme.palette.action.disabledBackground, - color: theme.palette.text.disabled, + timestampCell: { + fontFamily: 'monospace', + whiteSpace: 'nowrap', + fontSize: '0.8rem', + width: '140px', }, + logMessage: { fontFamily: 'monospace', - fontSize: '0.875rem', + fontSize: '0.8rem', wordBreak: 'break-word', - maxWidth: '400px', + maxWidth: 'calc(100vw - 920px)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -70,22 +70,15 @@ const useStyles = makeStyles(theme => ({ maxWidth: 'none', overflow: 'visible', }, - containerCell: { - fontSize: '0.875rem', - color: theme.palette.text.secondary, - }, podCell: { - fontSize: '0.875rem', - color: theme.palette.text.secondary, fontFamily: 'monospace', + fontSize: '0.8rem', }, expandButton: { padding: theme.spacing(0.5), }, expandedContent: { - padding: theme.spacing(2), - backgroundColor: theme.palette.background.default, - borderRadius: theme.shape.borderRadius, + padding: theme.spacing(2, 0), }, metadataSection: { marginTop: theme.spacing(2), @@ -105,16 +98,16 @@ const useStyles = makeStyles(theme => ({ }, metadataValue: { fontFamily: 'monospace', - fontSize: '0.875rem', - color: theme.palette.text.secondary, + fontSize: '0.8rem', }, copyButton: { padding: theme.spacing(0.5), marginLeft: theme.spacing(1), + color: theme.palette.text.hint, }, fullLogMessage: { fontFamily: 'monospace', - fontSize: '0.875rem', + fontSize: '0.8rem', whiteSpace: 'pre-wrap', backgroundColor: theme.palette.background.paper, padding: theme.spacing(1), @@ -132,19 +125,18 @@ interface LogEntryProps { export const LogEntry: FC = ({ log }) => { const classes = useStyles(); const [expanded, setExpanded] = useState(false); - - const getLogLevelChipClass = (level: string) => { + const getLogLevelRowClass = (level: string) => { switch (level) { case 'ERROR': - return classes.errorChip; + return classes.errorRow; case 'WARN': - return classes.warnChip; + return classes.warnRow; case 'INFO': - return classes.infoChip; + return classes.infoRow; case 'DEBUG': - return classes.debugChip; + return classes.debugRow; case 'UNDEFINED': - return classes.undefinedChip; + return classes.undefinedRow; default: return ''; } @@ -154,10 +146,6 @@ export const LogEntry: FC = ({ log }) => { return new Date(timestamp).toLocaleString(); }; - const truncatePodId = (podId: string) => { - return podId.length > 8 ? `${podId.substring(0, 8)}...` : podId; - }; - const handleCopyLog = (event: MouseEvent) => { event.stopPropagation(); navigator.clipboard.writeText(log.log).catch(_ => { @@ -175,27 +163,36 @@ export const LogEntry: FC = ({ log }) => { className={`${classes.logRow} ${expanded ? classes.expandedRow : ''}`} onClick={handleRowClick} > - + {formatTimestamp(log.timestamp)} - - + + + {log.containerName} + - + + + {log.podId} + + + - - {log.log} - + {log.log} = ({ log }) => { - - {log.containerName} - - - - {truncatePodId(log.podId)} - - - + = ({ log }) => { - - {expanded && ( - - - - - + + + + + + Full Log Message + {log.log} + - - - Metadata + + Metadata + + + Log Level: + + {log.logLevel} + + + + + Component: + + + {log.componentId} + + + + + Environment: + + + {log.environmentId} + + - - Component: - - {log.componentId} - - - - - Environment: - - {log.environmentId} - - + + + Project: + + + {log.projectId} + + - - Project: - - {log.projectId} - - + + + Namespace: + + + {log.namespace} + + - - Namespace: - - {log.namespace} - - + + + Pod ID: + + + {log.podId} + + - - Pod ID: - {log.podId} - + + + Container: + + + {log.containerName} + + + {log.version && ( - Container: - - {log.containerName} - + + Version: + + + {log.version} + + )} - {log.version && ( - - Version: - - {log.version} - - - )} - - {Object.keys(log.labels).length > 0 && ( - <> - - Labels - + {Object.keys(log.labels).length > 0 && ( + + + Labels + + {Object.entries(log.labels).map(([key, value]) => ( - - {key}: - {value} - + + + {key}: + + {value} + + } + /> ))} - - )} - + + + )} - - - - )} + + + + ); }; diff --git a/plugins/openchoreo/src/components/RuntimeLogs/LogsFilter.tsx b/plugins/openchoreo/src/components/RuntimeLogs/LogsFilter.tsx index f6ba7f86..4eaf18ca 100644 --- a/plugins/openchoreo/src/components/RuntimeLogs/LogsFilter.tsx +++ b/plugins/openchoreo/src/components/RuntimeLogs/LogsFilter.tsx @@ -4,14 +4,16 @@ import { InputLabel, Select, MenuItem, - FormGroup, - FormControlLabel, - Checkbox, - Typography, - Paper, Grid, + Box, + Chip, + IconButton, + Tooltip, + Typography, } from '@material-ui/core'; -import { Skeleton } from '@material-ui/lab'; +import Refresh from '@material-ui/icons/Refresh'; +import { Skeleton, Autocomplete } from '@material-ui/lab'; +import { TextField } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import { RuntimeLogsFilters, @@ -24,36 +26,44 @@ const useStyles = makeStyles(theme => ({ filterContainer: { padding: theme.spacing(2), marginBottom: theme.spacing(2), + backgroundColor: theme.palette.background.default, }, filterSection: { - marginBottom: theme.spacing(2), + // marginBottom: theme.spacing(2), + }, + refreshButtonContainer: { + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', }, - filterTitle: { - marginBottom: theme.spacing(1), - fontWeight: 'bold', + refreshButton: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), }, - logLevelCheckbox: { - padding: theme.spacing(0.5), + levelSelectorChip: { + marginTop: 2, + marginBottom: -2, }, errorLevel: { color: theme.palette.error.main, - fontWeight: 500, + borderColor: theme.palette.error.main, }, warnLevel: { color: theme.palette.warning.main, - fontWeight: 500, + borderColor: theme.palette.warning.main, }, infoLevel: { color: theme.palette.info.main, - fontWeight: 500, + borderColor: theme.palette.info.main, }, debugLevel: { color: theme.palette.text.secondary, - fontWeight: 500, + borderColor: theme.palette.text.secondary, }, undefinedLevel: { color: theme.palette.text.disabled, - fontWeight: 500, + borderColor: theme.palette.text.disabled, }, })); @@ -63,6 +73,8 @@ interface LogsFilterProps { environments: Environment[]; environmentsLoading: boolean; disabled?: boolean; + onRefresh?: () => void; + isRefreshing?: boolean; } export const LogsFilter: FC = ({ @@ -71,15 +83,15 @@ export const LogsFilter: FC = ({ environments, environmentsLoading, disabled = false, + onRefresh, + isRefreshing = false, }) => { const classes = useStyles(); - const handleLogLevelChange = (level: string) => { - const newLogLevels = filters.logLevel.includes(level) - ? filters.logLevel.filter(l => l !== level) - : [...filters.logLevel, level]; - - onFiltersChange({ logLevel: newLogLevels }); + const handleLogLevelChange = (_event: any, newValue: string[]) => { + // If no options are selected, consider it as all selected + const selectedLevels = newValue.length === 0 ? [] : newValue; + onFiltersChange({ logLevel: selectedLevels }); }; const handleEnvironmentChange = (event: ChangeEvent<{ value: unknown }>) => { @@ -106,76 +118,145 @@ export const LogsFilter: FC = ({ return ''; } }; - + const getLogLevelDisplayName = (level: string) => { + switch (level) { + case 'ERROR': + return 'Error'; + case 'WARN': + return 'Warn'; + case 'INFO': + return 'Info'; + case 'DEBUG': + return 'Debug'; + case 'UNDEFINED': + return classes.undefinedLevel; + default: + return ''; + } + }; return ( - - - -
- - Log Levels - - - {LOG_LEVELS.map(level => ( - handleLogLevelChange(level)} - disabled={disabled} - className={classes.logLevelCheckbox} - /> - } + + + + + value.map((option, index) => ( + {level} + + {getLogLevelDisplayName(option)} + } + size="small" + variant="outlined" + className={classes.levelSelectorChip} /> - ))} - -
-
- - -
- - Environment - {environmentsLoading ? ( - - ) : ( - - )} - -
-
+ )) + } + renderOption={option => ( + + {getLogLevelDisplayName(option)} + + )} + renderInput={params => ( + + )} + noOptionsText="No log levels available" + /> + +
- -
- - Time Range + + + + Environment + {environmentsLoading ? ( + + ) : ( - -
-
+ )} + + -
+ + + + + Time Range + + + + + {onRefresh && ( + + + + + + + + + + )} + ); }; diff --git a/plugins/openchoreo/src/components/RuntimeLogs/LogsTable.tsx b/plugins/openchoreo/src/components/RuntimeLogs/LogsTable.tsx index 1fc50aae..2053f998 100644 --- a/plugins/openchoreo/src/components/RuntimeLogs/LogsTable.tsx +++ b/plugins/openchoreo/src/components/RuntimeLogs/LogsTable.tsx @@ -14,10 +14,12 @@ import { Skeleton } from '@material-ui/lab'; import { makeStyles } from '@material-ui/core/styles'; import { LogEntry as LogEntryType } from './types'; import { LogEntry } from './LogEntry'; +import { PageBanner } from '../CommonComponents'; +import InfoRounded from '@material-ui/icons/InfoRounded'; const useStyles = makeStyles(theme => ({ tableContainer: { - maxHeight: '70vh', + height: 'calc(100vh - 380px)', overflow: 'auto', }, table: { @@ -47,6 +49,9 @@ const useStyles = makeStyles(theme => ({ skeletonRow: { height: 60, }, + emptyStateCell: { + backgroundColor: 'transparent !important', + }, })); interface LogsTableProps { @@ -73,9 +78,6 @@ export const LogsTable: FC = ({ - - - @@ -96,17 +98,21 @@ export const LogsTable: FC = ({ if (loading) { return null; } - return ( - - - - - No logs found - - - Try adjusting your filters or time range to see more logs. - + + + + } + /> @@ -116,14 +122,13 @@ export const LogsTable: FC = ({ return ( - +
Timestamp - Level - Message Container Pod + Message Details @@ -140,7 +145,7 @@ export const LogsTable: FC = ({ {hasMore && ( - +
{loading ? ( diff --git a/plugins/openchoreo/src/components/RuntimeLogs/RuntimeLogs.tsx b/plugins/openchoreo/src/components/RuntimeLogs/RuntimeLogs.tsx index 4102142f..bb8ba4eb 100644 --- a/plugins/openchoreo/src/components/RuntimeLogs/RuntimeLogs.tsx +++ b/plugins/openchoreo/src/components/RuntimeLogs/RuntimeLogs.tsx @@ -1,8 +1,7 @@ import { useEffect, useState, useRef } from 'react'; -import { Box, Typography, Button, Paper } from '@material-ui/core'; +import { Box, Typography, Button, Card, CardContent } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import { makeStyles } from '@material-ui/core/styles'; -import Refresh from '@material-ui/icons/Refresh'; import { LogsFilter } from './LogsFilter'; import { LogsTable } from './LogsTable'; import { @@ -15,7 +14,10 @@ import { RuntimeLogsPagination } from './types'; const useStyles = makeStyles(theme => ({ root: { - padding: theme.spacing(3), + padding: theme.spacing(0, 3), + gap: theme.spacing(2), + display: 'flex', + flexDirection: 'column', }, header: { display: 'flex', @@ -26,11 +28,7 @@ const useStyles = makeStyles(theme => ({ title: { fontWeight: 'bold', }, - refreshButton: { - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), - }, + errorContainer: { marginBottom: theme.spacing(2), }, @@ -156,28 +154,19 @@ export const RuntimeLogs = () => { return ( - - - Runtime Logs - - - - - + + + + + {logsError && renderError(logsError)} @@ -194,36 +183,6 @@ export const RuntimeLogs = () => { {filters.environmentId && ( <> - {totalCount > 0 && ( - - - - Total logs found: - - - {totalCount.toLocaleString()} - - - - - Environment: - - - {environments.find(env => env.id === filters.environmentId) - ?.name || filters.environmentId} - - - - - Time range: - - - {filters.timeRange} - - - - )} - void, + delay: number, + dependencies: any[], +) { + const savedCallback = useRef<() => void>(); + + useEffect(() => { + savedCallback.current = callback; + }, [callback]); + + useEffect(() => { + function tick() { + savedCallback.current?.(); + } + tick(); + if (delay > 0) { + const id = setInterval(tick, delay); + return () => clearInterval(id); + } + return () => {}; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [delay, ...dependencies]); +}