diff --git a/web-server/pages/api/internal/team/[team_id]/get_incidents.ts b/web-server/pages/api/internal/team/[team_id]/get_incidents.ts index 50103720d..010c78022 100644 --- a/web-server/pages/api/internal/team/[team_id]/get_incidents.ts +++ b/web-server/pages/api/internal/team/[team_id]/get_incidents.ts @@ -2,11 +2,13 @@ import { endOfDay, startOfDay } from 'date-fns'; import { isNil, reject } from 'ramda'; import * as yup from 'yup'; -import { getTeamPrs } from '@/api/internal/team/[team_id]/insights'; -import { getTeamRevertedPrs } from '@/api/internal/team/[team_id]/revert_prs'; +import { getAllTeamsReposProdBranchesForOrgAsMap } from '@/api/internal/team/[team_id]/repo_branches'; import { handleRequest } from '@/api-helpers/axios'; import { Endpoint } from '@/api-helpers/global'; -import { updatePrFilterParams } from '@/api-helpers/team'; +import { + updatePrFilterParams, + repoFiltersFromTeamProdBranches +} from '@/api-helpers/team'; import { mockDeploymentsWithIncidents } from '@/mocks/incidents'; import { DeploymentWithIncidents, @@ -22,7 +24,8 @@ const getSchema = yup.object().shape({ from_date: yup.date().required(), to_date: yup.date().required(), branches: yup.string().optional().nullable(), - repo_filters: yup.mixed().optional().nullable() + repo_filters: yup.mixed().optional().nullable(), + org_id: yup.string().uuid().required() }); const endpoint = new Endpoint(pathSchema); @@ -36,7 +39,8 @@ endpoint.handle.GET(getSchema, async (req, res) => { from_date: rawFromDate, to_date: rawToDate, branches, - repo_filters + repo_filters, + org_id } = req.payload; const from_date = startOfDay(new Date(rawFromDate)); const to_date = endOfDay(new Date(rawToDate)); @@ -50,22 +54,32 @@ endpoint.handle.GET(getSchema, async (req, res) => { { branches, repo_filters } ); - const [deploymentsWithIncident, summaryPrs, revertedPrs] = await Promise.all([ - getTeamIncidentsWithDeployment({ team_id, from_date, to_date }), - getTeamPrs({ - team_id, - branches, - from_date: from_date, - to_date: to_date, - repo_filters - }).then((r) => r.data), - getTeamRevertedPrs({ ...params, team_id }) - ]); + const teamProdBranchesMap = + await getAllTeamsReposProdBranchesForOrgAsMap(org_id); + const teamRepoFiltersMap = + repoFiltersFromTeamProdBranches(teamProdBranchesMap); + const pr_filter = await updatePrFilterParams( + team_id, + {}, + { + branches: branches, + repo_filters: !branches ? teamRepoFiltersMap[team_id] : null + } + ).then(({ pr_filter }) => ({ + pr_filter + })); + + const deploymentsWithIncident = await getTeamIncidentsWithDeployment({ + team_id, + from_date, + to_date, + pr_filter + }); return res.send({ deployments_with_incidents: deploymentsWithIncident, - summary_prs: summaryPrs, - revert_prs: revertedPrs + summary_prs: [], + revert_prs: [] } as IncidentApiResponseType); }); @@ -73,6 +87,7 @@ export const getTeamIncidentsWithDeployment = async (params: { team_id: ID; from_date: DateString | Date; to_date: DateString | Date; + pr_filter: any; }) => { const [from_time, to_time] = getWeekStartAndEndInterval( params.from_date, @@ -84,7 +99,8 @@ export const getTeamIncidentsWithDeployment = async (params: { { params: reject(isNil, { from_time, - to_time + to_time, + pr_filter: params.pr_filter }) } ); diff --git a/web-server/src/components/OverlayComponents/ChangeFailureRate.tsx b/web-server/src/components/OverlayComponents/ChangeFailureRate.tsx new file mode 100644 index 000000000..91421eb1f --- /dev/null +++ b/web-server/src/components/OverlayComponents/ChangeFailureRate.tsx @@ -0,0 +1,5 @@ +import { FlexBox } from '../FlexBox'; + +export const ChangeFailureRate = () => { + return ChangeFailureRate; +}; diff --git a/web-server/src/components/PRTable/PullRequestsTable.tsx b/web-server/src/components/PRTable/PullRequestsTable.tsx index d439deed7..364cb2654 100644 --- a/web-server/src/components/PRTable/PullRequestsTable.tsx +++ b/web-server/src/components/PRTable/PullRequestsTable.tsx @@ -35,9 +35,6 @@ import { PullRequestTableColumnSelector } from '@/components/PRTable/PullRequest import { DarkTooltip, LightTooltip } from '@/components/Shared'; import { Line } from '@/components/Text'; import { SearchInput } from '@/components/TicketsTableAddons/SearchInput'; -import { Integration } from '@/constants/integrations'; -import { ROUTES } from '@/constants/routes'; -import { useAuth } from '@/hooks/useAuth'; import { EasyState, useEasyState } from '@/hooks/useEasyState'; import { useTableSort } from '@/hooks/useTableSort'; import { DEFAULT_PR_TABLE_COLUMN_STATE_MAP } from '@/slices/app'; @@ -65,8 +62,6 @@ export const PullRequestsTable: FC< } & Omit > = ({ propPrs, selectionMenu, selectedPrIds, isPrSelectionEnabled }) => { const theme = useTheme(); - const { integrationSet } = useAuth(); - const hasBitbucket = integrationSet.has(Integration.BITBUCKET); const prTableColumnConfig = useSelector( (s) => s.app.prTableColumnsConfig || DEFAULT_PR_TABLE_COLUMN_STATE_MAP ); @@ -113,7 +108,6 @@ export const PullRequestsTable: FC< } }, [page, pageCount]); - const { orgId } = useAuth(); const enableCsv = true; return ( @@ -306,12 +300,7 @@ export const PullRequestsTable: FC< arrow title={ - - {hasBitbucket - ? pr.author.linked_user?.name || - pr.author.username - : `@${pr.author.username}`} - + {`@${pr.author.username}`} {!pr.author.linked_user && ( User not added to Middleware @@ -327,18 +316,8 @@ export const PullRequestsTable: FC< width="100%" > @@ -494,8 +469,6 @@ const PrChangesTooltip: FC<{ pr: PR }> = ({ pr }) => { const PrReviewersCell: FC<{ pr: PR }> = ({ pr }) => { const theme = useTheme(); - const { user } = useAuth(); - const hasBitbucket = user?.integrations?.bitbucket; return ( = ({ pr }) => { - - {hasBitbucket - ? reviewer.linked_user?.name || reviewer.username - : `@${reviewer.username}`} - + {`@${reviewer.username}`} {!reviewer.linked_user && ( User not added to Middleware @@ -522,17 +491,8 @@ const PrReviewersCell: FC<{ pr: PR }> = ({ pr }) => { } key={reviewer.username} - component={hasBitbucket ? 'div' : Link} - href={ - reviewer.linked_user?.id - ? `${ - ROUTES.COLLABORATE.INSIGHTS.USER.add(reviewer.linked_user.id) - .PATH - }` - : hasBitbucket - ? undefined - : getGHAvatar(reviewer.username) - } + component={Link} + href={getGHAvatar(reviewer.username)} target="_blank" fontWeight={500} display="flex" @@ -546,7 +506,7 @@ const PrReviewersCell: FC<{ pr: PR }> = ({ pr }) => { boxShadow: `0 0 0 2px ${theme.colors.info.main}` } )} - src={hasBitbucket ? undefined : getGHAvatar(reviewer.username)} + src={getGHAvatar(reviewer.username)} /> ))} diff --git a/web-server/src/constants/overlays.ts b/web-server/src/constants/overlays.ts index 164844a1f..ff364435c 100644 --- a/web-server/src/constants/overlays.ts +++ b/web-server/src/constants/overlays.ts @@ -20,5 +20,20 @@ export const overlaysImportMap = { import('@/content/PullRequests/DeploymentInsightsOverlay').then((c) => ({ default: c.DeploymentInsightsOverlay })) + ), + change_failure_rate: lazy(() => + import('@/components/OverlayComponents/ChangeFailureRate').then((c) => ({ + default: c.ChangeFailureRate + })) + ), + all_incidents: lazy(() => + import('@/content/DoraMetrics/Incidents').then((c) => ({ + default: c.AllIncidentsBody + })) + ), + resolved_incidents: lazy(() => + import('@/content/DoraMetrics/ResolvedIncidents').then((c) => ({ + default: c.ResolvedIncidentsBody + })) ) }; diff --git a/web-server/src/content/DoraMetrics/DeploymentWithIncidentsMenuItem.tsx b/web-server/src/content/DoraMetrics/DeploymentWithIncidentsMenuItem.tsx new file mode 100644 index 000000000..7806463de --- /dev/null +++ b/web-server/src/content/DoraMetrics/DeploymentWithIncidentsMenuItem.tsx @@ -0,0 +1,122 @@ +import { AccessTimeRounded, ArrowForwardRounded } from '@mui/icons-material'; +import BugReportOutlinedIcon from '@mui/icons-material/BugReportOutlined'; +import NearbyErrorIcon from '@mui/icons-material/NearbyError'; +import { Card, useTheme } from '@mui/material'; +import { format } from 'date-fns'; +import Link from 'next/link'; +import pluralize from 'pluralize'; +import { FC } from 'react'; +import { FaExternalLinkAlt } from 'react-icons/fa'; + +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { DeploymentWithIncidents } from '@/types/resources'; +import { getDurationString } from '@/utils/date'; +import { OPEN_IN_NEW_TAB_PROPS } from '@/utils/url'; + +export const DeploymentWithIncidentsMenuItem: FC<{ + deployment: DeploymentWithIncidents; + selected: boolean; + onSelect: (dep: DeploymentWithIncidents) => any; +}> = ({ deployment, selected, onSelect }) => { + const theme = useTheme(); + const incidents = deployment.incidents; + + return ( + onSelect(deployment)} + > + + + + + + Run on{' '} + {format(new Date(deployment.conducted_at), 'do, MMM - hh:mmaaa')} + + {deployment.html_url && ( + + + + + + )} + + + + + + {incidents.length} {pluralize('incident', incidents.length)} + + + {deployment.head_branch} + + + + + + {getDurationString(deployment.run_duration)} + + + + + {onSelect && ( + + )} + + + ); +}; diff --git a/web-server/src/content/DoraMetrics/DoraCards/ChangeFailureRateCard.tsx b/web-server/src/content/DoraMetrics/DoraCards/ChangeFailureRateCard.tsx index b3e044bb6..57ade914c 100644 --- a/web-server/src/content/DoraMetrics/DoraCards/ChangeFailureRateCard.tsx +++ b/web-server/src/content/DoraMetrics/DoraCards/ChangeFailureRateCard.tsx @@ -5,6 +5,7 @@ import { useMemo } from 'react'; import { Chart2, ChartOptions } from '@/components/Chart2'; import { FlexBox } from '@/components/FlexBox'; +import { useOverlayPage } from '@/components/OverlayPageContext'; import { Line } from '@/components/Text'; import { track } from '@/constants/events'; import { @@ -102,6 +103,8 @@ export const ChangeFailureRateCard = () => { changeFailureRateProps.avgWeeklyDeploymentFrequency && (changeFailureRateProps.count || prevChangeFailureRate) ); + + const { addPage } = useOverlayPage(); return ( @@ -207,7 +210,12 @@ export const ChangeFailureRateCard = () => { track('DORA_METRICS_SEE_DETAILS_CLICKED', { viewed: 'CFR' }); - return console.error('OVERLAY PENDING'); + addPage({ + page: { + title: 'Deployments with incidents', + ui: 'all_incidents' + } + }); }} color={changeFailureRateProps.color} > diff --git a/web-server/src/content/DoraMetrics/DoraCards/MeanTimeToRestoreCard.tsx b/web-server/src/content/DoraMetrics/DoraCards/MeanTimeToRestoreCard.tsx index 55f7e7866..9cd05a720 100644 --- a/web-server/src/content/DoraMetrics/DoraCards/MeanTimeToRestoreCard.tsx +++ b/web-server/src/content/DoraMetrics/DoraCards/MeanTimeToRestoreCard.tsx @@ -5,6 +5,7 @@ import { useMemo } from 'react'; import { Chart2, ChartOptions } from '@/components/Chart2'; import { FlexBox } from '@/components/FlexBox'; +import { useOverlayPage } from '@/components/OverlayPageContext'; import { Line } from '@/components/Text'; import { track } from '@/constants/events'; import { @@ -75,6 +76,8 @@ export const MeanTimeToRestoreCard = () => { ] ); + const { addPage } = useOverlayPage(); + return ( @@ -172,7 +175,12 @@ export const MeanTimeToRestoreCard = () => { track('DORA_METRICS_SEE_DETAILS_CLICKED', { viewed: 'MTR' }); - return console.error('OVERLAY PENDING'); + addPage({ + page: { + title: 'Resolved Incidents', + ui: 'resolved_incidents' + } + }); }} color={meanTimeToRestoreProps.color} > diff --git a/web-server/src/content/DoraMetrics/Incidents.tsx b/web-server/src/content/DoraMetrics/Incidents.tsx new file mode 100644 index 000000000..931fa2916 --- /dev/null +++ b/web-server/src/content/DoraMetrics/Incidents.tsx @@ -0,0 +1,500 @@ +import { AccessTimeRounded } from '@mui/icons-material'; +import BugReportOutlinedIcon from '@mui/icons-material/BugReportOutlined'; +import { Box, Card, Chip, Divider, Link, Paper, useTheme } from '@mui/material'; +import { format } from 'date-fns'; +import pluralize from 'pluralize'; +import { head } from 'ramda'; +import { FC, useCallback, useEffect, useMemo } from 'react'; +import { FaExternalLinkAlt } from 'react-icons/fa'; + +import { EmptyState } from '@/components/EmptyState'; +import { FlexBox } from '@/components/FlexBox'; +import { MiniLoader } from '@/components/MiniLoader'; +import { useOverlayPage } from '@/components/OverlayPageContext'; +import Scrollbar from '@/components/Scrollbar'; +import { LightTooltip } from '@/components/Shared'; +import { SimpleAvatar } from '@/components/SimpleAvatar'; +import { Line } from '@/components/Text'; +import { TrendsLineChart } from '@/components/TrendsLineChart'; +import { FetchState } from '@/constants/ui-states'; +import { DeploymentWithIncidentsMenuItem } from '@/content/DoraMetrics/DeploymentWithIncidentsMenuItem'; +import { RevertedPrs } from '@/content/PullRequests/PrsReverted'; +import { useAuth } from '@/hooks/useAuth'; +import { useDoraMetricsGraph } from '@/hooks/useDoraMetricsGraph'; +import { useEasyState } from '@/hooks/useEasyState'; +import { + useCurrentDateRangeReactNode, + useSingleTeamConfig, + useStateBranchConfig +} from '@/hooks/useStateTeamConfig'; +import { fetchAllDeploymentsWithIncidents } from '@/slices/dora_metrics'; +import { useDispatch, useSelector } from '@/store'; +import { PrUser, DeploymentWithIncidents } from '@/types/resources'; +import { getDurationString } from '@/utils/date'; +import { formatAsPercent } from '@/utils/stringFormatting'; +import { OPEN_IN_NEW_TAB_PROPS } from '@/utils/url'; +import { getGHAvatar } from '@/utils/user'; + +import { IncidentItemIcon } from './IncidentsMenuItem'; + +import { SubHeader } from '../../components/WrapperComponents'; + +export const AllIncidentsBody = () => { + const dispatch = useDispatch(); + const { orgId } = useAuth(); + const branches = useStateBranchConfig(); + const { singleTeamId, dates, team, singleTeamProdBranchesConfig } = + useSingleTeamConfig(); + const { addPage } = useOverlayPage(); + const dateRangeLabel = useCurrentDateRangeReactNode(); + const isLoading = useSelector( + (s) => s.doraMetrics.requests?.all_deployments === FetchState.REQUEST + ); + + const allDeployments = useSelector( + (s) => s.doraMetrics.all_deployments || [] + ); + const allPrs = useSelector((s) => s.doraMetrics.summary_prs); + const revertedPrs = useSelector((s) => s.doraMetrics.revert_prs); + + const selectedDeploymentId = useEasyState(null); + const setSelectedDeploymentId = useCallback( + (selectedDeployment: DeploymentWithIncidents) => { + selectedDeploymentId.set(selectedDeployment.id); + }, + [selectedDeploymentId] + ); + + const selectedDeployment = useMemo( + () => + allDeployments.find( + (deployment) => deployment.id === selectedDeploymentId.value + ), + [allDeployments, selectedDeploymentId] + ); + const filteredDeployments = useMemo( + () => allDeployments.filter((deployment) => deployment.incidents.length), + [allDeployments] + ); + + const fetchAllIncidentDetails = useCallback(() => { + if (!singleTeamId || !dates.start || !dates.end) return; + + dispatch( + fetchAllDeploymentsWithIncidents({ + team_id: singleTeamId, + from_date: dates.start, + to_date: dates.end, + branches, + repo_filters: singleTeamProdBranchesConfig, + org_id: orgId + }) + ); + }, [ + branches, + dates.end, + dates.start, + dispatch, + orgId, + singleTeamId, + singleTeamProdBranchesConfig + ]); + + useEffect(() => { + fetchAllIncidentDetails(); + }, [ + branches, + dates.end, + dates.start, + dispatch, + fetchAllIncidentDetails, + orgId, + singleTeamId, + singleTeamProdBranchesConfig + ]); + + const { trendsSeriesMap } = useDoraMetricsGraph(); + const isTrendSeriesAvailable = head( + trendsSeriesMap?.changeFailureRateTrends || [] + )?.data?.length; + + if (isLoading) return ; + if (!allDeployments.length || !isTrendSeriesAvailable) + return ( + + + No resolved incidents found for {team.name}{' '} + from {dateRangeLabel} + + + ); + + return ( + + + Change failure rate, across weeks + + {isTrendSeriesAvailable ? ( + + + + ) : ( + Not enough data to show trends. + )} + + + {Boolean(revertedPrs.length) && ( + <> + + + + + + )} + + Out of{' '} + { + addPage({ + page: { + title: 'Deployments insights', + ui: 'deployment_freq' + } + }); + }} + color="info" + > + + {allDeployments.length} total{' '} + {pluralize('deployment', allDeployments.length)} + + {' '} + from {dateRangeLabel} across all data sources,{' '} + {filteredDeployments.length}{' '} + {pluralize('deployment', filteredDeployments.length)} may have led to + possible incidents. + + + + + + + + + {filteredDeployments?.map((deployment) => { + return ( + + ); + })} + + + + + + + + + ); +}; + +const SelectedIncidentDetails: FC<{ + deploymentDetails: DeploymentWithIncidents; +}> = ({ deploymentDetails }) => { + const theme = useTheme(); + + const incidents = deploymentDetails?.incidents; + const isAssigned = + deploymentDetails?.event_actor?.username || + deploymentDetails?.event_actor?.linked_user; + + if (!deploymentDetails) + return ( + + + Select an deployment on the left + + + to view details of possible incidents that it may have led to + + + ); + + return ( + + Selected Deployment + + {Boolean(deploymentDetails.pr_count) && ( + + + + {deploymentDetails.pr_count}{' '} + {deploymentDetails.pr_count === 1 ? 'PR' : 'PRs'} + + + )} + + + Run on{' '} + {format( + new Date(deploymentDetails.conducted_at), + 'do, MMM - hh:mmaaa' + )} + + + + by{' '} + {deploymentDetails.event_actor?.linked_user?.name || + `@${deploymentDetails.event_actor.username}`}{' '} + + + + + + + + + + + {getDurationString(deploymentDetails.run_duration)} + + + } + /> + + {deploymentDetails.html_url && ( + + + + + + )} + + + + {incidents.map((incident) => ( + + + + Incident details + + + + + {isAssigned ? ( + + Assignee{' '} + + + ) : ( + + Unassigned + + )} + + + } + variant="filled" + /> + + {incident.status} on{' '} + {format( + new Date( + incident.resolved_date || + incident.acknowledged_date || + incident.creation_date + ), + 'do MMMM' + )} + + } + > + + + {incident.status} + + + + + } + variant="filled" + /> + + + + + + + + + + + + + {incident.title} + + + + {incident.summary} + + + ))} + + ); +}; + +const IncidentUserAvatar: FC<{ + userDetails: PrUser; + size?: number; +}> = ({ userDetails, size }) => { + const { org } = useAuth(); + const theme = useTheme(); + const hasGithub = org.integrations.github; + return ( + + {`@${userDetails.username}`} + {!userDetails.linked_user && ( + + User not added to Middleware + + )} + + } + > + + + + + + + ); +}; diff --git a/web-server/src/content/DoraMetrics/IncidentsMenuItem.tsx b/web-server/src/content/DoraMetrics/IncidentsMenuItem.tsx new file mode 100644 index 000000000..30c5c5eb2 --- /dev/null +++ b/web-server/src/content/DoraMetrics/IncidentsMenuItem.tsx @@ -0,0 +1,159 @@ +import { + ArrowForwardRounded, + CheckCircleOutlineRounded, + HowToRegRounded, + WarningAmberRounded +} from '@mui/icons-material'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import { Box, Card, useTheme } from '@mui/material'; +import { format } from 'date-fns'; +import { FC, useMemo } from 'react'; +import ClampLines from 'react-clamp-lines'; + +import { FlexBox } from '@/components/FlexBox'; +import { Line } from '@/components/Text'; +import { + IncidentStatus, + IncidentsWithDeploymentResponseType +} from '@/types/resources'; +import { getDurationString } from '@/utils/date'; + +export const IncidentsMenuItem: FC<{ + incident: IncidentsWithDeploymentResponseType; + selectedIncidentId: ID; + clickHandler: (incident: IncidentsWithDeploymentResponseType) => void; + showIcon?: boolean; +}> = ({ incident, selectedIncidentId, clickHandler, showIcon = true }) => { + const theme = useTheme(); + const isSelected = incident.id === selectedIncidentId; + + const updatedDate = + incident.resolved_date || + incident.acknowledged_date || + incident.creation_date; + + const resolutionTime = useMemo( + () => + incident.creation_date && incident.resolved_date + ? getDurationString( + (new Date(incident.resolved_date).getTime() - + new Date(incident.creation_date).getTime()) / + 1e3 + ) + : null, + [incident.creation_date, incident.resolved_date] + ); + + return ( + clickHandler(incident)} + > + + + + + + {format(new Date(updatedDate), 'do MMMM')} + + + + + + + + + {showIcon && ( + + {incident.status} on{' '} + {format( + new Date( + incident.resolved_date || + incident.acknowledged_date || + incident.creation_date + ), + 'do MMMM' + )} + + } + > + {incident.status} + + + )} + {resolutionTime && ( + + + Created:{' '} + {format( + new Date(incident.creation_date), + "do MMM, yyyy 'at' hh:mm:ss a" + )} +
+ Resolved:{' '} + {format( + new Date(incident.resolved_date), + "do MMM, yyyy 'at' hh:mm:ss a" + )} +
+
+ } + > + Resolved in {resolutionTime} +
+ )} +
+
+ + ); +}; + +export const IncidentItemIcon = ({ status }: { status: string }) => { + if (status === IncidentStatus.TRIGGERED) + return ; + if (status === IncidentStatus.ACKNOWLEDGED) + return ; + return ; +}; diff --git a/web-server/src/content/DoraMetrics/ResolvedIncidents.tsx b/web-server/src/content/DoraMetrics/ResolvedIncidents.tsx new file mode 100644 index 000000000..6bece0378 --- /dev/null +++ b/web-server/src/content/DoraMetrics/ResolvedIncidents.tsx @@ -0,0 +1,274 @@ +import { Box, Card, Chip, Divider, Link, Paper, useTheme } from '@mui/material'; +import { format } from 'date-fns'; +import { head } from 'ramda'; +import { FC, useCallback, useEffect, useMemo } from 'react'; +import { FaExternalLinkAlt } from 'react-icons/fa'; + +import { EmptyState } from '@/components/EmptyState'; +import { FlexBox } from '@/components/FlexBox'; +import { MiniLoader } from '@/components/MiniLoader'; +import Scrollbar from '@/components/Scrollbar'; +import { LightTooltip } from '@/components/Shared'; +import { SimpleAvatar } from '@/components/SimpleAvatar'; +import { Line } from '@/components/Text'; +import { TrendsLineChart } from '@/components/TrendsLineChart'; +import { FetchState } from '@/constants/ui-states'; +import { useAuth } from '@/hooks/useAuth'; +import { useDoraMetricsGraph } from '@/hooks/useDoraMetricsGraph'; +import { useEasyState } from '@/hooks/useEasyState'; +import { + useCurrentDateRangeReactNode, + useSingleTeamConfig +} from '@/hooks/useStateTeamConfig'; +import { fetchAllResolvedIncidents } from '@/slices/dora_metrics'; +import { useDispatch, useSelector } from '@/store'; +import { + IncidentStatus, + IncidentsWithDeploymentResponseType +} from '@/types/resources'; +import { OPEN_IN_NEW_TAB_PROPS } from '@/utils/url'; + +import { IncidentsMenuItem, IncidentItemIcon } from './IncidentsMenuItem'; + +import { SubHeader } from '../../components/WrapperComponents'; + +export const ResolvedIncidentsBody = () => { + const dispatch = useDispatch(); + const { orgId } = useAuth(); + const { singleTeamId, dates, team } = useSingleTeamConfig(); + + const dateRangeLabel = useCurrentDateRangeReactNode(); + const isLoading = useSelector( + (s) => s.doraMetrics.requests?.resolved_incidents === FetchState.REQUEST + ); + + const incidents = useSelector((s) => s.doraMetrics.resolved_incidents || []); + + const selectedIncidentId = useEasyState(null); + const incidentFilter = useEasyState(null); + const setSelectedIncidentId = useCallback( + (selectedIncident: IncidentsWithDeploymentResponseType) => { + selectedIncidentId.set(selectedIncident.id); + }, + [selectedIncidentId] + ); + const selectedIncident = useMemo( + () => + incidents.find((incident) => incident.id === selectedIncidentId.value), + [incidents, selectedIncidentId] + ); + + const filteredIncidents = useMemo( + () => + incidents.filter( + (incident) => + incident.status === incidentFilter.value || !incidentFilter.value + ), + [incidentFilter.value, incidents] + ); + + useEffect(() => { + dispatch( + fetchAllResolvedIncidents({ + team_id: singleTeamId, + from_date: dates.start, + to_date: dates.end + }) + ); + }, [dates.end, dates.start, dispatch, orgId, singleTeamId]); + + const { trendsSeriesMap } = useDoraMetricsGraph(); + const isTrendsSeriesDataAvailable = head( + trendsSeriesMap.meanTimeToRestoreTrends + ).data.length; + + if (isLoading || !team) return ; + if (!incidents.length) + return ( + + + No resolved incidents found for {team.name}{' '} + from {dateRangeLabel} + + + ); + + return ( + + + Mean time to recovery, across weeks + + {isTrendsSeriesDataAvailable ? ( + + + + ) : ( + Not enough data to show trends. + )} + + + + List of all incidents resolved from {dateRangeLabel} across all data + sources. + + + + + + + + {filteredIncidents?.map((incident) => { + return ( + + ); + })} + + + + + + + + ); +}; + +const SelectedIncidentDetails: FC<{ + incident: IncidentsWithDeploymentResponseType; +}> = ({ incident }) => { + const theme = useTheme(); + if (!incident) + return ( + + + Select an incident on the left + + + to view details including in the possible deployment + + + ); + const isAssigned = + incident.assigned_to.linked_user?.name || incident.assigned_to.username; + return ( + + + + + Incident details + + + + Assigned to{' '} + {incident.assigned_to.linked_user?.name || + incident.assigned_to.username || + 'No-one'} + + } + > + + {isAssigned ? ( + + Assignee{' '} + + + ) : ( + + Unassigned + + )} + + + } + variant="filled" + /> + + {incident.status} on{' '} + {format( + new Date( + incident.resolved_date || + incident.acknowledged_date || + incident.creation_date + ), + 'do MMMM' + )} + + } + > + + + {incident.status} + + + + + } + variant="filled" + /> + + + + + + + + + + + + + {incident.title} + + + + {incident.summary} + + + + ); +}; diff --git a/web-server/src/content/PullRequests/PrsReverted.tsx b/web-server/src/content/PullRequests/PrsReverted.tsx new file mode 100644 index 000000000..c3c8f138f --- /dev/null +++ b/web-server/src/content/PullRequests/PrsReverted.tsx @@ -0,0 +1,144 @@ +import { CheckCircleOutlined, WarningAmberRounded } from '@mui/icons-material'; +import { emphasize, lighten } from '@mui/material'; +import { FC, useMemo, useCallback } from 'react'; + +import { Chart2 } from '@/components/Chart2'; +import { FlexBox } from '@/components/FlexBox'; +import { PrTableWithPrExclusionMenu } from '@/components/PRTable/PrTableWithPrExclusionMenu'; +import { Line } from '@/components/Text'; +import { LineProps } from '@/components/Text/index'; +import { useModal } from '@/contexts/ModalContext'; +import { PR } from '@/types/resources'; +import { percent } from '@/utils/datatype'; + +export const RevertedPrs: FC<{ + id: string; + prs: PR[]; + revertedPrs: PR[]; + titleProps?: LineProps; + prUpdateCallback?: () => void; +}> = ({ id, prs, revertedPrs, titleProps, prUpdateCallback }) => { + const { addModal } = useModal(); + + const series = useMemo( + () => + [ + { + data: revertedPrs.length, + color: emphasize('#F8BBD0', 0.1), + label: 'Reverted' + }, + { + data: prs.length, + color: lighten('#80DEEA', 0.2), + label: 'Merged' + } + ].filter((serie) => serie.data), + [prs.length, revertedPrs.length] + ); + const [revertedSeries, revertedColors, revertedLabels, revertedPrCount] = + useMemo( + () => [ + series.map((s) => s.data), + series.map((s) => s.color), + series.map((s) => s.label), + revertedPrs.length + ], + [revertedPrs.length, series] + ); + + const openRevertedPrsModal = useCallback(async () => { + addModal({ + title: `Reverted PRs`, + body: ( + + ), + showCloseIcon: true + }); + }, [addModal, prUpdateCallback, revertedPrs]); + + if (!prs.length) return null; + + return ( + + + + PRs reverted + + + These were merged to mitigate bugs from recent PRs + + + + + + + {Boolean(revertedPrs.length) ? ( + + + + {prs.length === revertedPrs.length ? 'All ' : ''} + {revertedPrCount} {revertedPrCount > 1 ? 'PRs' : 'PR'} + {percent(revertedPrCount, prs.length) + ? ` (${percent(revertedPrCount, prs.length)}%)` + : ''} + + + reverted after merge + + + see details + + + ) : ( + + + + No {prs.length > 1 ? ' PRs' : ' PR'} + + + reverted after merge + + + )} + + + ); +}; diff --git a/web-server/src/hooks/useDoraMetricsGraph/index.tsx b/web-server/src/hooks/useDoraMetricsGraph/index.tsx index d24e648ea..ca444e65d 100644 --- a/web-server/src/hooks/useDoraMetricsGraph/index.tsx +++ b/web-server/src/hooks/useDoraMetricsGraph/index.tsx @@ -27,12 +27,10 @@ export const useDoraMetricsGraph = () => { leadTimeTrends?.previous ); - if (!activeTrends) return { trendsSeriesMap: null, yAxisLabels: [] }; - const yAxisLabels = useMemo(() => { return getSortedDatesAsArrayFromMap({ - ...activeTrends.previous, - ...activeTrends.current + ...(activeTrends?.previous || {}), + ...(activeTrends?.current || {}) }); }, [activeTrends]); @@ -183,6 +181,8 @@ export const useDoraMetricsGraph = () => { ] ); + if (!activeTrends) return { trendsSeriesMap: null, yAxisLabels: [] }; + return { trendsSeriesMap, yAxisLabels diff --git a/web-server/src/slices/dora_metrics.ts b/web-server/src/slices/dora_metrics.ts index a16c55f88..5f662fbe9 100644 --- a/web-server/src/slices/dora_metrics.ts +++ b/web-server/src/slices/dora_metrics.ts @@ -130,7 +130,7 @@ export const doraMetricsSlice = createSlice({ }); type DoraMetricsApiParamsType = { - team_id: string; + team_id: ID; from_date: Date; to_date: Date; branches?: string; @@ -161,7 +161,7 @@ export const fetchTeamDoraMetrics = createAsyncThunk( export const fetchAllDeploymentsWithIncidents = createAsyncThunk( 'dora_metrics/fetchAllIncidents', - async (params: DoraMetricsApiParamsType) => { + async (params: DoraMetricsApiParamsType & { org_id: ID }) => { return await handleApi( `internal/team/${params.team_id}/get_incidents`, { diff --git a/web-server/src/utils/stringFormatting.ts b/web-server/src/utils/stringFormatting.ts new file mode 100644 index 000000000..9bc08d28c --- /dev/null +++ b/web-server/src/utils/stringFormatting.ts @@ -0,0 +1,37 @@ +import { DatumValue } from '@nivo/core'; +import pluralize from 'pluralize'; +export const trimWithEllipsis = ( + text: string, + maxTextLength: number, + addInStart?: boolean +) => { + const diff = text.length - maxTextLength; + if (diff <= 3) return text; + const textStr = addInStart + ? `...${text.slice(text.length - maxTextLength)}` + : `${text.slice(0, maxTextLength)}...`; + return textStr; +}; + +export const pluralizePrCount = (value: number) => + `${value === 1 ? 'PR' : 'PRs'}`; + +export const formatAsPercent = (value: DatumValue) => + value ? `${value}%` : `0%`; + +export const formatAsDeployment = (value: number) => + value >= 1000 + ? `${value / 1000}k Deps` + : `${value} ${pluralize('deps', value)}`; + +export const joinNames = (names: string[]): string => { + if (names.length === 0) { + return ''; + } else if (names.length === 1) { + return names[0]; + } else { + const lastNames = names.slice(-1); + const otherNames = names.slice(0, -1); + return `${otherNames.join(', ')} and ${lastNames[0]}`; + } +};