From a8eda9d61f31310a0d6cc017efda4b4507b5ca5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Tue, 1 Oct 2024 09:02:08 +0100 Subject: [PATCH] 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) --- .../events/EventTimeline/EventTimeline.tsx | 123 +++++++++++++---- .../EventTimelineEventCircle.tsx | 14 +- .../EventTimelineEventGroup.tsx | 14 +- .../EventTimelineEventTooltip.tsx | 6 +- .../getters/useSignalQuery/useSignalQuery.ts | 125 ++++++++++++++++++ frontend/src/interfaces/signal.ts | 5 + 6 files changed, 248 insertions(+), 39 deletions(-) create mode 100644 frontend/src/hooks/api/getters/useSignalQuery/useSignalQuery.ts diff --git a/frontend/src/component/events/EventTimeline/EventTimeline.tsx b/frontend/src/component/events/EventTimeline/EventTimeline.tsx index faf43cdfd81c..dda221500a41 100644 --- a/frontend/src/component/events/EventTimeline/EventTimeline.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimeline.tsx @@ -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((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 ( <> { ))} diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventCircle.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventCircle.tsx index 375340c697eb..5d3969f56d66 100644 --- a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventCircle.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventCircle.tsx @@ -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 ; + } if (type === 'feature-environment-enabled') { return ; } @@ -57,8 +60,9 @@ const getEventIcon = (type: EventSchemaType) => { }; const customEventVariants: Partial< - Record + Record > = { + signal: 'warning', 'feature-environment-enabled': 'success', 'feature-environment-disabled': 'neutral', 'feature-archived': 'neutral', diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx index a9ecfba70941..9c458a4cbd69 100644 --- a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventGroup.tsx @@ -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 ( diff --git a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx index 2c8281eaac95..29d1ab92700d 100644 --- a/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx +++ b/frontend/src/component/events/EventTimeline/EventTimelineEventGroup/EventTimelineEventTooltip/EventTimelineEventTooltip.tsx @@ -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 = ({
{formatDateHMS( - event.createdAt, + event.timestamp, locationSettings?.locale, )} diff --git a/frontend/src/hooks/api/getters/useSignalQuery/useSignalQuery.ts b/frontend/src/hooks/api/getters/useSignalQuery/useSignalQuery.ts new file mode 100644 index 000000000000..6fedd43ff029 --- /dev/null +++ b/frontend/src/hooks/api/getters/useSignalQuery/useSignalQuery.ts @@ -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( + 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(); diff --git a/frontend/src/interfaces/signal.ts b/frontend/src/interfaces/signal.ts index 0a8ca150379f..7aac56956e8d 100644 --- a/frontend/src/interfaces/signal.ts +++ b/frontend/src/interfaces/signal.ts @@ -31,3 +31,8 @@ export interface ISignalEndpointSignal extends Omit { tokenName: string; } + +export interface ISignalQuerySignal extends ISignalEndpointSignal { + sourceName?: string; + sourceDescription?: string; +}