diff --git a/web-server/src/content/DoraMetrics/DoraMetricsDuration.tsx b/web-server/src/content/DoraMetrics/DoraMetricsDuration.tsx new file mode 100644 index 000000000..938fe3324 --- /dev/null +++ b/web-server/src/content/DoraMetrics/DoraMetricsDuration.tsx @@ -0,0 +1,182 @@ +import { + CheckCircleRounded, + AccessTimeRounded, + OpenInNew, + Code, + ArrowForwardIosRounded +} from '@mui/icons-material'; +import { Card, Tooltip, useTheme } from '@mui/material'; +import { FC, useMemo } from 'react'; + +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { Deployment } from '@/types/resources'; +import { getDurationString, isoDateString } from '@/utils/date'; + +type DeploymentCardType = 'Longest' | 'Shortest'; + +interface DeploymentCardProps { + deployment: Deployment; + type: DeploymentCardType; +} + +interface DoraMetricsDurationProps { + deployments: Deployment[]; +} + +const formatDeploymentDate = (dateString: string): string => { + if (!dateString) return 'Unknown Date'; + + try { + const date = new Date(dateString); + const isoDate = isoDateString(date); + const formattedDate = new Date(isoDate); + + const options: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true + }; + + return formattedDate.toLocaleDateString('en-US', options); + } catch (error) { + console.error('Error formatting date:', error); + return 'Invalid Date'; + } +}; + +const DeploymentCard: FC = ({ deployment }) => { + const theme = useTheme(); + + const handleDeploymentClick = () => { + if (deployment.html_url) { + window.open(deployment.html_url, '_blank', 'noopener,noreferrer'); + } + }; + + return ( + + + + + + + Run On {formatDeploymentDate(deployment.conducted_at)} + + + + + + + + + + + + + + {deployment.pr_count || 0} new PR's + + + {deployment.head_branch || 'Unknown Branch'} + + + + {getDurationString(deployment.run_duration)} + + + + + + + ); +}; + +export const DoraMetricsDuration: FC = ({ + deployments +}) => { + const { longestDeployment, shortestDeployment } = useMemo(() => { + if (!Array.isArray(deployments) || !deployments.length) { + return { longestDeployment: null, shortestDeployment: null }; + } + + const validDeployments = deployments + .filter((d): d is Deployment => + Boolean(d.conducted_at && typeof d.run_duration === 'number') + ) + .filter((d) => d.run_duration >= 0); + + if (!validDeployments.length) { + return { longestDeployment: null, shortestDeployment: null }; + } + + // Function to calculate Longest and shortest deployments + const { longest, shortest } = validDeployments.reduce( + (acc, current) => ({ + longest: + !acc.longest || current.run_duration > acc.longest.run_duration + ? current + : acc.longest, + shortest: + !acc.shortest || current.run_duration < acc.shortest.run_duration + ? current + : acc.shortest + }), + { + longest: null as Deployment | null, + shortest: null as Deployment | null + } + ); + + return { longestDeployment: longest, shortestDeployment: shortest }; + }, [deployments]); + + return ( + + + + + Longest Deployment + + + + + + + Shortest Deployment + + + + + + ); +}; diff --git a/web-server/src/content/DoraMetrics/DoraMetricsTrend.tsx b/web-server/src/content/DoraMetrics/DoraMetricsTrend.tsx new file mode 100644 index 000000000..abc22cc98 --- /dev/null +++ b/web-server/src/content/DoraMetrics/DoraMetricsTrend.tsx @@ -0,0 +1,457 @@ +import { + TrendingDownRounded, + TrendingFlatRounded, + TrendingUpRounded +} from '@mui/icons-material'; +import { darken, useTheme } from '@mui/material'; +import { FC, useMemo } from 'react'; + +import { Chart2, ChartSeries } from '@/components/Chart2'; +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { useSelector } from '@/store'; +import { Deployment } from '@/types/resources'; +import { percentageToMultiplier } from '@/utils/datatype'; + +const MEANINGFUL_CHANGE_THRESHOLD = 0.5; + +interface TrendData { + value: number; + change: number; + size?: string; + state: 'positive' | 'negative' | 'neutral'; +} + +const getDeploymentDurationInSeconds = (deployment: Deployment): number => { + if ( + deployment.id?.startsWith('WORKFLOW') && + typeof deployment.run_duration === 'number' && + deployment.run_duration > 0 + ) { + return deployment.run_duration; + } + + try { + const conductedAt = new Date(deployment.conducted_at); + const createdAt = new Date(deployment.created_at); + if (isNaN(conductedAt.getTime()) || isNaN(createdAt.getTime())) { + return 0; + } + const durationMs = conductedAt.getTime() - createdAt.getTime(); + return Math.max(0, Math.floor(durationMs / 1000)); + } catch (e) { + console.error('Error calculating deployment duration', e); + return 0; + } +}; + +const getDeploymentDurationInMinutes = (deployment: Deployment): number => { + const seconds = getDeploymentDurationInSeconds(deployment); + return +(seconds / 60).toFixed(2); +}; + +export const calculateDeploymentTrends = ( + deployments: Deployment[] +): { + durationTrend: TrendData; + prCountTrend: TrendData; +} => { + if (!deployments || deployments.length < 2) { + return { + durationTrend: { value: 0, change: 0, state: 'neutral' }, + prCountTrend: { value: 0, change: 0, state: 'neutral' } + }; + } + + // Filter valid deployments early + const validDeployments = deployments.filter((dep) => { + const hasValidDates = + dep.conducted_at && + new Date(dep.conducted_at).toString() !== 'Invalid Date'; + + if (dep.id.startsWith('WORKFLOW')) { + return hasValidDates && typeof dep.run_duration === 'number'; + } + + return hasValidDates; + }); + + if (validDeployments.length < 2) { + return { + durationTrend: { value: 0, change: 0, state: 'neutral' }, + prCountTrend: { value: 0, change: 0, state: 'neutral' } + }; + } + + const sortedDeployments = [...validDeployments].sort( + (a, b) => + new Date(a.conducted_at).getTime() - new Date(b.conducted_at).getTime() + ); + + const midpoint = Math.floor(sortedDeployments.length / 2); + const firstHalf = sortedDeployments.slice(0, midpoint); + const secondHalf = sortedDeployments.slice(midpoint); + + // Calculate average duration for each half + const getAvgDuration = (deps: Deployment[]) => { + const totalDuration = deps.reduce( + (sum, dep) => sum + getDeploymentDurationInSeconds(dep), + 0 + ); + return deps.length > 0 ? totalDuration / deps.length : 0; + }; + + const firstHalfAvgDuration = getAvgDuration(firstHalf); + const secondHalfAvgDuration = getAvgDuration(secondHalf); + + const durationChange = firstHalfAvgDuration + ? ((secondHalfAvgDuration - firstHalfAvgDuration) / firstHalfAvgDuration) * + 100 + : 0; + + const avgDuration = getAvgDuration(sortedDeployments); + + const getAvgPrCount = (deps: Deployment[]): number => { + if (!deps || deps.length === 0) return 0; + + // Filter deployments that have valid PR count data + const depsWithPrCount = deps.filter((dep) => dep.pr_count >= 0); + + console.log('prCount', deployments[0]); + + if (depsWithPrCount.length === 0) return 0; + + const deploymentsByDate = depsWithPrCount.reduce( + (acc, dep) => { + const date = new Date(dep.conducted_at).toLocaleDateString('en-US'); + if (!acc[date]) { + acc[date] = { totalPRs: 0, count: 0 }; + } + acc[date].totalPRs += dep.pr_count || 0; + acc[date].count++; + return acc; + }, + {} as Record + ); + + const dailyTotals = Object.values(deploymentsByDate); + + const avgPrPerDay = dailyTotals.map((day) => day.totalPRs / day.count); + const totalAvgPr = avgPrPerDay.reduce((sum, avg) => sum + avg, 0); + + return avgPrPerDay.length > 0 ? totalAvgPr / avgPrPerDay.length : 0; + }; + + const firstHalfAvgPrCount = getAvgPrCount(firstHalf); + const secondHalfAvgPrCount = getAvgPrCount(secondHalf); + + const prCountChange = firstHalfAvgPrCount + ? ((secondHalfAvgPrCount - firstHalfAvgPrCount) / firstHalfAvgPrCount) * 100 + : 0; + + const avgPrCount = getAvgPrCount(sortedDeployments); + + return { + durationTrend: { + value: avgDuration / 60, + change: durationChange, + state: determineTrendState(durationChange, false) + }, + prCountTrend: { + value: avgPrCount, + change: prCountChange, + state: determineTrendState(prCountChange, true) + } + }; +}; + +const determineTrendState = ( + change: number, + isPositiveWhenIncreasing: boolean +): 'positive' | 'negative' | 'neutral' => { + if (Math.abs(change) <= MEANINGFUL_CHANGE_THRESHOLD) { + return 'neutral'; + } + + const isIncreasing = change > 0; + if (isIncreasing) { + return isPositiveWhenIncreasing ? 'positive' : 'negative'; + } else { + return isPositiveWhenIncreasing ? 'negative' : 'positive'; + } +}; + +export const DeploymentTrendPill: FC<{ + label: string; + change: number; + state: 'positive' | 'negative' | 'neutral'; + value: number; + valueFormat?: (val: number) => string; +}> = ({ label, change, state }) => { + const theme = useTheme(); + + const text = + state === 'neutral' + ? label + : state === 'positive' + ? 'Increasing ' + label + : 'Decreasing ' + label; + + const useMultiplierFormat = Math.abs(change) > 100; + const formattedChange = useMultiplierFormat + ? `${percentageToMultiplier(Math.abs(change))}` + : `${Math.abs(Math.round(change))}%`; + + const color = darken( + state === 'positive' + ? theme.colors.success.main + : theme.colors.warning.main, + state === 'neutral' ? 0.5 : 0 + ); + + const icon = + state === 'positive' ? ( + + ) : state === 'negative' ? ( + + ) : ( + + ); + + return ( + + {text} + + + {formattedChange} + {icon} + + + + ); +}; + +export const DoraMetricsTrend: FC = () => { + const theme = useTheme(); + const { deployments_map = {} } = useSelector( + (state) => state.doraMetrics.team_deployments + ); + + const allDeployments = useMemo(() => { + return Object.values(deployments_map).flat(); + }, [deployments_map]); + + const { durationTrend, prCountTrend } = useMemo(() => { + return calculateDeploymentTrends(allDeployments); + }, [allDeployments]); + + const chartData = useMemo(() => { + const validDeployments = allDeployments.filter((dep) => { + const hasValidDates = + dep.conducted_at && + new Date(dep.conducted_at).toString() !== 'Invalid Date'; + + if (dep.id?.startsWith('WORKFLOW')) { + return ( + hasValidDates && + typeof dep.run_duration === 'number' && + dep.run_duration >= 0 + ); + } + + return ( + hasValidDates && + dep.created_at && + new Date(dep.created_at).toString() !== 'Invalid Date' + ); + }); + + if (!validDeployments.length) { + return { labels: [], series: [], yAxisMax: 0 }; + } + + const sortedDeployments = [...validDeployments].sort( + (a, b) => + new Date(a.conducted_at).getTime() - new Date(b.conducted_at).getTime() + ); + + const deploymentsByDate = sortedDeployments.reduce( + (acc, deployment) => { + const date = new Date(deployment.conducted_at).toLocaleDateString( + 'en-US', + { + day: 'numeric', + month: 'short' + } + ); + + if (!acc[date]) { + acc[date] = { + deployments: [], + totalDuration: 0, + totalPRs: 0, + prDeploymentCount: 0 + }; + } + + const durationInMinutes = getDeploymentDurationInMinutes(deployment); + acc[date].deployments.push(deployment); + acc[date].totalDuration += durationInMinutes; + + if (deployment.pr_count >= 0) { + acc[date].totalPRs += deployment.pr_count || 0; + acc[date].prDeploymentCount++; + } + + return acc; + }, + {} as Record< + string, + { + deployments: Deployment[]; + totalDuration: number; + totalPRs: number; + prDeploymentCount: number; + } + > + ); + + const dates = Object.keys(deploymentsByDate); + const durations = dates.map( + (date) => deploymentsByDate[date].totalDuration + ); + + const prCounts = dates.map((date) => { + const { totalPRs, prDeploymentCount } = deploymentsByDate[date]; + return prDeploymentCount > 0 ? totalPRs / prDeploymentCount : 0; + }); + + const maxDuration = Math.max(...durations); + const yAxisMax = Math.ceil(maxDuration); + + const series: ChartSeries = [ + { + type: 'bar', + label: 'Deployment Duration (minutes)', + data: durations, + yAxisID: 'y', + order: 0, + color: 'white' + }, + { + type: 'bar', + label: 'PR Count', + data: prCounts, + yAxisID: 'y1', + backgroundColor: theme.colors.info.main, + borderWidth: 2, + tension: 0.4, + order: 1, + color: 'white' + } + ]; + + return { labels: dates, series, yAxisMax }; + }, [allDeployments, theme.colors]); + + return ( + + + Deployment Trend + + + + + +
+ {chartData.labels.length > 0 && ( + value + 'm' + }, + max: chartData.yAxisMax, + grid: { + color: darken(theme.colors.success.lighter, 0.2) + } + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'PR Count', + color: theme.colors.info.main + }, + ticks: { + color: theme.colors.info.main, + stepSize: 1 + }, + grid: { + drawOnChartArea: false + } + } + }, + plugins: { + legend: { + display: true, + position: 'top' + }, + tooltip: { + callbacks: { + label: (context) => { + const label = context.dataset.label || ''; + const value = context.parsed.y; + if (label.includes('Duration')) { + return `${label}: ${value.toFixed(2)}m`; + } + return `${label}: ${value.toFixed(0)}`; + } + } + } + } + } + }} + /> + )} +
+
+ ); +}; + +export default DoraMetricsTrend; diff --git a/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx b/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx index 339700fa4..9ac41f4a2 100644 --- a/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx +++ b/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx @@ -1,8 +1,9 @@ import { ArrowDownwardRounded } from '@mui/icons-material'; import { Card, Divider, useTheme, Collapse, Box } from '@mui/material'; +import Link from 'next/link'; import pluralize from 'pluralize'; import { ascend, descend, groupBy, path, prop, sort } from 'ramda'; -import { FC, useCallback, useEffect, useMemo } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FlexBox } from '@/components/FlexBox'; import { MiniButton } from '@/components/MiniButton'; @@ -10,7 +11,9 @@ import { MiniCircularLoader } from '@/components/MiniLoader'; import { ProgressBar } from '@/components/ProgressBar'; import { PullRequestsTableMini } from '@/components/PRTableMini/PullRequestsTableMini'; import Scrollbar from '@/components/Scrollbar'; +import { Tabs, TabItem } from '@/components/Tabs'; import { Line } from '@/components/Text'; +import { ROUTES } from '@/constants/routes'; import { FetchState } from '@/constants/ui-states'; import { useAuth } from '@/hooks/useAuth'; import { useBoolState, useEasyState } from '@/hooks/useEasyState'; @@ -27,11 +30,15 @@ import { } from '@/slices/dora_metrics'; import { useDispatch, useSelector } from '@/store'; import { Deployment, PR, RepoWorkflowExtended } from '@/types/resources'; +import { DeploymentSources } from '@/types/resources'; import { percent } from '@/utils/datatype'; import { depFn } from '@/utils/fn'; import { DeploymentItem } from './DeploymentItem'; +import { DoraMetricsDuration } from '../DoraMetrics/DoraMetricsDuration'; +import { DoraMetricsTrend } from '../DoraMetrics/DoraMetricsTrend'; + enum DepStatusFilter { All, Pass, @@ -40,12 +47,24 @@ enum DepStatusFilter { const hideTableColumns = new Set(['reviewers', 'rework_cycles']); +enum TabKeys { + ANALYTICS = 'analytics', + EVENTS = 'events' +} + export const DeploymentInsightsOverlay = () => { const { orgId } = useAuth(); const { singleTeamId, team, dates } = useSingleTeamConfig(); const dispatch = useDispatch(); const depFilter = useEasyState(DepStatusFilter.All); const branchesForPrFilters = useBranchesForPrFilters(); + const [activeTab, setActiveTab] = useState(TabKeys.EVENTS); + const tabItems: TabItem[] = [ + { key: TabKeys.ANALYTICS, label: 'Deployment Analytics' }, + { key: TabKeys.EVENTS, label: 'Deployment Events' } + ]; + const handleTabSelect = (item: TabItem) => setActiveTab(item.key as string); + useEffect(() => { if (!singleTeamId) return; @@ -181,6 +200,13 @@ export const DeploymentInsightsOverlay = () => { const dateRangeLabel = useCurrentDateRangeReactNode(); + // Determine if the selected repository uses PR_MERGE as its deployment source + const currentBaseRepo = selectedRepo.value + ? teamDeployments.repos_map[selectedRepo.value] + : null; + const isPRMergeSource = + currentBaseRepo?.deployment_type === DeploymentSources.PR_MERGE; + if (!team) return Please select a team first...; return ( @@ -267,170 +293,215 @@ export const DeploymentInsightsOverlay = () => { }} minHeight={'calc(100vh - 275px)'} > - - - - - - No. of deployments {'->'} - {' '} - { - depFilter.set(DepStatusFilter.All); - }} - > - {deployments.length} - {' '} - {Boolean(failedDeps.length) ? ( - { - depFilter.set(DepStatusFilter.Fail); - }} - pointer - > - ({failedDeps.length} failed) - - ) : ( + item.key === activeTab} + /> + {activeTab === TabKeys.ANALYTICS ? ( + + + + + + + No. of deployments {'->'} + {' '} { - depFilter.set(DepStatusFilter.Pass); + depFilter.set(DepStatusFilter.All); }} - pointer > - (All passed) - - )} - - { - depFilter.set(DepStatusFilter.Pass); - }} - remainingOnClick={() => { - depFilter.set(DepStatusFilter.Fail); - }} - /> - - - - - - - depFilter.set(DepStatusFilter.All)} - variant={ - depFilter.value === DepStatusFilter.All - ? 'contained' - : 'outlined' - } - > - All - - depFilter.set(DepStatusFilter.Pass)} - variant={ - depFilter.value === DepStatusFilter.Pass - ? 'contained' - : 'outlined' - } - > - Successful - - depFilter.set(DepStatusFilter.Fail)} - variant={ - depFilter.value === DepStatusFilter.Fail - ? 'contained' - : 'outlined' - } - > - Failed - - - - - - - {filteredDeployments.length ? ( - groupedDeployments.map(([workflow, deployments]) => ( - - )) - ) : ( - - - No deployments matching the current filter. - - depFilter.set(DepStatusFilter.All)} - pointer - > - See all deployments? - - - )} - - - - - - {selectedDep ? ( - - - Selected Deployment - - - - - {loadingPrs ? ( - - ) : prs.length ? ( - + {deployments.length} + {' '} + {Boolean(failedDeps.length) ? ( + { + depFilter.set(DepStatusFilter.Fail); + }} + pointer + > + ({failedDeps.length} failed) + ) : ( - - No new PRs linked to this deployment. + { + depFilter.set(DepStatusFilter.Pass); + }} + pointer + > + (All passed) )} - + + { + depFilter.set(DepStatusFilter.Pass); + }} + remainingOnClick={() => { + depFilter.set(DepStatusFilter.Fail); + }} + /> + + {!isPRMergeSource ? ( + <> + + + + + + ) : ( - - - Select a deployment on the left - - - to view PRs included in that deployment + + + Deployment trends are only available for repos with + workflows as source.{' '} + + + Go to settings → + + - + )} - + ) : ( + <> + + + + + + depFilter.set(DepStatusFilter.All)} + variant={ + depFilter.value === DepStatusFilter.All + ? 'contained' + : 'outlined' + } + > + All + + depFilter.set(DepStatusFilter.Pass)} + variant={ + depFilter.value === DepStatusFilter.Pass + ? 'contained' + : 'outlined' + } + > + Successful + + depFilter.set(DepStatusFilter.Fail)} + variant={ + depFilter.value === DepStatusFilter.Fail + ? 'contained' + : 'outlined' + } + > + Failed + + + + + + + {filteredDeployments.length ? ( + groupedDeployments.map( + ([workflow, deployments]) => ( + + ) + ) + ) : ( + + + No deployments matching the current filter. + + + depFilter.set(DepStatusFilter.All) + } + pointer + > + See all deployments? + + + )} + + + + + + {selectedDep ? ( + + + Selected Deployment + + + + + {loadingPrs ? ( + + ) : prs.length ? ( + + ) : ( + + No new PRs linked to this deployment. + + )} + + + ) : ( + + + Select a deployment on the left + + + to view PRs included in that deployment + + + )} + + + + )} ) ) : (