mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-02 01:17:58 +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 { EventTimelineHeader } from './EventTimelineHeader/EventTimelineHeader';
|
||||||
import { useEventTimeline } from './useEventTimeline';
|
import { useEventTimeline } from './useEventTimeline';
|
||||||
import { useMemo } from 'react';
|
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;
|
label: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
timestamp: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TimelineEventGroup = EnrichedEvent[];
|
export type TimelineEventGroup = TimelineEvent[];
|
||||||
|
|
||||||
const StyledRow = styled('div')({
|
const StyledRow = styled('div')({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -86,6 +95,62 @@ const RELEVANT_EVENT_TYPES: EventSchemaType[] = [
|
|||||||
|
|
||||||
const toISODateString = (date: Date) => date.toISOString().split('T')[0];
|
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 = () => {
|
export const EventTimeline = () => {
|
||||||
const { timeSpan, environment, setTimeSpan, setEnvironment } =
|
const { timeSpan, environment, setTimeSpan, setEnvironment } =
|
||||||
useEventTimeline();
|
useEventTimeline();
|
||||||
@ -103,24 +168,34 @@ export const EventTimeline = () => {
|
|||||||
},
|
},
|
||||||
{ refreshInterval: 10 * 1000 },
|
{ refreshInterval: 10 * 1000 },
|
||||||
);
|
);
|
||||||
|
const { signals: baseSignals } = useSignalQuery(
|
||||||
const events = useMemo(() => {
|
{
|
||||||
return baseEvents.map((event) => ({
|
from: `IS:${toISODateString(startOfDay(startDate))}`,
|
||||||
...event,
|
to: `IS:${toISODateString(endDate)}`,
|
||||||
timestamp: new Date(event.createdAt).getTime(),
|
},
|
||||||
}));
|
{ refreshInterval: 10 * 1000 },
|
||||||
}, [baseEvents]) as EnrichedEvent[];
|
|
||||||
|
|
||||||
const filteredEvents = events.filter(
|
|
||||||
(event) =>
|
|
||||||
event.timestamp >= startTime &&
|
|
||||||
event.timestamp <= endTime &&
|
|
||||||
(!event.environment ||
|
|
||||||
!environment ||
|
|
||||||
event.environment === environment.name),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
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 timespanInMs = endTime - startTime;
|
||||||
const groupingThresholdInMs = useMemo(
|
const groupingThresholdInMs = useMemo(
|
||||||
@ -130,7 +205,7 @@ export const EventTimeline = () => {
|
|||||||
|
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
sortedEvents.reduce((groups: TimelineEventGroup[], event) => {
|
events.reduce((groups: TimelineEventGroup[], event) => {
|
||||||
if (groups.length === 0) {
|
if (groups.length === 0) {
|
||||||
groups.push([event]);
|
groups.push([event]);
|
||||||
} else {
|
} else {
|
||||||
@ -148,14 +223,14 @@ export const EventTimeline = () => {
|
|||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, []),
|
}, []),
|
||||||
[sortedEvents, groupingThresholdInMs],
|
[events, groupingThresholdInMs],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StyledRow>
|
<StyledRow>
|
||||||
<EventTimelineHeader
|
<EventTimelineHeader
|
||||||
totalEvents={sortedEvents.length}
|
totalEvents={events.length}
|
||||||
timeSpan={timeSpan}
|
timeSpan={timeSpan}
|
||||||
setTimeSpan={setTimeSpan}
|
setTimeSpan={setTimeSpan}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
@ -169,8 +244,8 @@ export const EventTimeline = () => {
|
|||||||
<EventTimelineEventGroup
|
<EventTimelineEventGroup
|
||||||
key={group[0].id}
|
key={group[0].id}
|
||||||
group={group}
|
group={group}
|
||||||
startDate={startDate}
|
startTime={startTime}
|
||||||
endDate={endDate}
|
endTime={endTime}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<StyledEnd />
|
<StyledEnd />
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import type { EventSchemaType } from 'openapi';
|
|
||||||
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
|
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
|
||||||
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
|
import ToggleOffIcon from '@mui/icons-material/ToggleOff';
|
||||||
import FlagOutlinedIcon from '@mui/icons-material/FlagOutlined';
|
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 SegmentsIcon from '@mui/icons-material/DonutLargeOutlined';
|
||||||
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import type { TimelineEventGroup } from '../EventTimeline';
|
import type { TimelineEventGroup, TimelineEventType } from '../EventTimeline';
|
||||||
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
||||||
import type { HTMLAttributes } from 'react';
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import SensorsIcon from '@mui/icons-material/Sensors';
|
||||||
|
|
||||||
type DefaultEventVariant = 'secondary';
|
type DefaultEventVariant = 'secondary';
|
||||||
type CustomEventVariant = 'success' | 'neutral';
|
type CustomEventVariant = 'success' | 'neutral' | 'warning';
|
||||||
type EventVariant = DefaultEventVariant | CustomEventVariant;
|
type EventVariant = DefaultEventVariant | CustomEventVariant;
|
||||||
|
|
||||||
const StyledEventCircle = styled('div', {
|
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') {
|
if (type === 'feature-environment-enabled') {
|
||||||
return <ToggleOnIcon />;
|
return <ToggleOnIcon />;
|
||||||
}
|
}
|
||||||
@ -57,8 +60,9 @@ const getEventIcon = (type: EventSchemaType) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const customEventVariants: Partial<
|
const customEventVariants: Partial<
|
||||||
Record<EventSchemaType, CustomEventVariant>
|
Record<TimelineEventType, CustomEventVariant>
|
||||||
> = {
|
> = {
|
||||||
|
signal: 'warning',
|
||||||
'feature-environment-enabled': 'success',
|
'feature-environment-enabled': 'success',
|
||||||
'feature-environment-disabled': 'neutral',
|
'feature-environment-disabled': 'neutral',
|
||||||
'feature-archived': 'neutral',
|
'feature-archived': 'neutral',
|
||||||
|
@ -18,19 +18,19 @@ const StyledEvent = styled('div', {
|
|||||||
|
|
||||||
interface IEventTimelineEventProps {
|
interface IEventTimelineEventProps {
|
||||||
group: TimelineEventGroup;
|
group: TimelineEventGroup;
|
||||||
startDate: Date;
|
startTime: number;
|
||||||
endDate: Date;
|
endTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EventTimelineEventGroup = ({
|
export const EventTimelineEventGroup = ({
|
||||||
group,
|
group,
|
||||||
startDate,
|
startTime,
|
||||||
endDate,
|
endTime,
|
||||||
}: IEventTimelineEventProps) => {
|
}: IEventTimelineEventProps) => {
|
||||||
const timelineDuration = endDate.getTime() - startDate.getTime();
|
const timelineDuration = endTime - startTime;
|
||||||
const eventTime = new Date(group[0].createdAt).getTime();
|
const eventTime = group[0].timestamp;
|
||||||
|
|
||||||
const position = `${((eventTime - startDate.getTime()) / timelineDuration) * 100}%`;
|
const position = `${((eventTime - startTime) / timelineDuration) * 100}%`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledEvent position={position}>
|
<StyledEvent position={position}>
|
||||||
|
@ -68,7 +68,7 @@ export const EventTimelineEventTooltip = ({
|
|||||||
if (group.length === 1) {
|
if (group.length === 1) {
|
||||||
const event = group[0];
|
const event = group[0];
|
||||||
const eventDateTime = formatDateYMDHMS(
|
const eventDateTime = formatDateYMDHMS(
|
||||||
event.createdAt,
|
event.timestamp,
|
||||||
locationSettings?.locale,
|
locationSettings?.locale,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -85,7 +85,7 @@ export const EventTimelineEventTooltip = ({
|
|||||||
|
|
||||||
const firstEvent = group[0];
|
const firstEvent = group[0];
|
||||||
const eventDate = formatDateYMD(
|
const eventDate = formatDateYMD(
|
||||||
firstEvent.createdAt,
|
firstEvent.timestamp,
|
||||||
locationSettings?.locale,
|
locationSettings?.locale,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ export const EventTimelineEventTooltip = ({
|
|||||||
<div>
|
<div>
|
||||||
<StyledDate>
|
<StyledDate>
|
||||||
{formatDateHMS(
|
{formatDateHMS(
|
||||||
event.createdAt,
|
event.timestamp,
|
||||||
locationSettings?.locale,
|
locationSettings?.locale,
|
||||||
)}
|
)}
|
||||||
</StyledDate>
|
</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'> {
|
extends Omit<ISignal, 'createdBySourceTokenId'> {
|
||||||
tokenName: string;
|
tokenName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ISignalQuerySignal extends ISignalEndpointSignal {
|
||||||
|
sourceName?: string;
|
||||||
|
sourceDescription?: string;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user