Compare commits
4 Commits
main
...
feat/deplo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cee4a3e2e2 | ||
|
|
c0336001f5 | ||
|
|
141380f1c7 | ||
|
|
7ba6a56115 |
@@ -27,6 +27,7 @@ import { IUser } from 'providers/App/types';
|
||||
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
|
||||
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
|
||||
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
|
||||
import { MarkersProvider } from 'providers/Markers/Markers';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
@@ -379,30 +380,34 @@ function App(): JSX.Element {
|
||||
<PrivateRoute>
|
||||
<ResourceProvider>
|
||||
<QueryBuilderProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</AlertRuleProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
<MarkersProvider>
|
||||
<DashboardProvider>
|
||||
<KeyboardHotkeysProvider>
|
||||
<AlertRuleProvider>
|
||||
<AppLayout>
|
||||
<PreferenceContextProvider>
|
||||
<Suspense
|
||||
fallback={<Spinner size="large" tip="Loading..." />}
|
||||
>
|
||||
<Switch>
|
||||
{routes.map(({ path, component, exact }) => (
|
||||
<Route
|
||||
key={`${path}`}
|
||||
exact={exact}
|
||||
path={path}
|
||||
component={component}
|
||||
/>
|
||||
))}
|
||||
<Route exact path="/" component={Home} />
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
</Suspense>
|
||||
</PreferenceContextProvider>
|
||||
</AppLayout>
|
||||
</AlertRuleProvider>
|
||||
</KeyboardHotkeysProvider>
|
||||
</DashboardProvider>
|
||||
</MarkersProvider>
|
||||
</QueryBuilderProvider>
|
||||
</ResourceProvider>
|
||||
</PrivateRoute>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
.panel-markers-control {
|
||||
padding: 16px;
|
||||
.panel-markers-view-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.markers-control-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
.ant-skeleton-input{
|
||||
width: 230px;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-markers-inputs-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
.panel-markers-select-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
.custom-multiselect-wrapper {
|
||||
width: fit-content;
|
||||
min-width: 230px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
display: flex;
|
||||
min-width: 56px;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-300);
|
||||
color: var(--bg-robin-300);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px;
|
||||
|
||||
.info-icon {
|
||||
margin-left: 4px;
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
3
frontend/src/components/PanelMarkersControl/constants.ts
Normal file
3
frontend/src/components/PanelMarkersControl/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const MARKER_TYPES = {
|
||||
DEPLOYMENT: 'deployment',
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Dispatch } from 'react';
|
||||
import { useReducer } from 'react';
|
||||
|
||||
import type { MarkerControlState, MarkerQueryState } from '../types';
|
||||
|
||||
export const MARKER_ACTIONS = {
|
||||
TOGGLE_SHOW_MARKERS: 'toggleShowMarkers',
|
||||
SET_MARKER_SERVICES: 'setMarkerServices',
|
||||
SET_MARKER_TYPES: 'setMarkerTypes',
|
||||
SET_DEFAULTS_ON: 'setDefaultsOn',
|
||||
RESET: 'reset',
|
||||
} as const;
|
||||
|
||||
export type MarkerActionType = typeof MARKER_ACTIONS[keyof typeof MARKER_ACTIONS];
|
||||
|
||||
export type MarkerControlAction =
|
||||
| { type: typeof MARKER_ACTIONS.TOGGLE_SHOW_MARKERS; payload: boolean }
|
||||
| { type: typeof MARKER_ACTIONS.SET_MARKER_SERVICES; payload: string[] }
|
||||
| { type: typeof MARKER_ACTIONS.SET_MARKER_TYPES; payload: string[] }
|
||||
| {
|
||||
type: typeof MARKER_ACTIONS.SET_DEFAULTS_ON;
|
||||
payload: { markerServices: string[]; markerTypes: string[] };
|
||||
}
|
||||
| { type: typeof MARKER_ACTIONS.RESET };
|
||||
|
||||
function normalizeInitialState(
|
||||
state: MarkerQueryState | null,
|
||||
): MarkerControlState {
|
||||
return {
|
||||
showMarkers: state?.showMarkers ? 1 : 0,
|
||||
markerServices: state?.markerServices || [],
|
||||
markerTypes: state?.markerTypes || [],
|
||||
};
|
||||
}
|
||||
|
||||
function reducer(
|
||||
state: MarkerControlState,
|
||||
action: MarkerControlAction,
|
||||
): MarkerControlState {
|
||||
switch (action.type) {
|
||||
case MARKER_ACTIONS.TOGGLE_SHOW_MARKERS:
|
||||
return { ...state, showMarkers: action.payload ? 1 : 0 };
|
||||
case MARKER_ACTIONS.SET_MARKER_SERVICES:
|
||||
return { ...state, markerServices: action.payload };
|
||||
case MARKER_ACTIONS.SET_MARKER_TYPES:
|
||||
return { ...state, markerTypes: action.payload };
|
||||
case MARKER_ACTIONS.SET_DEFAULTS_ON:
|
||||
return {
|
||||
...state,
|
||||
showMarkers: 1,
|
||||
markerServices: action.payload.markerServices,
|
||||
markerTypes: action.payload.markerTypes,
|
||||
};
|
||||
case MARKER_ACTIONS.RESET:
|
||||
return { showMarkers: 0, markerServices: [], markerTypes: [] };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function useMarkerControlState(
|
||||
initialQueryState: MarkerQueryState | null,
|
||||
): {
|
||||
store: MarkerControlState;
|
||||
dispatch: Dispatch<MarkerControlAction>;
|
||||
} {
|
||||
const initial = normalizeInitialState(initialQueryState);
|
||||
const [store, dispatch] = useReducer(reducer, initial);
|
||||
return { store, dispatch };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
clearLocalStorageState,
|
||||
getLocalStorageState,
|
||||
getMarkerStateFromQuery,
|
||||
getQueryParamsFromState,
|
||||
setLocalStorageState,
|
||||
} from 'components/PanelMarkersControl/utils';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
type MarkerHandlers = {
|
||||
onMarkerToggleOn: () => void;
|
||||
onMarkerToggleOff: () => void;
|
||||
};
|
||||
|
||||
const useMarkerHandlers = ({ key }: { key: string }): MarkerHandlers => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
// useEffect to sync url query with local storage
|
||||
useEffect(() => {
|
||||
const queryState = getMarkerStateFromQuery(urlQuery);
|
||||
const localStorageState = getLocalStorageState(key);
|
||||
|
||||
if (queryState === null && localStorageState?.showMarkers) {
|
||||
const params = new URLSearchParams(search);
|
||||
const queryParams = getQueryParamsFromState(params, localStorageState);
|
||||
history.replace({ search: queryParams.toString() });
|
||||
} else {
|
||||
setLocalStorageState(key, queryState);
|
||||
}
|
||||
}, [urlQuery, key, search, history]);
|
||||
|
||||
const onMarkerToggleOn = useCallback(() => {
|
||||
// set defaults for service and marker type
|
||||
const params = new URLSearchParams(search);
|
||||
params.set('showMarkers', '1');
|
||||
history.replace({ search: params.toString() });
|
||||
}, [search, history]);
|
||||
|
||||
const onMarkerToggleOff = useCallback(() => {
|
||||
// important to clear both url query and local storage here. Else url local storage sync useEffect will not work as expected.
|
||||
clearLocalStorageState(key);
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
params.delete('showMarkers');
|
||||
params.delete('markerServices');
|
||||
params.delete('markerTypes');
|
||||
history.replace({ search: params.toString() });
|
||||
}, [key, search, history]);
|
||||
|
||||
return { onMarkerToggleOn, onMarkerToggleOff };
|
||||
};
|
||||
|
||||
export default useMarkerHandlers;
|
||||
234
frontend/src/components/PanelMarkersControl/index.tsx
Normal file
234
frontend/src/components/PanelMarkersControl/index.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import './PanelMarkersControl.scss';
|
||||
|
||||
import { Skeleton, Switch, Typography } from 'antd';
|
||||
import CustomMultiSelect from 'components/NewSelect/CustomMultiSelect';
|
||||
import { MARKER_TYPES } from 'components/PanelMarkersControl/constants';
|
||||
import useMarkerControlState, {
|
||||
MARKER_ACTIONS,
|
||||
} from 'components/PanelMarkersControl/hooks/useMarkerControlState';
|
||||
import useMarkerHandlers from 'components/PanelMarkersControl/hooks/useMarkerHandlers';
|
||||
import {
|
||||
getInitialStateForControls,
|
||||
getQueryParamsFromState,
|
||||
} from 'components/PanelMarkersControl/utils';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useFetchMarkersData, useMarkers } from 'providers/Markers/Markers';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
|
||||
function PanelMarkersControl(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
const { selectedDashboard } = useDashboard();
|
||||
|
||||
const { store: markerControlState, dispatch } = useMarkerControlState(
|
||||
getInitialStateForControls(selectedDashboard?.id || '', urlQuery),
|
||||
);
|
||||
|
||||
const { markersData, setMarkersData } = useMarkers();
|
||||
|
||||
const { loadingMarkers } = useFetchMarkersData({
|
||||
isFetchEnabled: markerControlState.showMarkers === 1,
|
||||
});
|
||||
|
||||
const { onMarkerToggleOn, onMarkerToggleOff } = useMarkerHandlers({
|
||||
key: selectedDashboard?.id || '',
|
||||
});
|
||||
|
||||
// API integration: check if this is correct
|
||||
const markerTypeOptions = useMemo(() => {
|
||||
const uniqueTypes = Array.from(
|
||||
new Set((markersData || []).map((m: any) => m?.type).filter(Boolean)),
|
||||
);
|
||||
return uniqueTypes.map((t: string) => ({
|
||||
label: t.charAt(0).toUpperCase() + t.slice(1),
|
||||
value: t,
|
||||
}));
|
||||
}, [markersData]);
|
||||
|
||||
// API integration: check if this is correct
|
||||
const serviceNameOptions = useMemo(() => {
|
||||
const uniqueServices = Array.from(
|
||||
new Set(
|
||||
(markersData || [])
|
||||
.map((m: any) => m?.attr?.['service.name'])
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
return uniqueServices.map((s: string) => ({ label: s, value: s }));
|
||||
}, [markersData]);
|
||||
|
||||
const handleServiceChange = useCallback(
|
||||
(serviceOrServices: string | string[] | undefined): void => {
|
||||
let servicesArray: string[] = [];
|
||||
if (Array.isArray(serviceOrServices)) {
|
||||
servicesArray = serviceOrServices;
|
||||
} else if (serviceOrServices) {
|
||||
servicesArray = [serviceOrServices];
|
||||
}
|
||||
dispatch({
|
||||
type: MARKER_ACTIONS.SET_MARKER_SERVICES,
|
||||
payload: servicesArray,
|
||||
});
|
||||
|
||||
// sync URL param
|
||||
const params = new URLSearchParams(search);
|
||||
if (servicesArray.length > 0) {
|
||||
params.set('markerServices', servicesArray.join(','));
|
||||
} else {
|
||||
params.delete('markerServices');
|
||||
}
|
||||
history.replace({ search: params.toString() });
|
||||
},
|
||||
[history, search, dispatch],
|
||||
);
|
||||
|
||||
const handleMarkerTypesChange = useCallback(
|
||||
(typesOrArray: string | string[] | undefined): void => {
|
||||
let typesArray: string[] = [];
|
||||
if (Array.isArray(typesOrArray)) {
|
||||
typesArray = typesOrArray;
|
||||
} else if (typesOrArray) {
|
||||
typesArray = [typesOrArray];
|
||||
}
|
||||
dispatch({ type: MARKER_ACTIONS.SET_MARKER_TYPES, payload: typesArray });
|
||||
|
||||
const params = new URLSearchParams(search);
|
||||
if (typesArray.length > 0) {
|
||||
params.set('markerTypes', typesArray.join(','));
|
||||
} else {
|
||||
params.delete('markerTypes');
|
||||
}
|
||||
history.replace({ search: params.toString() });
|
||||
},
|
||||
[history, search, dispatch],
|
||||
);
|
||||
|
||||
const handleToggleShowMarkers = useCallback(
|
||||
(checked: boolean): void => {
|
||||
dispatch({ type: MARKER_ACTIONS.TOGGLE_SHOW_MARKERS, payload: checked });
|
||||
if (checked) {
|
||||
// get default services and marker types from markersData
|
||||
onMarkerToggleOn();
|
||||
} else {
|
||||
// consider using useReducer to reset the state
|
||||
setMarkersData([]);
|
||||
dispatch({ type: MARKER_ACTIONS.RESET });
|
||||
onMarkerToggleOff();
|
||||
}
|
||||
},
|
||||
[onMarkerToggleOn, onMarkerToggleOff, dispatch, setMarkersData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (markersData.length < 1) return;
|
||||
/**
|
||||
* On markers data change, derive defaults (or use query selections if present)
|
||||
* and set them in a single reducer dispatch.
|
||||
*/
|
||||
const queryMarkerServicesRaw = urlQuery.get('markerServices') || '';
|
||||
const queryMarkerTypesRaw = urlQuery.get('markerTypes') || '';
|
||||
|
||||
const defMarkerServices = ['cart-service'];
|
||||
const defMarkerTypes = [MARKER_TYPES.DEPLOYMENT];
|
||||
|
||||
const servicesArray = queryMarkerServicesRaw
|
||||
? queryMarkerServicesRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
: defMarkerServices;
|
||||
const typesArray = queryMarkerTypesRaw
|
||||
? queryMarkerTypesRaw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
: defMarkerTypes;
|
||||
|
||||
dispatch({
|
||||
type: MARKER_ACTIONS.SET_DEFAULTS_ON,
|
||||
payload: { markerServices: servicesArray, markerTypes: typesArray },
|
||||
});
|
||||
|
||||
// reflect in URL params as well
|
||||
const params = new URLSearchParams(search);
|
||||
const queryParams = getQueryParamsFromState(params, {
|
||||
showMarkers: 1,
|
||||
markerServices: servicesArray,
|
||||
markerTypes: typesArray,
|
||||
});
|
||||
history.replace({ search: queryParams.toString() });
|
||||
|
||||
console.log('>>> markersData', markersData);
|
||||
// urlQuery removed from dependencies as not able to unset markerTypes. But works with markerServices. [CHECK THIS]
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [markersData]);
|
||||
|
||||
// ADD INITIAL STATE FOR SELECTED SERVICES AND MARKER TYPES
|
||||
|
||||
return (
|
||||
<div className="panel-markers-control">
|
||||
<div className="panel-markers-view-controller">
|
||||
<Typography>Show Markers</Typography>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={markerControlState.showMarkers === 1}
|
||||
onChange={handleToggleShowMarkers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{markerControlState.showMarkers === 1 && (
|
||||
<div className="panel-markers-inputs-section">
|
||||
{loadingMarkers ? (
|
||||
<div className="markers-control-skeleton">
|
||||
<Skeleton.Input active size="small" />
|
||||
<Skeleton.Input active size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="panel-markers-select-container">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
Marker type
|
||||
</Typography.Text>
|
||||
<CustomMultiSelect
|
||||
className="panel-markers-select"
|
||||
placeholder="Select one or more marker types"
|
||||
enableAllSelection={false}
|
||||
options={markerTypeOptions}
|
||||
maxTagCount={3}
|
||||
value={markerControlState.markerTypes}
|
||||
onChange={handleMarkerTypesChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="panel-markers-select-container">
|
||||
<Typography.Text className="variable-name" ellipsis>
|
||||
Service name
|
||||
</Typography.Text>
|
||||
<CustomMultiSelect
|
||||
className="panel-markers-select"
|
||||
placeholder="Select one or more service names"
|
||||
maxTagCount={3}
|
||||
enableAllSelection={false}
|
||||
options={serviceNameOptions}
|
||||
value={markerControlState.markerServices}
|
||||
onChange={handleServiceChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// select bright color for the markers
|
||||
// convert panel marker state to useReducer
|
||||
// removed urlQuery from dependencies.
|
||||
// filters on markersData should work properly. If no markers selected. Show no markers.
|
||||
|
||||
export default PanelMarkersControl;
|
||||
7
frontend/src/components/PanelMarkersControl/types.ts
Normal file
7
frontend/src/components/PanelMarkersControl/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface MarkerControlState {
|
||||
showMarkers: number;
|
||||
markerServices: string[];
|
||||
markerTypes: string[];
|
||||
}
|
||||
|
||||
export type MarkerQueryState = MarkerControlState | null;
|
||||
108
frontend/src/components/PanelMarkersControl/utils.ts
Normal file
108
frontend/src/components/PanelMarkersControl/utils.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { MarkerQueryState } from 'components/PanelMarkersControl/types';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
// CHECK LOGIC AND CREATE UTIL
|
||||
export function getMarkerStateFromQuery(
|
||||
urlQuery: URLSearchParams,
|
||||
): MarkerQueryState | null {
|
||||
const showMarkers = urlQuery.get('showMarkers') === '1';
|
||||
const servicesRaw = urlQuery.get('markerServices') || '';
|
||||
|
||||
if (!showMarkers) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const markerTypesParam = urlQuery.get('markerTypes') || '';
|
||||
|
||||
const markerTypes = markerTypesParam
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
const markerServices = servicesRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
|
||||
return {
|
||||
showMarkers: 1,
|
||||
markerServices,
|
||||
markerTypes,
|
||||
};
|
||||
}
|
||||
|
||||
export const getLocalStorageState = (key: string): MarkerQueryState | null => {
|
||||
const raw = localStorage.getItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE);
|
||||
try {
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
return parsed?.[key] ?? null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getQueryParamsFromState = (
|
||||
params: URLSearchParams,
|
||||
state: MarkerQueryState,
|
||||
): URLSearchParams => {
|
||||
if (!state) {
|
||||
return params;
|
||||
}
|
||||
if (state.showMarkers) {
|
||||
params.set('showMarkers', String(state.showMarkers) || '0');
|
||||
}
|
||||
if (Array.isArray(state.markerServices) && state.markerServices.length > 0) {
|
||||
params.set('markerServices', state.markerServices.join(','));
|
||||
}
|
||||
if (Array.isArray(state.markerTypes) && state.markerTypes.length > 0) {
|
||||
params.set('markerTypes', state.markerTypes.join(','));
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
export const setLocalStorageState = (
|
||||
key: string,
|
||||
state: MarkerQueryState | null,
|
||||
): void => {
|
||||
if (!key || key.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE);
|
||||
let obj: Record<string, unknown> = {};
|
||||
try {
|
||||
obj = raw ? JSON.parse(raw) : {};
|
||||
} catch (_) {
|
||||
obj = {};
|
||||
}
|
||||
obj[key] = state;
|
||||
localStorage.setItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE, JSON.stringify(obj));
|
||||
} catch (_) {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
export const getInitialStateForControls = (
|
||||
key: string,
|
||||
urlQuery: URLSearchParams,
|
||||
): MarkerQueryState | null => {
|
||||
const queryState = getMarkerStateFromQuery(urlQuery);
|
||||
const localStorageState = getLocalStorageState(key);
|
||||
return queryState ?? localStorageState;
|
||||
};
|
||||
|
||||
export const clearLocalStorageState = (key: string): void => {
|
||||
if (!key || key.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
const raw = localStorage.getItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE);
|
||||
let obj: Record<string, unknown> = {};
|
||||
try {
|
||||
obj = raw ? JSON.parse(raw) : {};
|
||||
} catch (_) {
|
||||
obj = {};
|
||||
}
|
||||
delete obj[key];
|
||||
localStorage.setItem(LOCALSTORAGE.MARKERS_OVERLAY_STATE, JSON.stringify(obj));
|
||||
};
|
||||
177
frontend/src/components/Uplot/plugins/verticalMarkersPlugin.ts
Normal file
177
frontend/src/components/Uplot/plugins/verticalMarkersPlugin.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import uPlot from 'uplot';
|
||||
|
||||
type MarkersData = { id: string | number; val: number; stroke?: string };
|
||||
|
||||
export function verticalMarkersPlugin({
|
||||
markersData = [],
|
||||
lineType = [5, 3],
|
||||
width = 1,
|
||||
}: {
|
||||
markersData?: MarkersData[];
|
||||
lineType?: number[];
|
||||
width?: number;
|
||||
} = {}): uPlot.Plugin {
|
||||
const DEFAULT_STROKE = 'rgba(0, 102, 255, 0.95)';
|
||||
let removeListeners: (() => void) | null = null;
|
||||
let tooltipEl: HTMLDivElement | null = null;
|
||||
const renderAxisMarkers = (uu: uPlot): void => {
|
||||
const axes = uu.root.querySelectorAll('.u-axis');
|
||||
const xAxis = (axes && (axes[0] as HTMLElement)) || null;
|
||||
if (!xAxis) return;
|
||||
|
||||
// attach delegated hover/mouseout listeners once on the x-axis container
|
||||
if (!(xAxis as HTMLElement).dataset?.vlineHoverAttached) {
|
||||
const onMouseOver = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target?.classList?.contains('vline-triangle-marker')) return;
|
||||
|
||||
const { id } = target.dataset;
|
||||
const valStr = target.dataset.val;
|
||||
const val = valStr ? Number(valStr) : undefined;
|
||||
// const mData = markersData.find((d) => String(d.id) === String(id));
|
||||
// create tooltip
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.className = 'vline-marker-tooltip';
|
||||
Object.assign(tooltipEl.style, {
|
||||
position: 'fixed',
|
||||
padding: '6px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
zIndex: '10000',
|
||||
pointerEvents: 'none',
|
||||
background: '#111827',
|
||||
color: '#e5e7eb',
|
||||
border: '1px solid #374151',
|
||||
});
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
tooltipEl.textContent = `id: ${id ?? ''} • ts: ${val ?? ''}`;
|
||||
// position near cursor
|
||||
tooltipEl.style.left = `${e.clientX + 10}px`;
|
||||
tooltipEl.style.top = `${e.clientY - 28}px`;
|
||||
};
|
||||
const onMouseOut = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target?.classList?.contains('vline-triangle-marker')) return;
|
||||
if (tooltipEl) {
|
||||
tooltipEl.remove();
|
||||
tooltipEl = null;
|
||||
}
|
||||
};
|
||||
xAxis.addEventListener('mouseover', onMouseOver);
|
||||
xAxis.addEventListener('mouseout', onMouseOut);
|
||||
removeListeners = (): void => {
|
||||
xAxis.removeEventListener('mouseover', onMouseOver);
|
||||
xAxis.removeEventListener('mouseout', onMouseOut);
|
||||
};
|
||||
(xAxis as HTMLElement).dataset.vlineHoverAttached = '1';
|
||||
}
|
||||
|
||||
// cleanup markers to avoid duplicates on rerender/resize
|
||||
xAxis.querySelectorAll('.vline-triangle-marker').forEach((el) => el.remove());
|
||||
|
||||
const plotLeft = uu.bbox.left;
|
||||
const plotRight = plotLeft + uu.bbox.width;
|
||||
|
||||
for (let i = 0; i < markersData.length; i++) {
|
||||
const mData = markersData[i];
|
||||
const xAbs = uu.valToPos(mData.val, 'x', true);
|
||||
if (xAbs >= plotLeft && xAbs <= plotRight) {
|
||||
const xPx = (xAbs - plotLeft) / window.devicePixelRatio;
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'vline-triangle-marker';
|
||||
marker.dataset.id = String(mData.id); // may change later after BE discussion
|
||||
marker.dataset.val = String(mData.val); // TODO: remove this later
|
||||
Object.assign(marker.style, {
|
||||
position: 'absolute',
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderBottomWidth: '5px',
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: mData.stroke || DEFAULT_STROKE,
|
||||
transform: 'translateX(-50%)',
|
||||
cursor: 'pointer',
|
||||
zIndex: '1',
|
||||
left: `${xPx}px`,
|
||||
});
|
||||
|
||||
xAxis.appendChild(marker);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
hooks: {
|
||||
destroy: [
|
||||
(): void => {
|
||||
if (tooltipEl) {
|
||||
tooltipEl.remove();
|
||||
tooltipEl = null;
|
||||
}
|
||||
if (removeListeners) {
|
||||
removeListeners();
|
||||
removeListeners = null;
|
||||
}
|
||||
},
|
||||
],
|
||||
drawAxes: [
|
||||
(uu: uPlot): void => {
|
||||
renderAxisMarkers(uu);
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
(uu: uPlot): void => {
|
||||
const { ctx } = uu;
|
||||
const { top } = uu.bbox;
|
||||
const bottom = top + uu.bbox.height;
|
||||
const plotLeft = uu.bbox.left;
|
||||
const plotRight = plotLeft + uu.bbox.width;
|
||||
|
||||
ctx.save();
|
||||
for (let i = 0; i < markersData.length; i++) {
|
||||
const mData = markersData[i];
|
||||
const x = uu.valToPos(mData.val, 'x', true);
|
||||
// only draw if within plot bounds
|
||||
if (x >= plotLeft && x <= plotRight) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = mData.stroke || DEFAULT_STROKE;
|
||||
ctx.lineWidth = width;
|
||||
ctx.setLineDash(lineType || []);
|
||||
ctx.moveTo(x, top);
|
||||
ctx.lineTo(x, bottom);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// MOVE TO REACT. or use portal for tooltip.
|
||||
// correct the format should work with expected data from BE
|
||||
// Remove cognitive complexity rule.
|
||||
|
||||
// logic to get marker plugin to be added to context
|
||||
// depending on type of marker parse the data to choose color. example deployment should be red etc.
|
||||
// pass such data with multple colors to check render.
|
||||
// PERF CHECK.
|
||||
|
||||
// drive data passing from the context for markers. data in this is enriched only when we
|
||||
// use <ShowMarkers /> component. else it will be empty.
|
||||
// plugins. and pass marker hooks using this
|
||||
// should only pass marker hook if data is present(shouldRenderMarker memo)
|
||||
// depending on type of marker parse the data to choose color. example deployment should be red etc.
|
||||
// pass such data with multple colors to check render.
|
||||
// PERF CHECK.
|
||||
|
||||
// drive data passing from the context for markers. data in this is enriched only when we
|
||||
// use <ShowMarkers /> component. else it will be empty.
|
||||
@@ -36,4 +36,5 @@ export enum LOCALSTORAGE {
|
||||
LAST_USED_CUSTOM_TIME_RANGES = 'LAST_USED_CUSTOM_TIME_RANGES',
|
||||
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
MARKERS_OVERLAY_STATE = 'MARKERS_OVERLAY_STATE',
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import PanelMarkersControl from 'components/PanelMarkersControl';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -488,6 +489,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
<DashboardVariableSelection />
|
||||
</section>
|
||||
)}
|
||||
<PanelMarkersControl />
|
||||
<DashboardGraphSlider />
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import './UplotPanelWrapper.styles.scss';
|
||||
|
||||
import { Alert } from 'antd';
|
||||
import { ToggleGraphProps } from 'components/Graph/types';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { verticalMarkersPlugin } from 'components/Uplot/plugins/verticalMarkersPlugin';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import GraphManager from 'container/GridCardLayout/GridCard/FullView/GraphManager';
|
||||
import { getLocalStorageGraphVisibilityState } from 'container/GridCardLayout/GridCard/utils';
|
||||
@@ -17,6 +20,7 @@ import { cloneDeep, isEqual, isUndefined } from 'lodash-es';
|
||||
import _noop from 'lodash-es/noop';
|
||||
import { ContextMenu, useCoordinates } from 'periscope/components/ContextMenu';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useMarkers } from 'providers/Markers/Markers';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -55,6 +59,32 @@ function UplotPanelWrapper({
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { filteredMarkersData, shouldShowMarkers } = useMarkers();
|
||||
|
||||
const markersPlugin: uPlot.Plugin | null = useMemo(() => {
|
||||
if (shouldShowMarkers) {
|
||||
console.log('*** filteredMarkersData', filteredMarkersData);
|
||||
console.log('*** shouldShowMarkers', {
|
||||
markersData: [
|
||||
{ id: 'm1', val: 1760625000, stroke: 'rgba(96, 255, 128, 0.95)' },
|
||||
{ id: 'm2', val: 1760630000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
{ id: 'm3', val: 1760640000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
],
|
||||
lineType: [6, 4],
|
||||
width: 1,
|
||||
});
|
||||
return verticalMarkersPlugin({
|
||||
markersData: [
|
||||
{ id: 'm1', val: 1760625000, stroke: 'rgba(96, 255, 128, 0.95)' },
|
||||
{ id: 'm2', val: 1760630000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
{ id: 'm3', val: 1760640000, stroke: 'rgba(255, 96, 96, 0.95)' },
|
||||
],
|
||||
lineType: [6, 4],
|
||||
width: 1,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [shouldShowMarkers, filteredMarkersData]);
|
||||
|
||||
const [hiddenGraph, setHiddenGraph] = useState<{ [key: string]: boolean }>();
|
||||
|
||||
@@ -249,6 +279,7 @@ function UplotPanelWrapper({
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
customPlugins: [...(markersPlugin ? [markersPlugin] : [])],
|
||||
}),
|
||||
[
|
||||
queryResponse.data?.payload,
|
||||
@@ -270,6 +301,7 @@ function UplotPanelWrapper({
|
||||
onClickHandler,
|
||||
widget,
|
||||
stackedBarChart,
|
||||
markersPlugin,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface GetUPlotChartOptions {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => void;
|
||||
customPlugins?: uPlot.Plugin[];
|
||||
}
|
||||
|
||||
/** the function converts series A , series B , series C to
|
||||
@@ -218,6 +219,7 @@ export const getUPlotChartOptions = ({
|
||||
query,
|
||||
legendScrollPosition,
|
||||
setLegendScrollPosition,
|
||||
customPlugins = [],
|
||||
}: GetUPlotChartOptions): uPlot.Options => {
|
||||
const timeScaleProps = getXAxisScale(minTimeScale, maxTimeScale);
|
||||
|
||||
@@ -387,6 +389,7 @@ export const getUPlotChartOptions = ({
|
||||
],
|
||||
},
|
||||
},
|
||||
...customPlugins,
|
||||
],
|
||||
hooks: {
|
||||
draw: [
|
||||
|
||||
184
frontend/src/providers/Markers/Markers.tsx
Normal file
184
frontend/src/providers/Markers/Markers.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
type Marker = {
|
||||
type: string;
|
||||
env: string;
|
||||
source: string;
|
||||
attr: {
|
||||
version: string;
|
||||
firstSeen: number;
|
||||
'service.name': string;
|
||||
region: string;
|
||||
spanCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
function generateMockMarkers(
|
||||
minTime: number,
|
||||
maxTime: number,
|
||||
count = 10,
|
||||
): { markers: Marker[] } {
|
||||
const step = (maxTime - minTime) / count;
|
||||
|
||||
const services = [
|
||||
'cart-service',
|
||||
'checkout-service',
|
||||
'inventory-service',
|
||||
'auth-service',
|
||||
'payment-service',
|
||||
'recommendation-service',
|
||||
'search-service',
|
||||
'user-service',
|
||||
'notification-service',
|
||||
'analytics-service',
|
||||
];
|
||||
|
||||
const regions = [
|
||||
'us-east-1',
|
||||
'us-west-1',
|
||||
'us-east-2',
|
||||
'eu-central-1',
|
||||
'ap-southeast-1',
|
||||
'eu-west-1',
|
||||
];
|
||||
const envs = ['prod-us', 'prod-eu', 'staging', 'prod-ap'];
|
||||
const sources = ['traces', 'logs'];
|
||||
|
||||
const markers: Marker[] = Array.from({ length: count }).map((_, i) => {
|
||||
const firstSeen = Math.round(minTime + i * step);
|
||||
const service = services[i % services.length];
|
||||
const env = envs[i % envs.length];
|
||||
const region = regions[i % regions.length];
|
||||
const source = sources[i % sources.length];
|
||||
|
||||
return {
|
||||
type: 'deployment',
|
||||
env,
|
||||
source,
|
||||
attr: {
|
||||
version: `1.${160 + i}.0-${env.includes('prod') ? 'prod' : 'stg'}`,
|
||||
firstSeen,
|
||||
'service.name': service,
|
||||
region,
|
||||
spanCount: 100 + Math.floor(Math.random() * 150),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { markers };
|
||||
}
|
||||
|
||||
type MarkersContextValue = {
|
||||
markersData: Marker[];
|
||||
filteredMarkersData: Marker[];
|
||||
setMarkersData: (markersData: Marker[]) => void;
|
||||
shouldShowMarkers: boolean;
|
||||
};
|
||||
|
||||
const MarkersContext = createContext<MarkersContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function MarkersProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element {
|
||||
const [markersData, setMarkersData] = useState<Marker[]>([]);
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
// CHECK LOGIC AND CREATE UTIL
|
||||
const filteredMarkersData = useMemo(() => {
|
||||
const servicesRaw = urlQuery.get('markerServices') || '';
|
||||
const typesRaw = urlQuery.get('markerTypes') || '';
|
||||
const selectedServices = servicesRaw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
const selectedTypes = typesRaw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
if (selectedServices.length === 0 && selectedTypes.length === 0) {
|
||||
return markersData;
|
||||
}
|
||||
|
||||
return (markersData || []).filter((m: Marker) => {
|
||||
const typeOk = selectedTypes.includes(m?.type);
|
||||
const serviceOk = selectedServices.includes(m?.attr?.['service.name']);
|
||||
return typeOk && serviceOk;
|
||||
});
|
||||
}, [urlQuery, markersData]);
|
||||
|
||||
const shouldShowMarkers = useMemo(() => !!urlQuery.get('showMarkers'), [
|
||||
urlQuery,
|
||||
]);
|
||||
|
||||
console.log('*** filteredMarkersData', filteredMarkersData);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
markersData,
|
||||
filteredMarkersData,
|
||||
setMarkersData,
|
||||
shouldShowMarkers,
|
||||
}),
|
||||
[markersData, filteredMarkersData, setMarkersData, shouldShowMarkers],
|
||||
);
|
||||
|
||||
return (
|
||||
<MarkersContext.Provider value={value}>{children}</MarkersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMarkers(): MarkersContextValue {
|
||||
const ctx = useContext(MarkersContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useMarkers must be used within a MarkersProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useFetchMarkersData({
|
||||
isFetchEnabled,
|
||||
}: {
|
||||
isFetchEnabled: boolean;
|
||||
}): { loadingMarkers: boolean } {
|
||||
const { setMarkersData } = useMarkers();
|
||||
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const { data, isLoading, isFetching } = useQuery<{ markers: Marker[] }>(
|
||||
['mock-markers', minTime, maxTime],
|
||||
async () => {
|
||||
// simulate network latency without returning from the executor
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 2000);
|
||||
});
|
||||
return generateMockMarkers(minTime, maxTime, 10);
|
||||
},
|
||||
{
|
||||
enabled: isFetchEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
console.log('*** setting markers data', data.markers);
|
||||
setMarkersData(data.markers);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
return {
|
||||
loadingMarkers: isLoading || isFetching,
|
||||
};
|
||||
}
|
||||
|
||||
export default MarkersProvider;
|
||||
Reference in New Issue
Block a user