1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-10 01:16:39 +02:00

chore: event timeline signals (#8310)

https://linear.app/unleash/issue/2-2665/show-signals-in-the-event-timeline

Implements signals in the event timeline.

This merges events and signals into a unified `TimelineEvent`
abstraction, streamlining the data structure to only include properties
relevant to the timeline.

Key changes:
- Refactors the timeline logic to handle both events and signals through
the new abstraction.
- Introduces the `useSignalQuery` hook, modeled after `useEventSearch`,
as both serve similar purposes, albeit for different resource types.

Note: The signals suggestion alert is not included and will be addressed
in a future task.


![image](https://github.com/user-attachments/assets/9dad5c21-cd36-45e6-9369-ceca25936123)
This commit is contained in:
Nuno Góis 2024-10-01 09:02:08 +01:00 committed by GitHub
parent dcb0228105
commit a8eda9d61f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 248 additions and 39 deletions

View File

@ -6,14 +6,23 @@ import { EventTimelineEventGroup } from './EventTimelineEventGroup/EventTimeline
import { EventTimelineHeader } from './EventTimelineHeader/EventTimelineHeader';
import { useEventTimeline } from './useEventTimeline';
import { useMemo } from 'react';
import { useSignalQuery } from 'hooks/api/getters/useSignalQuery/useSignalQuery';
import type { ISignalQuerySignal } from 'interfaces/signal';
import type { IEnvironment } from 'interfaces/environments';
export type EnrichedEvent = EventSchema & {
export type TimelineEventType = 'signal' | EventSchemaType;
type RawTimelineEvent = EventSchema | ISignalQuerySignal;
type TimelineEvent = {
id: number;
timestamp: number;
type: TimelineEventType;
label: string;
summary: string;
timestamp: number;
};
export type TimelineEventGroup = EnrichedEvent[];
export type TimelineEventGroup = TimelineEvent[];
const StyledRow = styled('div')({
display: 'flex',
@ -86,6 +95,62 @@ const RELEVANT_EVENT_TYPES: EventSchemaType[] = [
const toISODateString = (date: Date) => date.toISOString().split('T')[0];
const isSignal = (event: RawTimelineEvent): event is ISignalQuerySignal =>
'sourceId' in event;
const getTimestamp = (event: RawTimelineEvent) => {
return new Date(event.createdAt).getTime();
};
const isInRange = (timestamp: number, startTime: number, endTime: number) =>
timestamp >= startTime && timestamp <= endTime;
const getTimelineEvent = (
event: RawTimelineEvent,
timestamp: number,
environment?: IEnvironment,
): TimelineEvent | undefined => {
if (isSignal(event)) {
const {
id,
sourceName = 'unknown source',
sourceDescription,
tokenName,
} = event;
const label = `Signal: ${sourceName}`;
const summary = `Signal originated from **[${sourceName} (${tokenName})](/integrations/signals)** endpoint${sourceDescription ? `: ${sourceDescription}` : ''}`;
return {
id,
timestamp,
type: 'signal',
label,
summary,
};
}
if (
!event.environment ||
!environment ||
event.environment === environment.name
) {
const {
id,
type,
label: eventLabel,
summary: eventSummary,
createdBy,
} = event;
const label = eventLabel || type;
const summary =
eventSummary || `**${createdBy}** triggered **${type}**`;
return { id, timestamp, type, label, summary };
}
};
export const EventTimeline = () => {
const { timeSpan, environment, setTimeSpan, setEnvironment } =
useEventTimeline();
@ -103,24 +168,34 @@ export const EventTimeline = () => {
},
{ refreshInterval: 10 * 1000 },
);
const events = useMemo(() => {
return baseEvents.map((event) => ({
...event,
timestamp: new Date(event.createdAt).getTime(),
}));
}, [baseEvents]) as EnrichedEvent[];
const filteredEvents = events.filter(
(event) =>
event.timestamp >= startTime &&
event.timestamp <= endTime &&
(!event.environment ||
!environment ||
event.environment === environment.name),
const { signals: baseSignals } = useSignalQuery(
{
from: `IS:${toISODateString(startOfDay(startDate))}`,
to: `IS:${toISODateString(endDate)}`,
},
{ refreshInterval: 10 * 1000 },
);
const sortedEvents = filteredEvents.reverse();
const events = useMemo(
() =>
[...baseEvents, ...baseSignals]
.reduce<TimelineEvent[]>((acc, event) => {
const timestamp = getTimestamp(event);
if (isInRange(timestamp, startTime, endTime)) {
const timelineEvent = getTimelineEvent(
event,
timestamp,
environment,
);
if (timelineEvent) {
acc.push(timelineEvent);
}
}
return acc;
}, [])
.sort((a, b) => a.timestamp - b.timestamp),
[baseEvents, baseSignals, startTime, endTime, environment],
);
const timespanInMs = endTime - startTime;
const groupingThresholdInMs = useMemo(
@ -130,7 +205,7 @@ export const EventTimeline = () => {
const groups = useMemo(
() =>
sortedEvents.reduce((groups: TimelineEventGroup[], event) => {
events.reduce((groups: TimelineEventGroup[], event) => {
if (groups.length === 0) {
groups.push([event]);
} else {
@ -148,14 +223,14 @@ export const EventTimeline = () => {
}
return groups;
}, []),
[sortedEvents, groupingThresholdInMs],
[events, groupingThresholdInMs],
);
return (
<>
<StyledRow>
<EventTimelineHeader
totalEvents={sortedEvents.length}
totalEvents={events.length}
timeSpan={timeSpan}
setTimeSpan={setTimeSpan}
environment={environment}
@ -169,8 +244,8 @@ export const EventTimeline = () => {
<EventTimelineEventGroup
key={group[0].id}
group={group}
startDate={startDate}
endDate={endDate}
startTime={startTime}
endTime={endTime}
/>
))}
<StyledEnd />

View File

@ -1,4 +1,3 @@
import type { EventSchemaType } from 'openapi';
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
@ -6,12 +5,13 @@ import ExtensionOutlinedIcon from '@mui/icons-material/ExtensionOutlined';
import SegmentsIcon from '@mui/icons-material/DonutLargeOutlined';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import { styled } from '@mui/material';
import type { TimelineEventGroup } from '../EventTimeline';
import type { TimelineEventGroup, TimelineEventType } from '../EventTimeline';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
import type { HTMLAttributes } from 'react';
import SensorsIcon from '@mui/icons-material/Sensors';
type DefaultEventVariant = 'secondary';
type CustomEventVariant = 'success' | 'neutral';
type CustomEventVariant = 'success' | 'neutral' | 'warning';
type EventVariant = DefaultEventVariant | CustomEventVariant;
const StyledEventCircle = styled('div', {
@ -36,7 +36,10 @@ const StyledEventCircle = styled('div', {
},
}));
const getEventIcon = (type: EventSchemaType) => {
const getEventIcon = (type: TimelineEventType) => {
if (type === 'signal') {
return <SensorsIcon />;
}
if (type === 'feature-environment-enabled') {
return <ToggleOnIcon />;
}
@ -57,8 +60,9 @@ const getEventIcon = (type: EventSchemaType) => {
};
const customEventVariants: Partial<
Record<EventSchemaType, CustomEventVariant>
Record<TimelineEventType, CustomEventVariant>
> = {
signal: 'warning',
'feature-environment-enabled': 'success',
'feature-environment-disabled': 'neutral',
'feature-archived': 'neutral',

View File

@ -18,19 +18,19 @@ const StyledEvent = styled('div', {
interface IEventTimelineEventProps {
group: TimelineEventGroup;
startDate: Date;
endDate: Date;
startTime: number;
endTime: number;
}
export const EventTimelineEventGroup = ({
group,
startDate,
endDate,
startTime,
endTime,
}: IEventTimelineEventProps) => {
const timelineDuration = endDate.getTime() - startDate.getTime();
const eventTime = new Date(group[0].createdAt).getTime();
const timelineDuration = endTime - startTime;
const eventTime = group[0].timestamp;
const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`;
const position = `${((eventTime - startTime) / timelineDuration) * 100}%`;
return (
<StyledEvent position={position}>

View File

@ -68,7 +68,7 @@ export const EventTimelineEventTooltip = ({
if (group.length === 1) {
const event = group[0];
const eventDateTime = formatDateYMDHMS(
event.createdAt,
event.timestamp,
locationSettings?.locale,
);
@ -85,7 +85,7 @@ export const EventTimelineEventTooltip = ({
const firstEvent = group[0];
const eventDate = formatDateYMD(
firstEvent.createdAt,
firstEvent.timestamp,
locationSettings?.locale,
);
@ -103,7 +103,7 @@ export const EventTimelineEventTooltip = ({
<div>
<StyledDate>
{formatDateHMS(
event.createdAt,
event.timestamp,
locationSettings?.locale,
)}
</StyledDate>

View File

@ -0,0 +1,125 @@
import type { SWRConfiguration } from 'swr';
import { useCallback, useContext } from 'react';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { useClearSWRCache } from 'hooks/useClearSWRCache';
import type { ISignalQuerySignal } from 'interfaces/signal';
import useUiConfig from '../useUiConfig/useUiConfig';
import { useUiFlag } from 'hooks/useUiFlag';
import AccessContext from 'contexts/AccessContext';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
type SignalQueryParams = {
from?: string;
to?: string;
offset?: string;
limit?: string;
};
type SignalQueryResponse = {
signals: ISignalQuerySignal[];
total: number;
};
type UseSignalsOutput = {
loading: boolean;
initialLoad: boolean;
error: string;
refetch: () => void;
} & SignalQueryResponse;
type CacheValue = {
total: number;
initialLoad: boolean;
[key: string]: number | boolean;
};
const fallbackData: SignalQueryResponse = {
signals: [],
total: 0,
};
const SWR_CACHE_SIZE = 10;
const PATH = 'api/admin/signals?';
const createSignalQuery = () => {
const internalCache: CacheValue = {
total: 0,
initialLoad: true,
};
const set = (key: string, value: number | boolean) => {
internalCache[key] = value;
};
return (
params: SignalQueryParams,
options: SWRConfiguration = {},
cachePrefix: string = '',
): UseSignalsOutput => {
const { isAdmin } = useContext(AccessContext);
const { isEnterprise } = useUiConfig();
const signalsEnabled = useUiFlag('signals');
const { KEY, fetcher } = getSignalQueryFetcher(params);
const swrKey = `${cachePrefix}${KEY}`;
useClearSWRCache(swrKey, PATH, SWR_CACHE_SIZE);
const { data, error, mutate, isLoading } =
useConditionalSWR<SignalQueryResponse>(
isEnterprise() && isAdmin && signalsEnabled,
fallbackData,
swrKey,
fetcher,
options,
);
const refetch = useCallback(() => {
mutate();
}, [mutate]);
if (data?.total !== undefined) {
set('total', data.total);
}
if (!isLoading && internalCache.initialLoad) {
set('initialLoad', false);
}
const returnData = data || fallbackData;
return {
...returnData,
loading: isLoading,
error,
refetch,
total: internalCache.total,
initialLoad: isLoading && internalCache.initialLoad,
};
};
};
const getSignalQueryFetcher = (params: SignalQueryParams) => {
const urlSearchParams = new URLSearchParams(
Array.from(
Object.entries(params)
.filter(([_, value]) => !!value)
.map(([key, value]) => [key, value.toString()]),
),
).toString();
const KEY = `${PATH}${urlSearchParams}`;
const fetcher = () => {
const path = formatApiPath(KEY);
return fetch(path, {
method: 'GET',
})
.then(handleErrorResponses('Signal query'))
.then((res) => res.json());
};
return {
fetcher,
KEY,
};
};
export const useSignalQuery = createSignalQuery();

View File

@ -31,3 +31,8 @@ export interface ISignalEndpointSignal
extends Omit<ISignal, 'createdBySourceTokenId'> {
tokenName: string;
}
export interface ISignalQuerySignal extends ISignalEndpointSignal {
sourceName?: string;
sourceDescription?: string;
}