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. 
This commit is contained in:
parent
dcb0228105
commit
a8eda9d61f
@ -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 />
|
||||
|
@ -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',
|
||||
|
@ -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}>
|
||||
|
@ -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>
|
||||
|
125
frontend/src/hooks/api/getters/useSignalQuery/useSignalQuery.ts
Normal file
125
frontend/src/hooks/api/getters/useSignalQuery/useSignalQuery.ts
Normal 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();
|
@ -31,3 +31,8 @@ export interface ISignalEndpointSignal
|
||||
extends Omit<ISignal, 'createdBySourceTokenId'> {
|
||||
tokenName: string;
|
||||
}
|
||||
|
||||
export interface ISignalQuerySignal extends ISignalEndpointSignal {
|
||||
sourceName?: string;
|
||||
sourceDescription?: string;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user