diff --git a/web-server/src/content/DoraMetrics/DoraMetricsDuration.tsx b/web-server/src/content/DoraMetrics/DoraMetricsDuration.tsx new file mode 100644 index 00000000..05b95f43 --- /dev/null +++ b/web-server/src/content/DoraMetrics/DoraMetricsDuration.tsx @@ -0,0 +1,165 @@ +import { Card, Chip, Tooltip, useTheme } from '@mui/material'; +import { FC, useMemo } from 'react'; +import { CheckCircleRounded, AccessTimeRounded, OpenInNew, Code, ArrowForwardIosRounded } from '@mui/icons-material'; + +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 00000000..1d4b15bb --- /dev/null +++ b/web-server/src/content/DoraMetrics/DoraMetricsTrend.tsx @@ -0,0 +1,421 @@ +import { + TrendingDownRounded, + TrendingFlatRounded, + TrendingUpRounded +} from '@mui/icons-material'; +import { darken, useTheme } from '@mui/material'; +import { FC, useMemo } from 'react'; +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { useSelector } from '@/store'; +import { Deployment } from '@/types/resources'; +import { percentageToMultiplier } from '@/utils/datatype'; +import { Chart2, ChartSeries } from '@/components/Chart2'; + +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 getDeploymentDurationInHours = (deployment: Deployment): number => { + const seconds = getDeploymentDurationInSeconds(deployment); + return +(seconds / 3600).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); + + 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, + 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 === 'positive' ? 'Increasing ' + label : state === 'negative' ? 'Decreasing ' + label : 'Stable ' + label + ) + + const useMultiplierFormat = Math.abs(change) > 100; + const formattedChange = useMultiplierFormat + ? `${percentageToMultiplier(change)}` + : `${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 durationInHours = getDeploymentDurationInHours(deployment); + acc[date].deployments.push(deployment); + acc[date].totalDuration += durationInHours; + + if (deployment.pr_count >= 0) { + acc[date].totalPRs += deployment.pr_count || 0; + acc[date].prDeploymentCount++; + } + + return acc; + }, {} as Record); + + 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 (hours)', + data: durations, + yAxisID: 'y', + borderColor: theme.colors.success.main, + order: 0 + }, + { + type: 'bar', + label: 'PR Count', + data: prCounts, + yAxisID: 'y1', + backgroundColor: theme.colors.info.main, + borderWidth: 2, + tension: 0.4, + order: 1 + } + ]; + + return { labels: dates, series, yAxisMax }; + }, [allDeployments, theme.colors]); + + return ( + + + Deployment Trend + + + + + +
+ {chartData.labels.length > 0 && ( + value + 'h' + }, + 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)}h`; + } + 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 339700fa..88bb2808 100644 --- a/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx +++ b/web-server/src/content/PullRequests/DeploymentInsightsOverlay.tsx @@ -2,14 +2,16 @@ import { ArrowDownwardRounded } from '@mui/icons-material'; import { Card, Divider, useTheme, Collapse, Box } from '@mui/material'; 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 { DoraMetricsTrend } from '../DoraMetrics/DoraMetricsTrend'; +import { DoraMetricsDuration } from '../DoraMetrics/DoraMetricsDuration'; import { FlexBox } from '@/components/FlexBox'; import { MiniButton } from '@/components/MiniButton'; 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 { FetchState } from '@/constants/ui-states'; import { useAuth } from '@/hooks/useAuth'; @@ -40,12 +42,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; @@ -205,11 +219,10 @@ export const DeploymentInsightsOverlay = () => { px={1} py={1 / 2} corner={theme.spacing(1)} - border={`1px solid ${ - repo.id === selectedRepo.value + border={`1px solid ${repo.id === selectedRepo.value ? theme.colors.info.main : theme.colors.secondary.light - }`} + }`} pointer bgcolor={ repo.id === selectedRepo.value @@ -221,7 +234,9 @@ export const DeploymentInsightsOverlay = () => { ? theme.colors.info.main : undefined } - fontWeight={repo.id === selectedRepo.value ? 'bold' : undefined} + fontWeight={ + repo.id === selectedRepo.value ? 'bold' : undefined + } onClick={() => { selectedRepo.set(repo.id as ID); }} @@ -267,170 +282,195 @@ 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 - + {deployments.length} + {' '} + {Boolean(failedDeps.length) ? ( + { + depFilter.set(DepStatusFilter.Fail); + }} + pointer + > + ({failedDeps.length} failed) + + ) : ( + { + depFilter.set(DepStatusFilter.Pass); + }} + pointer + > + (All passed) + + )} + + { + depFilter.set(DepStatusFilter.Pass); + }} + remainingOnClick={() => { + depFilter.set(DepStatusFilter.Fail); + }} + /> - - - - - {filteredDeployments.length ? ( - groupedDeployments.map(([workflow, deployments]) => ( - + + + + + + + ) : ( + <> + + + + + + 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 deployments matching the current filter. + No new PRs linked to this deployment. - 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 - )} - - - ) : ( - - - Select a deployment on the left - - - to view PRs included in that deployment - + + to view PRs included in that deployment + + + )} - )} - - + + + )} ) ) : (