From 205b59ddeeff753fe1699661eed8dfeab2403746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Thu, 19 Sep 2024 12:14:10 +0100 Subject: [PATCH] chore: event timeline (#8176) https://linear.app/unleash/issue/2-2657/implement-a-first-iteration-of-an-horizontal-event-timeline This implements the very first iteration of our event timeline. This is behind a feature flag, which when enabled shows the new timeline at the top of our event log page. It is missing some features, like: - Placement: It should show up as an option in the header, not in the event log; - Tooltip: It should show proper tooltips for all the events that we're displaying; - Grouping: It should group together events that occurred in a short span of time; - Signals: It should show signals along with the events; Here's how it currently looks like, with some example events, in order from left to right: - A flag was disabled more than 30 min ago; - A flag was then enabled; - A segment was updated (didn't have an icon for segments, so I picked one); - A strategy was updated; - A flag was created; ![image](https://github.com/user-attachments/assets/1716d8c0-e491-47cc-895b-e02d019c9e80) ![image](https://github.com/user-attachments/assets/a1b5c6b9-86d6-43f7-8a36-5661625e41d6) (Time passed since I took the first screenshot, so you can see the events "moved" to the left slightly in the dark theme screenshot) I have some concerns about the low contrast of `neutral` variant events, especially in dark mode. Maybe we should consider using `error` instead, for red? Or maybe add a border to our event circles? I specifically changed my environment to be "development" for the screenshots. The default selection is the first enabled environment that is `type=production`, which in my case is "production". Here are our filters: - Time Span ![image](https://github.com/user-attachments/assets/b0649d7b-c6c2-482f-918f-b35b23184578) - Environment ![image](https://github.com/user-attachments/assets/33c788d6-9d76-4afd-b921-3c81eda4e1c5) Here are a few more screenshots, with the different time spans (zooming out, since we're increasing the time span): ![image](https://github.com/user-attachments/assets/16003a67-039e-43ad-a4db-617f96ec5650) ![image](https://github.com/user-attachments/assets/6d50b53f-1fc0-4e07-96a6-6843629ecb2d) ![image](https://github.com/user-attachments/assets/e6cc6b10-ff02-44db-82d5-346fba8eb681) ![image](https://github.com/user-attachments/assets/1181b8d7-a951-4e5a-aa5b-bd9fdbd16a7a) ![image](https://github.com/user-attachments/assets/7a43c5a0-c51c-4861-952a-2c09968263d6) ![image](https://github.com/user-attachments/assets/5bfda117-5524-435b-b0d1-a8b1bd446a36) Again, when zooming out, some events should be grouped together, but that's a task for later. --- .../component/events/EventLog/EventLog.tsx | 113 +++++++----- .../events/EventTimeline/EventTimeline.tsx | 151 ++++++++++++++++ .../EventTimelineEvent/EventTimelineEvent.tsx | 108 +++++++++++ .../EventTimelineEventTooltip.tsx | 50 ++++++ .../EventTimelineHeader.tsx | 167 ++++++++++++++++++ 5 files changed, 542 insertions(+), 47 deletions(-) create mode 100644 frontend/src/component/events/EventTimeline/EventTimeline.tsx create mode 100644 frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEvent.tsx create mode 100644 frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx create mode 100644 frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx diff --git a/frontend/src/component/events/EventLog/EventLog.tsx b/frontend/src/component/events/EventLog/EventLog.tsx index e61ca8915c..a4f43c50fb 100644 --- a/frontend/src/component/events/EventLog/EventLog.tsx +++ b/frontend/src/component/events/EventLog/EventLog.tsx @@ -1,4 +1,4 @@ -import { Switch, FormControlLabel, useMediaQuery } from '@mui/material'; +import { Switch, FormControlLabel, useMediaQuery, Box } from '@mui/material'; import EventJson from 'component/events/EventJson/EventJson'; import { PageContent } from 'component/common/PageContent/PageContent'; import { PageHeader } from 'component/common/PageHeader/PageHeader'; @@ -15,6 +15,7 @@ import { useEventLogSearch } from './useEventLogSearch'; import { StickyPaginationBar } from 'component/common/Table/StickyPaginationBar/StickyPaginationBar'; import { EventActions } from './EventActions'; import useLoading from 'hooks/useLoading'; +import { EventTimeline } from '../EventTimeline/EventTimeline'; interface IEventLogProps { title: string; @@ -50,7 +51,8 @@ const Placeholder = styled('li')({ }); export const EventLog = ({ title, project, feature }: IEventLogProps) => { - const { isEnterprise } = useUiConfig(); + const { isOss, isEnterprise } = useUiConfig(); + const eventTimeline = useUiFlag('eventTimeline') && !isOss(); const showFilters = useUiFlag('newEventSearch') && isEnterprise(); const { events, @@ -131,55 +133,72 @@ export const EventLog = ({ title, project, feature }: IEventLogProps) => { }; return ( - - {showDataSwitch} - - {!isSmallScreen && searchInputField} - - } - > - {isSmallScreen && searchInputField} - - } - > - + <> + ({ + borderRadius: theme.shape.borderRadius, + padding: theme.spacing(2), + marginBottom: theme.spacing(2), + backgroundColor: theme.palette.background.paper, + })} + > + + + } + /> + + {showDataSwitch} + + {!isSmallScreen && searchInputField} + + } + > + {isSmallScreen && searchInputField} + + } + > + + + } + /> + {resultComponent()} + 25} show={ - } /> - {resultComponent()} - - 25} - show={ - - } - /> - + + ); }; diff --git a/frontend/src/component/events/EventTimeline/EventTimeline.tsx b/frontend/src/component/events/EventTimeline/EventTimeline.tsx new file mode 100644 index 0000000000..9cee0ca075 --- /dev/null +++ b/frontend/src/component/events/EventTimeline/EventTimeline.tsx @@ -0,0 +1,151 @@ +import { styled } from '@mui/material'; +import type { EventSchemaType } from 'openapi'; +import { useState } from 'react'; +import { startOfDay, sub } from 'date-fns'; +import type { IEnvironment } from 'interfaces/environments'; +import { useEventSearch } from 'hooks/api/getters/useEventSearch/useEventSearch'; +import { EventTimelineEvent } from './EventTimelineEvent/EventTimelineEvent'; +import { + EventTimelineHeader, + type TimeSpanOption, + timeSpanOptions, +} from './EventTimelineHeader/EventTimelineHeader'; + +const StyledRow = styled('div')({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', +}); + +const StyledTimelineContainer = styled('div')(({ theme }) => ({ + position: 'relative', + height: theme.spacing(1), + width: '100%', + display: 'flex', + alignItems: 'center', + padding: theme.spacing(1.5, 0), +})); + +const StyledTimeline = styled('div')(({ theme }) => ({ + backgroundColor: theme.palette.divider, + height: theme.spacing(0.5), + width: '100%', +})); + +const StyledMiddleMarkerContainer = styled('div')({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + position: 'relative', +}); + +const StyledMarker = styled('div')(({ theme }) => ({ + position: 'absolute', + height: theme.spacing(1), + width: theme.spacing(0.25), + backgroundColor: theme.palette.text.secondary, +})); + +const StyledMiddleMarker = styled(StyledMarker)(({ theme }) => ({ + top: theme.spacing(-2), +})); + +const StyledMarkerLabel = styled('div')(({ theme }) => ({ + fontSize: theme.fontSizes.smallerBody, + color: theme.palette.text.secondary, +})); + +const StyledStart = styled(StyledMarker)({ + left: 0, +}); + +const StyledEnd = styled(StyledMarker)({ + right: 0, +}); + +const RELEVANT_EVENT_TYPES: EventSchemaType[] = [ + 'strategy-reactivated', + 'strategy-updated', + 'segment-updated', + 'segment-deleted', + 'feature-created', + 'feature-updated', + 'feature-variants-updated', + 'feature-archived', + 'feature-revived', + 'feature-strategy-update', + 'feature-strategy-add', + 'feature-strategy-remove', + 'feature-environment-enabled', + 'feature-environment-disabled', +]; + +const toISODateString = (date: Date) => date.toISOString().split('T')[0]; + +export const EventTimeline = () => { + const [timeSpan, setTimeSpan] = useState( + timeSpanOptions[0], + ); + const [environment, setEnvironment] = useState(); + + const endDate = new Date(); + const startDate = sub(endDate, timeSpan.value); + + const { events } = useEventSearch( + { + from: `IS:${toISODateString(startOfDay(startDate))}`, + to: `IS:${toISODateString(endDate)}`, + type: `IS_ANY_OF:${RELEVANT_EVENT_TYPES.join(',')}`, + }, + { refreshInterval: 10 * 1000 }, + ); + + const filteredEvents = events.filter( + (event) => + new Date(event.createdAt).getTime() >= startDate.getTime() && + new Date(event.createdAt).getTime() <= endDate.getTime() && + RELEVANT_EVENT_TYPES.includes(event.type) && + (!event.environment || + !environment || + event.environment === environment.name), + ); + + const sortedEvents = [...filteredEvents].reverse(); + + return ( + <> + + + + + + + {sortedEvents.map((event) => ( + + ))} + + + + {timeSpan.markers[0]} + {timeSpan.markers.slice(1).map((marker) => ( + + + {marker} + + ))} + now + + + ); +}; diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEvent.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEvent.tsx new file mode 100644 index 0000000000..8cdedc001d --- /dev/null +++ b/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEvent.tsx @@ -0,0 +1,108 @@ +import type { EventSchema, EventSchemaType } from 'openapi'; +import { styled } from '@mui/material'; +import { HtmlTooltip } from 'component/common/HtmlTooltip/HtmlTooltip'; +import { EventTimelineEventTooltip } from './EventTimelineEventTooltip/EventTimelineEventTooltip'; +import ToggleOnIcon from '@mui/icons-material/ToggleOn'; +import ToggleOffIcon from '@mui/icons-material/ToggleOff'; +import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined'; +import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined'; +import SegmentsIcon from '@mui/icons-material/DonutLargeOutlined'; +import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; + +type DefaultEventVariant = 'secondary'; +type CustomEventVariant = 'success' | 'neutral'; +type EventVariant = DefaultEventVariant | CustomEventVariant; + +const StyledEvent = styled('div', { + shouldForwardProp: (prop) => prop !== 'position', +})<{ position: string }>(({ position }) => ({ + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + left: position, + transform: 'translateX(-50%)', + zIndex: 1, +})); + +const StyledEventCircle = styled('div', { + shouldForwardProp: (prop) => prop !== 'variant', +})<{ variant: EventVariant }>(({ theme, variant }) => ({ + height: theme.spacing(3), + width: theme.spacing(3), + borderRadius: '50%', + backgroundColor: theme.palette[variant].light, + border: `1px solid ${theme.palette[variant].main}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'transform 0.2s', + '& svg': { + color: theme.palette[variant].main, + height: theme.spacing(2.5), + width: theme.spacing(2.5), + }, + '&:hover': { + transform: 'scale(1.5)', + }, +})); + +const getEventIcon = (type: EventSchemaType) => { + if (type === 'feature-environment-enabled') { + return ; + } + if (type === 'feature-environment-disabled') { + return ; + } + if (type.startsWith('strategy-') || type.startsWith('feature-strategy-')) { + return ; + } + if (type.startsWith('feature-')) { + return ; + } + if (type.startsWith('segment-')) { + return ; + } + + return ; +}; + +const customEventVariants: Partial< + Record +> = { + 'feature-environment-enabled': 'success', + 'feature-environment-disabled': 'neutral', + 'feature-archived': 'neutral', +}; + +interface IEventTimelineEventProps { + event: EventSchema; + startDate: Date; + endDate: Date; +} + +export const EventTimelineEvent = ({ + event, + startDate, + endDate, +}: IEventTimelineEventProps) => { + const timelineDuration = endDate.getTime() - startDate.getTime(); + const eventTime = new Date(event.createdAt).getTime(); + + const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`; + + const variant = customEventVariants[event.type] || 'secondary'; + + return ( + + } + arrow + > + + {getEventIcon(event.type)} + + + + ); +}; diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx new file mode 100644 index 0000000000..328d35cf4d --- /dev/null +++ b/frontend/src/component/events/EventTimeline/EventTimelineEvent/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx @@ -0,0 +1,50 @@ +import { useLocationSettings } from 'hooks/useLocationSettings'; +import type { EventSchema } from 'openapi'; +import { formatDateYMDHMS } from 'utils/formatDate'; + +interface IEventTimelineEventTooltipProps { + event: EventSchema; +} + +export const EventTimelineEventTooltip = ({ + event, +}: IEventTimelineEventTooltipProps) => { + const { locationSettings } = useLocationSettings(); + const eventDateTime = formatDateYMDHMS( + event.createdAt, + locationSettings?.locale, + ); + + if (event.type === 'feature-environment-enabled') { + return ( +
+ {eventDateTime} +

+ {event.createdBy} enabled {event.featureName} for the{' '} + {event.environment} environment in project {event.project} +

+
+ ); + } + if (event.type === 'feature-environment-disabled') { + return ( +
+ {eventDateTime} +

+ {event.createdBy} disabled {event.featureName} for the{' '} + {event.environment} environment in project {event.project} +

+
+ ); + } + + return ( +
+
{eventDateTime}
+
{event.createdBy}
+
{event.type}
+
{event.featureName}
+
{event.environment}
+
+ ); +}; diff --git a/frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx b/frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx new file mode 100644 index 0000000000..0cb1d0ef86 --- /dev/null +++ b/frontend/src/component/events/EventTimeline/EventTimelineHeader/EventTimelineHeader.tsx @@ -0,0 +1,167 @@ +import { MenuItem, styled, TextField } from '@mui/material'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; +import type { IEnvironment } from 'interfaces/environments'; +import { useEffect, useMemo } from 'react'; + +const StyledCol = styled('div')(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1), +})); + +const StyledFilter = styled(TextField)(({ theme }) => ({ + color: theme.palette.text.secondary, + '& > div': { + background: 'transparent', + '& > .MuiSelect-select': { + padding: theme.spacing(0.5, 4, 0.5, 1), + background: 'transparent', + }, + '& > fieldset': { borderColor: 'transparent' }, + }, +})); + +export type TimeSpanOption = { + key: string; + label: string; + value: Duration; + markers: string[]; +}; + +export const timeSpanOptions: TimeSpanOption[] = [ + { + key: '30m', + label: 'last 30 min', + value: { minutes: 30 }, + markers: ['30 min ago'], + }, + { + key: '1h', + label: 'last hour', + value: { hours: 1 }, + markers: ['1 hour ago', '30 min ago'], + }, + { + key: '3h', + label: 'last 3 hours', + value: { hours: 3 }, + markers: ['3 hours ago', '2 hours ago', '1 hour ago'], + }, + { + key: '12h', + label: 'last 12 hours', + value: { hours: 12 }, + markers: ['12 hours ago', '9 hours ago', '6 hours ago', '3 hours ago'], + }, + { + key: '24h', + label: 'last 24 hours', + value: { hours: 24 }, + markers: [ + '24 hours ago', + '18 hours ago', + '12 hours ago', + '6 hours ago', + ], + }, + { + key: '48h', + label: 'last 48 hours', + value: { hours: 48 }, + markers: [ + '48 hours ago', + '36 hours ago', + '24 hours ago', + '12 hours ago', + ], + }, +]; + +interface IEventTimelineHeaderProps { + totalEvents: number; + timeSpan: TimeSpanOption; + setTimeSpan: (timeSpan: TimeSpanOption) => void; + environment: IEnvironment | undefined; + setEnvironment: (environment: IEnvironment) => void; +} + +export const EventTimelineHeader = ({ + totalEvents, + timeSpan, + setTimeSpan, + environment, + setEnvironment, +}: IEventTimelineHeaderProps) => { + const { environments } = useEnvironments(); + + const activeEnvironments = useMemo( + () => environments.filter(({ enabled }) => enabled), + [environments], + ); + + useEffect(() => { + if (activeEnvironments.length > 0) { + const defaultEnvironment = + activeEnvironments.find(({ type }) => type === 'production') || + activeEnvironments[0]; + setEnvironment(defaultEnvironment); + } + }, [activeEnvironments]); + + return ( + <> + + + {totalEvents} event + {totalEvents === 1 ? '' : 's'} + + + setTimeSpan( + timeSpanOptions.find( + ({ key }) => key === e.target.value, + ) || timeSpanOptions[0], + ) + } + > + {timeSpanOptions.map(({ key, label }) => ( + + {label} + + ))} + + + + ( + + setEnvironment( + environments.find( + ({ name }) => name === e.target.value, + ) || environments[0], + ) + } + > + {environments.map(({ name }) => ( + + {name} + + ))} + + )} + /> + + + ); +};