1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

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.
This commit is contained in:
Nuno Góis 2024-09-19 12:14:10 +01:00 committed by GitHub
parent 70e95e66a8
commit 205b59ddee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 542 additions and 47 deletions

View File

@ -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 (
<PageContent
bodyClass={'no-padding'}
header={
<PageHeader
title={`${title} (${total})`}
actions={
<>
{showDataSwitch}
<EventActions events={events} />
{!isSmallScreen && searchInputField}
</>
}
>
{isSmallScreen && searchInputField}
</PageHeader>
}
>
<EventResultWrapper ref={ref} withFilters={showFilters}>
<>
<ConditionallyRender
condition={eventTimeline}
show={
<Box
sx={(theme) => ({
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(2),
marginBottom: theme.spacing(2),
backgroundColor: theme.palette.background.paper,
})}
>
<EventTimeline />
</Box>
}
/>
<PageContent
bodyClass={'no-padding'}
header={
<PageHeader
title={`${title} (${total})`}
actions={
<>
{showDataSwitch}
<EventActions events={events} />
{!isSmallScreen && searchInputField}
</>
}
>
{isSmallScreen && searchInputField}
</PageHeader>
}
>
<EventResultWrapper ref={ref} withFilters={showFilters}>
<ConditionallyRender
condition={showFilters}
show={
<StyledFilters
logType={
project
? 'project'
: feature
? 'flag'
: 'global'
}
state={filterState}
onChange={setTableState}
/>
}
/>
{resultComponent()}
</EventResultWrapper>
<ConditionallyRender
condition={showFilters}
condition={total > 25}
show={
<StyledFilters
logType={
project
? 'project'
: feature
? 'flag'
: 'global'
}
state={filterState}
onChange={setTableState}
<StickyPaginationBar
totalItems={total}
pageSize={pagination.pageSize}
pageIndex={pagination.currentPage}
fetchPrevPage={pagination.prevPage}
fetchNextPage={pagination.nextPage}
setPageLimit={pagination.setPageLimit}
/>
}
/>
{resultComponent()}
</EventResultWrapper>
<ConditionallyRender
condition={total > 25}
show={
<StickyPaginationBar
totalItems={total}
pageSize={pagination.pageSize}
pageIndex={pagination.currentPage}
fetchPrevPage={pagination.prevPage}
fetchNextPage={pagination.nextPage}
setPageLimit={pagination.setPageLimit}
/>
}
/>
</PageContent>
</PageContent>
</>
);
};

View File

@ -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<TimeSpanOption>(
timeSpanOptions[0],
);
const [environment, setEnvironment] = useState<IEnvironment | undefined>();
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 (
<>
<StyledRow>
<EventTimelineHeader
totalEvents={sortedEvents.length}
timeSpan={timeSpan}
setTimeSpan={setTimeSpan}
environment={environment}
setEnvironment={setEnvironment}
/>
</StyledRow>
<StyledTimelineContainer>
<StyledTimeline />
<StyledStart />
{sortedEvents.map((event) => (
<EventTimelineEvent
key={event.id}
event={event}
startDate={startDate}
endDate={endDate}
/>
))}
<StyledEnd />
</StyledTimelineContainer>
<StyledRow>
<StyledMarkerLabel>{timeSpan.markers[0]}</StyledMarkerLabel>
{timeSpan.markers.slice(1).map((marker) => (
<StyledMiddleMarkerContainer key={marker}>
<StyledMiddleMarker />
<StyledMarkerLabel>{marker}</StyledMarkerLabel>
</StyledMiddleMarkerContainer>
))}
<StyledMarkerLabel>now</StyledMarkerLabel>
</StyledRow>
</>
);
};

View File

@ -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 <ToggleOnIcon />;
}
if (type === 'feature-environment-disabled') {
return <ToggleOffIcon />;
}
if (type.startsWith('strategy-') || type.startsWith('feature-strategy-')) {
return <ExtensionOutlinedIcon />;
}
if (type.startsWith('feature-')) {
return <FlagOutlinedIcon />;
}
if (type.startsWith('segment-')) {
return <SegmentsIcon />;
}
return <QuestionMarkIcon />;
};
const customEventVariants: Partial<
Record<EventSchemaType, CustomEventVariant>
> = {
'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 (
<StyledEvent position={position}>
<HtmlTooltip
title={<EventTimelineEventTooltip event={event} />}
arrow
>
<StyledEventCircle variant={variant}>
{getEventIcon(event.type)}
</StyledEventCircle>
</HtmlTooltip>
</StyledEvent>
);
};

View File

@ -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 (
<div>
<small>{eventDateTime}</small>
<p>
{event.createdBy} enabled {event.featureName} for the{' '}
{event.environment} environment in project {event.project}
</p>
</div>
);
}
if (event.type === 'feature-environment-disabled') {
return (
<div>
<small>{eventDateTime}</small>
<p>
{event.createdBy} disabled {event.featureName} for the{' '}
{event.environment} environment in project {event.project}
</p>
</div>
);
}
return (
<div>
<div>{eventDateTime}</div>
<div>{event.createdBy}</div>
<div>{event.type}</div>
<div>{event.featureName}</div>
<div>{event.environment}</div>
</div>
);
};

View File

@ -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 (
<>
<StyledCol>
<span>
{totalEvents} event
{totalEvents === 1 ? '' : 's'}
</span>
<StyledFilter
select
size='small'
variant='outlined'
value={timeSpan.key}
onChange={(e) =>
setTimeSpan(
timeSpanOptions.find(
({ key }) => key === e.target.value,
) || timeSpanOptions[0],
)
}
>
{timeSpanOptions.map(({ key, label }) => (
<MenuItem key={key} value={key}>
{label}
</MenuItem>
))}
</StyledFilter>
</StyledCol>
<StyledCol>
<ConditionallyRender
condition={Boolean(environment)}
show={() => (
<StyledFilter
select
size='small'
variant='outlined'
value={environment!.name}
onChange={(e) =>
setEnvironment(
environments.find(
({ name }) => name === e.target.value,
) || environments[0],
)
}
>
{environments.map(({ name }) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</StyledFilter>
)}
/>
</StyledCol>
</>
);
};